Codementor Events

How to build layout like GrabRewards

Published Mar 04, 2019

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.

Why padding / 2?

  • 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 and item_2 is 2x = 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.

Discover and read more posts from Ky Nguyen
get started
post commentsBe the first to share your opinion
Juani Kimberly
4 years ago

Hi sir, why can’t swipe?

vivek nc
5 years ago

Thanks for the update

Show more replies