How to build layout like GrabRewards
I published post Implement flexible UI with UITableView this week and get requested to make a demo like GrabRewards.
This post tells you how to build GrabRewards with my knStaticListController
. Check it out.
Preparation
Download the starter project at GrabRewards.
Master branch is the starter. You can checkout branch completed
to see the result.
We build the layout like this screen
I don't build the whole screen, stuffs below are excluded.
- Swipe top tab (Browse, My Rewards)
- Call APIs (dummy data only)
- Bottom point bar (A solid color view instead)
Let's begin.
The first part: Category
I put a UICollectionView
inside a view, CategoryView
. And CategoryView
is put inside a knTableCell
.
1. Add model
Analyze the target UI, we have a collection of category (icon and name).
struct Category {
var icon: String?
var name: String?
init(icon: String, name: String) {
self.icon = icon
self.name = name
}
}
2. CategoryCell
Setup layout and data for collection view cell
let padding: CGFloat = 24
class CategoryCollectionCell: knCollectionCell {
let iconImageView = UIMaker.makeImageView()
let nameLabel = UIMaker.makeLabel(alignment: .center)
// 1
var data: Category? {
didSet {
iconImageView.image = UIImage(named: data?.icon ?? "")
nameLabel.text = data?.name
}
}
override func setupView() {
// 2
let iconWrapper = UIMaker.makeView(background: .lightGray)
iconWrapper.addSubviews(views: iconImageView)
iconImageView.square(edge: 32)
iconImageView.center(toView: iconWrapper)
addSubviews(views: iconWrapper, nameLabel)
iconWrapper.setCorner(radius: 36)
iconWrapper.square()
iconWrapper.horizontal(toView: self, space: padding / 2)
iconWrapper.top(toView: self, space: padding)
nameLabel.horizontal(toView: self, space: padding / 2)
nameLabel.verticalSpacing(toView: iconWrapper, space: padding / 2)
}
}
The init functions don't have many code, so I put it in the last of the class. Focus on layout.
(1)
I always set the variable for cell data is data
, no matter what the data types are.
I want to get something:
let something = data.something
instead of
let something = event.something
let something_else = photo.something_else
let other = category.other
NOTE
Just use and remember
data
.
(2)
- Setup the layout for icon and label. The icon is inside a circle with a spacing, so I add the icon into a view, and add spacing to that view.
32
,36
are fixed for this demo. You can try some different numbers to understand. If you change them, you need to change the width (96px) of the collection view cell (next part).padding / 2
: is spacing from icon wrapper/nameLabel
to leading and trailing.
padding / 2
?
Why - Assume x = padding / 2
- The layout like this
|-x-[item_1]-x-| |-x-[item_2]-x-| |-x-[item_3]-x-|
- The spacing between
item_1
anditem_2
is2x = padding
, equal to the standard spacing of all layout. => consistent and more beautiful.
3. CategoryView
private let viewHeight: CGFloat = 150
class CategoryView: knView {
var datasource = [Category]() { didSet { collectionView.reloadData() }}
var collectionView: UICollectionView!
override func setupView() {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .white
collectionView.showsHorizontalScrollIndicator = false
collectionView.showsVerticalScrollIndicator = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(CategoryCollectionCell.self,
forCellWithReuseIdentifier: "CategoryCollectionCell")
collectionView.contentInset = UIEdgeInsets(left: padding / 2, right: padding / 2)
addSubviews(views: collectionView)
collectionView.fill(toView: self)
collectionView.height(viewHeight)
}
}
extension CategoryView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return datasource.count }
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CategoryCollectionCell", for: indexPath) as! CategoryCollectionCell
cell.data = datasource[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: 96, height: viewHeight)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 0 // remove spacing between 2 cells in UICollectionView
}
}
4. ViewController
Add CategoryView
into cell and see how it renders in UITableView.
class ViewController: knStaticListController {
override func viewDidLoad() {
super.viewDidLoad()
setupView()
}
let categoryView = CategoryView()
override func setupView() {
view.addSubviews(views: tableView)
tableView.fill(toView: view)
let categoryCell = knTableCell.wrap(view: categoryView, space: UIEdgeInsets(bottom: 12))
categoryCell.backgroundColor = .lightGray
datasource = [categoryCell]
categoryView.datasource = [
Category(icon: "1", name: "All"),
Category(icon: "2", name: "Limit Edition"),
Category(icon: "3", name: "Food"),
Category(icon: "4", name: "Grab"),
Category(icon: "5", name: "Service"),
Category(icon: "6", name: "Shopping"),
Category(icon: "7", name: "Entertainment"),
Category(icon: "8", name: "Travel"),
]
}
}
Run and you see first part of GrabRewards layout.
5. Reward
Similar to Category, Reward
, RewardCollectionCell
, RewardView
is setup the auto layout for UICollectionView. The code is quite similar. If some codes are not clear enough, let me know in comment.
struct Reward {
var merchantLogoUrl: String?
var merchantName: String?
var bannerUrl: String?
var title: String?
var point = 0
init(merchantLogo: String, merchantName: String, banner: String, title: String, point: Int) {
self.merchantLogoUrl = merchantLogo
self.merchantName = merchantName
self.bannerUrl = banner
self.title = title
self.point = point
}
}
6. RewardCollectionCell
class RewardCollectionCell: knCollectionCell {
let bannerImageView = UIMaker.makeImageView(contentMode: .scaleAspectFill)
let logoImageView = UIMaker.makeImageView()
let merchantNameLabel = UIMaker.makeLabel(font: UIFont.boldSystemFont(ofSize: 15),
color: .white)
let titleLabel = UIMaker.makeLabel(font: UIFont.boldSystemFont(ofSize: 15),
color: .black)
let pointLabel = UIMaker.makeLabel(font: UIFont.systemFont(ofSize: 15),
color: .black)
var data: Reward? {
didSet {
bannerImageView.downloadImage(from: data?.bannerUrl)
logoImageView.downloadImage(from: data?.merchantLogoUrl)
merchantNameLabel.text = data?.merchantName
titleLabel.text = data?.title
let point = data?.point ?? 0
pointLabel.text = "\(point) points"
}
}
override func setupView() {
addSubviews(views: bannerImageView, logoImageView, merchantNameLabel, titleLabel, pointLabel)
bannerImageView.horizontal(toView: self, leftPadding: 0, rightPadding: -padding / 2)
bannerImageView.top(toView: self)
bannerImageView.height(200)
bannerImageView.setCorner(radius: 7)
logoImageView.bottomLeft(toView: bannerImageView, bottom: -padding / 2, left: padding / 2)
logoImageView.square(edge: 32)
logoImageView.setCorner(radius: 7)
merchantNameLabel.leftHorizontalSpacing(toView: logoImageView, space: -padding / 2)
merchantNameLabel.centerY(toView: logoImageView)
titleLabel.horizontal(toView: bannerImageView)
titleLabel.verticalSpacing(toView: bannerImageView, space: padding / 2)
pointLabel.left(toView: titleLabel)
pointLabel.verticalSpacing(toView: titleLabel, space: padding / 2)
pointLabel.bottom(toView: self, space: -padding)
}
}
7. RewardView
private let viewHeight: CGFloat = 285
class RewardView: knView {
let titleLabel = UIMaker.makeLabel(font: UIFont.boldSystemFont(ofSize: 20))
var datasource = [Reward]() { didSet { collectionView.reloadData() }}
var collectionView: UICollectionView!
convenience init(title: String) {
self.init()
titleLabel.text = title
}
override func setupView() {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.backgroundColor = .white
collectionView.showsHorizontalScrollIndicator = false
collectionView.showsVerticalScrollIndicator = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(RewardCollectionCell.self,
forCellWithReuseIdentifier: "RewardCollectionCell")
collectionView.contentInset = UIEdgeInsets(left: padding, right: padding / 2)
addSubviews(views: titleLabel, collectionView)
titleLabel.topLeft(toView: self, top: padding, left: padding)
collectionView.horizontal(toView: self)
collectionView.verticalSpacing(toView: titleLabel, space: padding / 2)
collectionView.bottom(toView: self)
collectionView.height(viewHeight)
backgroundColor = .white
}
}
extension RewardView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return datasource.count }
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "RewardCollectionCell", for: indexPath) as! RewardCollectionCell
cell.data = datasource[indexPath.row]
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: screenWidth / 1.25, height: viewHeight)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 0
}
}
8. Update ViewController
- Add this below
categoryView
let forMeReward = RewardView(title: "Just for you")
let limitRewards = RewardView(title: "Limit rewards")
- Add this below
categoryCell
let forMeCell = knTableCell.wrap(view: forMeReward, space: UIEdgeInsets(bottom: 12))
forMeCell.backgroundColor = .lightGray
let limitCell = knTableCell.wrap(view: limitRewards, space: UIEdgeInsets(bottom: 12))
limitCell.backgroundColor = .lightGray
- Update datasource
datasource = [categoryCell, forMeCell, limitCell]
- Add this below
categoryView.datasource
let rewards = [
Reward(merchantLogo: "https://media.franoppnetwork.com/media/concept-logo/gong-cha-usa-250x250.png?v=1", merchantName: "Gong Cha", banner: "https://www.8days.sg/image/9448028/16x9/1920/1080/61d6eb71675b012fa506f1a02a98430f/tx/a68i8605.jpg", title: "Save 15%", point: 750),
Reward(merchantLogo: "https://media.franoppnetwork.com/media/concept-logo/gong-cha-usa-250x250.png?v=1", merchantName: "Gong Cha", banner: "https://www.8days.sg/image/9448028/16x9/1920/1080/61d6eb71675b012fa506f1a02a98430f/tx/a68i8605.jpg", title: "Save 15%", point: 750),
Reward(merchantLogo: "https://media.franoppnetwork.com/media/concept-logo/gong-cha-usa-250x250.png?v=1", merchantName: "Gong Cha", banner: "https://www.8days.sg/image/9448028/16x9/1920/1080/61d6eb71675b012fa506f1a02a98430f/tx/a68i8605.jpg", title: "Save 15%", point: 750)
]
forMeReward.datasource = rewards
limitRewards.datasource = rewards
Now run, and you can see the final layout is completed.
Conclusion
With knStaticListView
, you can build most of UIs you want. Understand it clearly, you can build your UI effortlessly and with fun.
Let me know your opinions and how you build long, complicated UI.
Download the final source code at GrabRewards, branch completed
.
Enjoy coding.
Hi sir, why can’t swipe?
Thanks for the update