Swift UI - Movie Booking App Part 1: UIViewRepresentable & UICollectionViewCompositionalLayout

MovieBookingApp Dec 30, 2019

Introduction

This is part 1 of the movie booking app series. In this part, we will create the home screen which displays a collectionView of movies. The source code of this series will not be public on GitHub, instead if you like this series, and you want to get the source code, click HERE so that I can send it to you. This will help me analyse how many people like this series so that I can do more. If you’ve already subscribed, don’t subscribe again. Every existing subscriber will get an email every time I publish a new tutorial, and the source code will be included in it.

Here is what we are going to create in this first part:

Preparations

To get started, you should have Xcode installed on macOS Mojave or Catalina. If you haven’t downloaded the starter and final project for this article, get it HERE. The folder contains 2 projects, a starter project which contains all of the images and data we will use to display movies and a finished one for this first part. You will need the starter project to follow along.

Models

In the Xcode root folder, create a Models folder and inside it add MovieBundle.swift file containing the following code:


import SwiftUI

struct MovieBundle: Codable, Hashable {
    let trending: [Trending]
    let popular: [Popular]
    let actors: [Actor]
    let upcoming: [Upcoming]
}

protocol Movie: Codable, Hashable {
    var id: Int { get }
    var title: String { get }
    var releaseDate: String { get }
    var description: String { get }
    var image: String { get }
    var rating: Double { get }
    var genres: [String] { get }
    var runtime: String { get }
    var studio: String? { get }
}


// MARK: - Trending
struct Trending: Movie {
    let id: Int
    let title, releaseDate, description, image: String
    let rating: Double
    let genres: [String]
    let runtime: String
    var studio: String? = ""
    
    static var `default`: Trending{
        .init(id: 0, title: "", releaseDate: "", description: "", image: "", rating: 0, genres: [], runtime: "", studio: "")
    }
}


// MARK: - Actor
struct Actor: Codable, Hashable {
    let id: Int
    let name, bio, image: String
    
    static var `default`: Actor{
        .init(id: 0, name: "", bio: "", image: "")
    }

}

// MARK: - Popular
struct Popular: Movie {
    let id: Int
    let title, releaseDate, description, image: String
    let rating: Double
    let genres: [String]
    let runtime: String
    var studio: String? = ""
    
    static var `default`: Popular{
        .init(id: 0, title: "", releaseDate: "", description: "", image: "", rating: 0, genres: [], runtime: "", studio: "")
    }

}

// MARK: - Upcoming
struct Upcoming: Movie {
    let id: Int
    let title, releaseDate, description, image: String
    let rating: Double
    let genres: [String]
    let runtime: String
    var studio: String? = ""
    
    static var `default`: Upcoming{
        .init(id: 0, title: "", releaseDate: "", description: "", image: "", rating: 0, genres: [], runtime: "", studio: "")
    }
}


Couple of things to note here:

  1. The MovieBundle struct is the one that we will use to decode the data.
  2. We then create a Movie protocol that all our movies must conform to, you will see why in the second part of the series.

That’s our models. Let’s create the ViewModel then.

ViewModel

import SwiftUI

enum HomeSection: String, CaseIterable {
    case Trending
    case Popular
    case Upcoming
    case Actors
}


class MovieViewModel: ObservableObject {
    
    @Published var allItems: [HomeSection:[Codable]] = [:]
    
    init() {
        getAll()
    }
    
    private func getAll(){
        if let path = Bundle.main.path(forResource: "data", ofType: "json") {
            
            do {
                let url = URL(fileURLWithPath: path)
                let data = try Data(contentsOf: url, options: .mappedIfSafe)
                let decoder = JSONDecoder()
                let result = try decoder.decode(MovieBundle.self, from: data)
                allItems = [HomeSection.Trending: result.trending,
                            HomeSection.Popular: result.popular,
                            HomeSection.Upcoming: result.upcoming,
                            HomeSection.Actors: result.actors]
                
            } catch let e{
                print(e)
            }
        }
    }
}

Here is a brief rundown of the above code:

  1. We create the HomeSection enum which will be used as keys to retrieve specific section from the model.
  2. Then in the ViewModel, we read the file containing the data, decode it and set the published property to notify interested views so that they can reload and reflect the current state of the model.

UIKit

UIViewRepresentable

