Florian Marcu

Complex Collection View Layouts in Swift with Compositional Layout

While SwiftUI is an extremely useful framework to create complex views, we all know that UIKit is here to stay for a good amount of time. Apple has made the Collection Views even more powerful this year with the release of new APIs. Compositional Layout lets you create complex and intricate collection views in SwiftUI really easily.
We will create a simple collection view layout that has horizontal scrolling, and each row contains three cells. One on the left side and two stacked together on the right.
Let's start by writing the CollectionViewCell and UICollectionView code. The cell will be really simple:
import UIKit

class QuickCell: UICollectionViewCell {
    
    let containerView: UIView = {
        let view = UIView()
        view.backgroundColor = .white
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        configureViews()
    }
    
    func configureViews() {
        addSubview(containerView)
        containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true
        containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
        containerView.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true
        containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
In our ViewController, we will set up the collection view:
class ViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource {

    var collectionView: UICollectionView!
    let dataColors = [
        [UIColor.red, UIColor.blue, UIColor.green, UIColor.magenta, UIColor.purple, UIColor.orange, UIColor.black, UIColor.lightGray, UIColor.blue],
        [UIColor.red, UIColor.blue, UIColor.green, UIColor.magenta, UIColor.blue]
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: createCustomLayout())
        collectionView.backgroundColor = .white
        self.collectionView.delegate = self
        self.collectionView.dataSource = self
        
        self.collectionView.register(QuickCell.self, forCellWithReuseIdentifier: "cellID")
        configureCollectionView()
    }

    func configureCollectionView() {
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(collectionView)
        
        NSLayoutConstraint.activate([
            self.collectionView.topAnchor.constraint(equalTo: self.view.layoutMarginsGuide.topAnchor),
            self.collectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
            self.collectionView.leftAnchor.constraint(equalTo: self.view.leftAnchor),
            self.collectionView.rightAnchor.constraint(equalTo: self.view.rightAnchor)
        ])
    }
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return dataColors.count
    }
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return dataColors[section].count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cellID", for: indexPath) as? QuickCell {
            let colorArray = dataColors[indexPath.section]
            
            cell.containerView.backgroundColor = colorArray[indexPath.row]
            return cell
        } else {
            return UICollectionViewCell()
        }
    }
The above code is quite simple. I have created an array which has two arrays in it. Then I have created a collectionView and initiated it with a createCustomLayout() function (more on this in a bit). Then I have just added the collectionView as a subView and configured auto-layout constraints.
Now let's get to the fun stuff!
The main thing to understand here is what is Item, Group and Section. We design a section by creating a group. That group then contains items.
In the above picture, you can see the section dissected. In the section, we have two groups. (two black boxes). In each group, we have item. The left group contains one item, while the right group contains two items. This is also where we have nested groups in action. We are adding two groups inside one group to create a section.
Let's see how we can achieve this using code.
func createCustomLayout() -> UICollectionViewLayout {
        
        let layout = UICollectionViewCompositionalLayout { (section: Int, environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

            let leadingItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: NSCollectionLayoutDimension.fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))
            leadingItem.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
            let leadingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7), heightDimension: .fractionalHeight(1))
            let leadingGroup = NSCollectionLayoutGroup.vertical(layoutSize: leadingGroupSize, subitem: leadingItem, count: 1)
            
            let trailingItem =  NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: NSCollectionLayoutDimension.fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))
            trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
            let trailingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3), heightDimension: .fractionalHeight(1))
            
            let trailingGroup =  NSCollectionLayoutGroup.vertical(layoutSize: trailingGroupSize, subitem: trailingItem, count: 2)
            
            let containerGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),  heightDimension: .absolute(250))
            
            let containerGroup = NSCollectionLayoutGroup.horizontal(layoutSize: containerGroupSize, subitems: [leadingGroup, trailingGroup])
  
            
            let section = NSCollectionLayoutSection(group: containerGroup)
            section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
            section.contentInsets = NSDirectionalEdgeInsets(top: 20, leading: 0, bottom: 20, trailing: 0)
            
            return section
        }
        return layout
    }
}
We will be using UICollectionViewCompositionalLayout initializer that comes with a closure. The code inside the closure will return NSCollectionLayoutSection. This section will then create the UICollectionViewLayout which we will return outside the closure.
We won't be using section and environment parameters in this tutorial, however we can use the environment parameter if the user rotates the device and now we want to change the number of columns shown when the device is in landscape orientation and so forth. Section parameter can be used if you want different number of columns or items in each section.
Now we will start by creating the items. The leading item (left item) will be initialized like so:
let leadingItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: NSCollectionLayoutDimension.fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))
fractionalWidth and fractionalHeight represent how wide and high should the item be as compared to its parent container. For items, their parent container is group. Then we will create the leadingGroup. For that we need to create the group size.
let leadingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.7), heightDimension: .fractionalHeight(1))
let leadingGroup = NSCollectionLayoutGroup.vertical(layoutSize: leadingGroupSize, subitem: leadingItem, count: 1)
The group will be 70% of the parent container, which in this case will be the containerGroup. The height will be the height of the section. (we will set this in just a moment).
Then we will create the group, and the group can have items stacked either horizontally or vertically. We give the initializer the group size, the item which will go in and the count. Count basically means how many items should go in this group. Setting this overrides item's dimensions (width or height depending upon horizontal or vertical axis). Since the count is 1, it will cover the entire dimensions of the group.
Now let's create the trailing group and item.
let trailingItem =  NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: NSCollectionLayoutDimension.fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))
trailingItem.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
let trailingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.3), heightDimension: .fractionalHeight(1))
let trailingGroup =  NSCollectionLayoutGroup.vertical(layoutSize: trailingGroupSize, subitem: trailingItem, count: 2)
Similar to the leadingItem, this item also takes 100% dimensions of the parent container. However,  the group size is 30% of the width and height is 100% of the section height. The trailingGroup however is initialized by giving the count parameter a value of 2. This overrides the item's height dimensions and divides the height by 2.
So now the leading group contains one item and the trailing group contains two items. We can have different number of items stacked vertically in either group just by changing the count parameter.
Now let's create the containerGroup, that will hold the entire sub groups. This is called nested-groups. You can have unlimited nested groups inside your layout. So essentially we have a group, that contains two different groups, each group then contains items.
This is the containerGroup:
let containerGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),  heightDimension: .absolute(250))
let containerGroup = NSCollectionLayoutGroup.horizontal(layoutSize: containerGroupSize, subitems: [leadingGroup, trailingGroup])
The containerGroup has absolute height of 250 and the width of the collectionView. Then we are using the horizontal axis for the layout group and giving it the group size and an array of subitems, which are leadingGroup and trailingGroup.
Finally we create the section, and provide the section an orthogonalScrollingBehavior to get horizontal scrolling within each section. We get six different behaviors to choose from - I have selected continuousGroupLeadingBoundary, which always align the group's leading boundary whenever you scroll.
Here is the app in action:
Awesome, right? Compositional Layout is an extremely powerful Swift API, that allows us to avoid a ton of boilerplate code and write a complex collection view layout concisely and efficiently. To see this example in practice, check out this Swift app template.
As you can see, with less than 15 lines of code, we were able to mimic the layout above. Historically, this has been an arduous task, but fortunately Apple came up with a better API at the latest WWDC. This finally brought this task to the same level of simplicity as the React Native collections.
Give this code a try, and let us know what you build with it. You can read more about Compositional Layout in the Apple's official docs.

Tags

Comments

Topics of interest