If you take a look a the video or just open the App Store’s game tab and try scrolling, you will notice that no matter how hard you scroll, you will only scroll one item at a time creating a peekaboo effect. To achieve that effect, we must use UIKit’s UICollectionView combined with the newly introduced UICollectionViewCompositionalLayout.

In you Xcode’s root folder, create a folder named UIKit. This is where we are going to keep all UIKit related files. In that folder, create a swift file named MovieCollectionView.swift


import SwiftUI


struct MovieCollectionView: UIViewRepresentable {}

You will get a compiler error, but it’s okay we will fix that shortly. Notice how we conform to the UIViewRepresentable protocol, this might be familiar if you’ve read the article where I showed how to create an onboarding screen in swift ui. If you haven’t read it, here it is. It sort of does the same thing except that we want to use a UIView rather than a UIViewController in swift UI.

Add this code inside that struct:

    func makeUIView(context: Context) -> UICollectionView {
    
        return UICollectionView()
    }

    func updateUIView(_ collectionView: UICollectionView, context: Context) {
    
    }

In the first method is where we are going to initialise whatever view we want to use in swiftUI, in this case, it’s a UICollectionView. Treat the makeUIView method as init(frame: CGRect) in a normal UIKit UIView. The second view is where you would normally update the view that you want to use in swiftUI. There’s 2 other methods, the first one being makeCoordinator() which we will learn more of shortly, and the last one is called dismantleUIView(uiView: Self.UIViewType, coordinator: Self.Coordinator) which you would normally use when this view is about to be killed, and want to clean up resources to avoid memory leaks and so on…

Coordinator

The coordinator will be the class that will coordinate, as its name implies, the interaction between swift UI with whatever UIView we are using. This is the class that will handle all the datasource and delegate conformance.

Add the following class inside the same struct, below updateUIView:

class Coordinator: NSObject,UICollectionViewDataSource, UICollectionViewDelegate{

	var parent: MovieCollectionView
        
    init(_ parent: MovieCollectionView) {
            self.parent = parent
        }
        
}

As I mentioned earlier, we conform the coordinator to the UICollectionView datasource and delegate. We also keep a reference to the parent struct which is the MovieCollectionView… You will see why in a moment, but first, override the following methods to silence the compiler error you got.

   func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 0
        }
        
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
return UICollectionViewCell()
        }
        

In the same class, add the following method:

 func createCompositionalLayout() -> UICollectionViewLayout {
      return UICollectionViewFlowLayout()

}

We return the standard FlowLayout for now, but we will change with the UICollectionViewCompositionalLayout later on.

Now add the following method in MovieCollectionView.swift above the coordinator class:

  func makeCoordinator() -> MovieCollectionView.Coordinator {
        Coordinator(self)
    }

This is the fourth method from the UIViewRepresentable protocol. Its only job is to initialise a new Coordinator instance that we will use shortly.

After you’ve done that, add the following in themakeUIView method:

let collectionView = UICollectionView(frame: .zero, collectionViewLayout: context.coordinator.createCompositionalLayout())
        collectionView.backgroundColor = .clear
   
        collectionView.dataSource = context.coordinator
        collectionView.delegate = context.coordinator
        collectionView.alwaysBounceVertical = true
        collectionView.showsVerticalScrollIndicator = false

return collectionView

A few things to note in the above code:

  1. We retrieve the coordinator from the context which in turn gives us access to everything in the coordinator class.
  2. We set the datasource and delegate to the coordinator because the coordinator conforms to those 2 protocols.

Before we continue, we need to create UICollectionView cells for each section.

In the UIKit folder, create a TrendingCell.swift file, and put the following inside:


import UIKit

class TrendingCell: UICollectionViewCell {
    static let reuseId: String = "TrendingCell"
    var trending: Trending?{
        didSet{
            if let trending = self.trending {
                imageView.image = UIImage(named: "\(trending.image)_land.jpg")
                titleLabel.text = trending.title
            }
        }
    }
    
    lazy var imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.image = UIImage(named: "adastra_land.jpg")
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.backgroundColor = .clear
        imageView.clipsToBounds = true
        imageView.layer.cornerRadius = 10
        return imageView
    }()
    
    lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.text = "Ad Astra"
        label.textColor = UIColor(named: "textColor")
        label.font = UIFontMetrics.default.scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .bold))
        label.translatesAutoresizingMaskIntoConstraints = false
        label.numberOfLines = 2
        label.textColor = .white
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupCell()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    
    
      private func setupCell(){
          let gradientView = UIView(frame: CGRect(x: 0, y:  self.frame.height / 4 , width: self.frame.width, height: self.frame.height / 2) )
          gradientView.layer.cornerRadius = 20
          let gradient = CAGradientLayer()
          gradient.frame = gradientView.frame
          gradient.colors = [UIColor.clear, UIColor.black.cgColor]
          
          gradientView.layer.insertSublayer(gradient, at: 0)

          contentView.addSubview(self.imageView)
          self.imageView.addSubview(gradientView)
          contentView.addSubview(titleLabel)
              
          
          NSLayoutConstraint.activate([
              imageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
              imageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
              imageView.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.9),
              imageView.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: 1),
              
              titleLabel.leadingAnchor.constraint(equalTo: imageView.leadingAnchor, constant: 10),
              titleLabel.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
              titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
              titleLabel.heightAnchor.constraint(equalToConstant: 50)
          ])

      }
    
}


If you have worked with UIKit before this code will be simple, but if you haven’t, I will briefly run through what I have done.

  1. I initialise and configure views lazily using closure to make the code a bit cleaner, but also for performance purposes. Read more about lazy variables on Paul Hudson’s Hacking with swift website
  2. In the setup cell method, I constrain those 2 views to the contentView after adding them to it.
  3. I also created a gradient view at the bottom of the cell which will lay behind the title.

Here is what we’ve just created:

UICollection compositional layout
UICollection compositional layout

In the same folder, create a PopularCell.swift file and put the following inside:


import SwiftUI

class PopularCell: UICollectionViewCell {
    static let reuseId: String = "PopularCell"
    var popular: Popular?{
        didSet{
            if let movie = self.popular {
                imageView.image = UIImage(named: "\(movie.image).jpg")
                titleLabel.text = movie.title
            }
        }
    }
    
    lazy var imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.image = UIImage(named: "adastra.jpg")
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.backgroundColor = .clear
        imageView.clipsToBounds = true
        imageView.layer.cornerRadius = 10
        return imageView
    }()
    
    lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.text = "Ad Astra"
        label.textColor = UIColor(named: "textColor")
        label.translatesAutoresizingMaskIntoConstraints = false
        label.numberOfLines = 2
        label.textColor = .secondaryLabel
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupCell()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    
    private func setupCell(){
        
        self.addSubview(self.imageView)
        self.addSubview(self.titleLabel)
        
        NSLayoutConstraint.activate([
            imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
            imageView.heightAnchor.constraint(equalTo: self.widthAnchor,multiplier: (3/2)),
            
            titleLabel.topAnchor.constraint(equalTo: self.imageView.bottomAnchor),
            titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            titleLabel.heightAnchor.constraint(equalToConstant: 50)
            
        ])
    }
    
}


This code is almost similar to the one above, so refer to it if you have trouble understanding.

Actor Cell

In the same folder, create a PopularCell.swift file and put the following inside:


import UIKit


class ActorCell: UICollectionViewCell {
    static let reuseId: String = "ActorCell"
    var actor: Actor?{
        didSet{
            if let actor = self.actor {
                imageView.image = UIImage(named: "\(actor.image).jpg")
                titleLabel.text = actor.name
            }
        }
    }
    
    lazy var imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.image = UIImage(named: "adastra.jpg")
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.backgroundColor = .clear
        imageView.clipsToBounds = true
        imageView.layer.cornerRadius = 10

        return imageView
    }()
    
    lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.text = "Ad Astra"
        label.textColor = .secondaryLabel
        label.translatesAutoresizingMaskIntoConstraints = false
        label.numberOfLines = 2


        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupCell()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
    
    private func setupCell(){
        
        self.addSubview(self.imageView)
        self.addSubview(self.titleLabel)
        
                
        NSLayoutConstraint.activate([
            imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
            imageView.heightAnchor.constraint(equalTo: self.widthAnchor,multiplier: (3/2)),
            
            titleLabel.topAnchor.constraint(equalTo: self.imageView.bottomAnchor),
            titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            titleLabel.heightAnchor.constraint(equalToConstant: 50)
            
        ])
    }
}

This code is also similar to the popular cell with the exception of the actor property at the top.

Both of them create the following layout:

UICollectionView Compositional layout
UICollectionView Compositional layout

Upcoming Cell

In the same folder, create a UpcomingCell.swift file and put this inside:


import UIKit


class UpcomingCell: UICollectionViewCell {
    static let reuseId: String = "UpcomingCell"
    var upcoming: Upcoming?{
        didSet{
            if let upcoming = self.upcoming {
                imageView.image = UIImage(named: "\(upcoming.image)_land.jpg")
                titleLabel.text = upcoming.title
                releaseDateLabel.text = upcoming.releaseDate
            }
        }
    }
    
    lazy var imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.image = UIImage(named: "adastra_land.jpg")
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.backgroundColor = .clear
        imageView.clipsToBounds = true
        imageView.layer.cornerRadius = 10
        return imageView
    }()
    
    lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.text = "Ad Astra"
        label.textColor = UIColor(named:"textColor")
        label.font = UIFontMetrics.default.scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .bold))
        label.numberOfLines = 2
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    lazy var releaseDateLabel: UILabel = {
        let label = UILabel()
        label.text = "December 25, 2019"
        label.textColor = UIColor.gray
        label.font = UIFontMetrics.default.scaledFont(for: UIFont.systemFont(ofSize: 15, weight: .regular))
        label.numberOfLines = 2
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()


    override init(frame: CGRect) {
        super.init(frame: frame)
        setupCell()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }
    
      private func setupCell(){
        contentView.addSubview(self.imageView)
        contentView.addSubview(self.titleLabel)
        contentView.addSubview(self.releaseDateLabel)
                                
          NSLayoutConstraint.activate([
              imageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
              imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
              imageView.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.5),
              imageView.heightAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.25),
              
              titleLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 10),
              titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
              titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
              titleLabel.heightAnchor.constraint(equalToConstant: 30),

              releaseDateLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 10),
              releaseDateLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
              releaseDateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
              releaseDateLabel.heightAnchor.constraint(equalToConstant: 15)
          ])

      }
}

The above is works similarly as the above ones, but the layout rendered here will slightly be different. Here it is:

UICollectionView compositional layout
UICollectionView compositional layout

Section Header View

This is the view we will build in UIKit. Add a swift file named HeaderView.swift , and inside it , add the following code:


import UIKit

class HeaderView: UICollectionReusableView {
    
    static let reuseId = "HeaderView"
    var onSeeAllClicked = {}
    
    lazy var name: UILabel = {
          let label = UILabel()
          label.text = "Popular"
          label.textColor = UIColor(named: "textColor")
          label.numberOfLines = 2
        label.font = UIFontMetrics.default.scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .bold))

          return label
      }()
    
    lazy var seeAll: UIButton = {
        let button  = UIButton(type: .system)
        button.setTitle("See all", for: .normal)
        button.setTitleColor(UIColor(named: "darkPurple"), for: .normal)
        button.addTarget(self, action: #selector(seeAllMovies), for: .touchUpInside)
        button.backgroundColor = .clear
        return button
    }()


    lazy var HStack:UIStackView = {
        let stack = UIStackView(arrangedSubviews: [name,seeAll])
        stack.translatesAutoresizingMaskIntoConstraints = false
        stack.axis = .horizontal
        stack.distribution = .equalSpacing
        return stack
    }()
    
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
       self.addSubview(HStack)
        
        NSLayoutConstraint.activate([
            HStack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 20),
            HStack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -20),
            HStack.topAnchor.constraint(equalTo: self.topAnchor),
            HStack.bottomAnchor.constraint(equalTo: self.bottomAnchor)
        ])
    }
    
    required init?(coder: NSCoder) {
         fatalError("Not happening...")
    }
    
    @objc fileprivate func seeAllMovies(){
        self.onSeeAllClicked()
    }
}

This code is similar to what we have done except we have a closure that will run when the seeAll button is clicked. You will see how it will be used in the MovieCollectionView later.

Here is the header view we’ve just created:

UIStack View
UIStack View

Now that we have the cells out of the way, the start working on the compositional layout.

UICollectionViewCompositionalLayout

If you’ve worked with the UICollectionView before, then you are aware of UICollectionViewFlowLayout. With the flow layout, the items in the collection view flow from one row or column (depending on the scrolling direction) to the next, with each row comprising as many cells as will fit. However, there's a new way of creating complex layouts with ease in UIKit using the UICollectionViewCompositionalLayout.

Before, if one wanted to create a layout like the App Store’s app or game screen layout, he would combine multiple UICollectionViews scrolled horizontally within a UITableView or a UICollectionView scrolled vertically. However, that’s not the case anymore with the introduction of the amazing UICollectionViewCompositionalLayout.

In a nutshell, here is the UICollectionViewCompositionalLayout breakdown:

Let’s now start creating layouts. We will start the shared layout that will be used by the Popular and Actor section because they have identical layouts.

In the coordinator class, below the cellForItemAt method, add the following:

       
        func createSharedSection() -> NSCollectionLayoutSection {
            let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1))
            let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
            layoutItem.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
            let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.93), heightDimension:  .fractionalWidth(0.75))
            let layoutGroup = NSCollectionLayoutGroup.horizontal(layoutSize: layoutGroupSize, subitems: [layoutItem])
            let layoutSection = NSCollectionLayoutSection(group: layoutGroup)
            layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
            layoutSection.boundarySupplementaryItems = [createSectionHeader()]
            return layoutSection
        }
        

Let me explain the above code line by line:

  1. The itemSize is calculated by taking half of the group’s width using .fractionalWidth(0.5) while the height will be equal to the group’s full height. Then we create an NSCollectionLayoutItem giving it its size.
  2. The layoutGroupSize is calculated the same way, but uses the section’s dimensions, and notice how we use .fractionalWidth(0.75) to set the height, this is because we want the height to be 3/4 of the group’s width. Then we create the layout group which will scroll horizontally by by giving it the group size and an array of layout Item.
  3. We then create an NSCollectionLayoutSection passing in the layout group.
  4. We define the scrolling behaviour by setting the layout section’s orthogonalScrollingBehavior to .groupPagingCentered which will create the app Store’s peekaboo effect. Here are all the scrolling behaviour you can try and see how they work:

Here is what the layout will look like:

public enum UICollectionLayoutSectionOrthogonalScrollingBehavior : Int {

	
	// default behavior. Section will layout along main layout axis (i.e. configuration.scrollDirection)
	case none

	
	// NOTE: For each of the remaining cases, the section content will layout orthogonal to the main layout axis (e.g. main layout axis == .vertical, section will scroll in .horizontal axis)
	
	// Standard scroll view behavior: UIScrollViewDecelerationRateNormal
	case continuous

	
	// Scrolling will come to rest on the leading edge of a group boundary
	case continuousGroupLeadingBoundary

	
	// Standard scroll view paging behavior (UIScrollViewDecelerationRateFast) with page size == extent of the collection view's bounds
	case paging

	
	// Fractional size paging behavior determined by the sections layout group's dimension
	case groupPaging

	
	// Same of group paging with additional leading and trailing content insets to center each group's contents along the orthogonal axis
	case groupPagingCentered
}

  1. Last, we create the section header by setting the section’s boundarySupplementaryItems. You will get a compiler error caused by the missing createSectionHeader method. So add the following code to silence that error:

        func createSectionHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
              let layoutSectionHeaderSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(80))
              let layoutSectionHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: layoutSectionHeaderSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
              return layoutSectionHeader
        }

You can learn more about compositional layout here.

To avoid being repetitive, I am not going to add the code for the remaining layout here, but it almost similar to what I’ve just explained above. Just get the final projectHERE if you don’t have it yet, and copy the those 2 methods in you code.

So far, we have been preparing the components of our compositional layout, now is the time to create it. Find the createCompositionalLayout method, and replace the return statement with the following:

        func createCompositionalLayout() -> UICollectionViewLayout {
            let layout = UICollectionViewCompositionalLayout{[weak self] index, environment in
                switch index{
                case 0:
                    return self?.createTrendingSection()
                case 1:
                    return self?.createSharedSection()
                case 2:
                    return self?.createUpcomingSection()
                default:
                    return self?.createSharedSection()
                }
            }
            let config = UICollectionViewCompositionalLayoutConfiguration()
            config.interSectionSpacing = 20
            layout.configuration = config
            return layout
        }

Voila!! that’s our compositional layout. For each section index, we return the appropriate layout, and we set the spacing between section to 20. We are not ready to run the app yet because we still haven’t implemented the DataSource, so let’s tackle that now.

UICollectionViewDataSource

At the top of the viewController struct, add the following properties:

    var allItems: [HomeSection:[Codable]]
    var didSelectItem: ((_ indexPath: IndexPath)->()) = {_ in }
    var seeAllforSection: ((_ section: HomeSection)->()) = {_ in }

Here is a quick explanation of what they do:

  1. The first one is the data that we parsed earlier.
  2. The second one is a closure that will run every time one of the cells is selected.
  3. The third one is obviously also a closure that will run when the see all button on the header view is tapped.

And in makeUIView, below the code line that creates the collectionView, add the following code:

        collectionView.register(TrendingCell.self, forCellWithReuseIdentifier: TrendingCell.reuseId)
        collectionView.register(PopularCell.self, forCellWithReuseIdentifier: PopularCell.reuseId)
        collectionView.register(UpcomingCell.self, forCellWithReuseIdentifier: UpcomingCell.reuseId)
        collectionView.register(ActorCell.self, forCellWithReuseIdentifier: ActorCell.reuseId)
        collectionView.register(HeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader , withReuseIdentifier: HeaderView.reuseId)

In the above code, we register all of the cells and the headerView.

In the coordinator struct, add the following method:

func numberOfSections(in collectionView: UICollectionView) -> Int {
            return parent.allItems.count
        }

The above code return the number of section, which 4 in our case. Then replace the return statement in numberOfItemsInSection with the following:

return parent.allItems[HomeSection.allCases[section]]?.count ?? 0

In this method, we return the number of items (Movies or Actors) for each section. And last, before we try a first run, replace everything in cellForItemAt with the following:

        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            
            switch indexPath.section {
            case 0:
                if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TrendingCell.reuseId, for: indexPath) as? TrendingCell{
                    cell.trending = parent.allItems[HomeSection.Trending]?[indexPath.item] as? Trending
                    return cell
                }
            case 1:
                if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PopularCell.reuseId, for: indexPath) as? PopularCell{
                    cell.popular = parent.allItems[HomeSection.Popular]?[indexPath.item] as? Popular
                    return cell
                }
                
            case 2:
                if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: UpcomingCell.reuseId, for: indexPath) as? UpcomingCell{
                    cell.upcoming = parent.allItems[HomeSection.Upcoming]?[indexPath.item] as? Upcoming
                    return cell
                }
            default:
                if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ActorCell.reuseId, for: indexPath) as? ActorCell{
                    cell.actor = parent.allItems[HomeSection.Actors]?[indexPath.item] as? Actor
                    return cell
                }
            }
            return UICollectionViewCell()
        }

For each section, we dequeue and return the corresponding cells. Now try running the app… Only the “Hello world” shows up, right? Well haven’t added the MovieCollectionView in swift UI yet.

Open MovieStoreApp, and add this at the top of the file:

@ObservedObject private var model = MovieViewModel()

Then replace the hello world text with this:

let movieCollectionView = createCollectionView().edgesIgnoringSafeArea(.all).navigationBarTitle("Movies")
        
        return NavigationView {
            movieCollectionView
        }

To silence the error you get, put the following below the body block:

    fileprivate func createCollectionView() -> MovieCollectionView {
        return MovieCollectionView(allItems: model.allItems, didSelectItem: { indexPath in }, seeAllforSection: { section in })
    }

If you run the app, it looks naked without the section headers. So add the following method anywhere inside the Coordinator class:


        func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
            switch kind {
            case UICollectionView.elementKindSectionHeader:
            guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: HeaderView.reuseId, for: indexPath) as? HeaderView else { return UICollectionReusableView() }
                header.name.text =  HomeSection.allCases[indexPath.section].rawValue
                header.onSeeAllClicked = { [weak self] in
                    self?.parent.seeAllforSection(HomeSection.allCases[indexPath.section])
                }
                return header
            default:
                 return UICollectionReusableView()
            }
        }

The above code creates the header for each section and wires the onSeeAllClicked closure from the HeaderView class to the parent’s closure. Let’s also wire the didSelectItem.

Add the following below cellForItemAt:

        func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
            parent.didSelectItem(indexPath)
        }

Now, run the app again... everything should look as expected. And dark mode is supported. With this, we say goodbye to UIKit in this series. In the second part, we will work on the detail screens for both movies and actors, so make sure you are subscribed if you haven’t done so already. Happy Coding.

Read part two HERE

John K

I am a software developer and code enthusiast. Do you want to work with me, have a suggestion or a request? Feel free to contact me at [email protected] or https://twitter.com/liquidcoder