Bidirectional collection view with orthogonalScrollingBehavior

Photo by Maxwell Nelson / Unsplash

Before iOS 13, if you wanted to have a vertical collection view with horizontally scrolling sections it required embedding a scroll view inside the collection view cell, passing the model around, handling touch events, a lot of delegation, and complicated logic. With UICollectionViewCompositionalLayout all of those problems fading away. Apple gave us orthogonalScrollingBehavior.

We will build our new section using code from my last post.

Let's start with our model. I want to display a horizontally scrolled section displaying just letters. Modify model class by adding new var:

lazy var letters: [Character] = {
         let aScalars = "a".unicodeScalars
         let aCode = aScalars[aScalars.startIndex].value

         return (0..<26).map { i in
             Character(Unicode.Scalar(aCode + i) ?? aScalars[aScalars.startIndex])
         }
     }()

Now simple cell, just label for letter:

final class LetterCell: UICollectionViewCell {
     private(set) lazy var letterLabel = UILabel()

     override init(frame: CGRect) {
         super.init(frame: frame)
         setupViews()
     }

     required init?(coder: NSCoder) {
         super.init(coder: coder)
     }

     private func setupViews() {
         contentView.addSubview(letterLabel)
		 letterLabel.translatesAutoresizingMaskIntoConstraints = false

         layer.borderWidth = 2
         layer.borderColor = UIColor.black.cgColor
         layer.cornerRadius = 5

         NSLayoutConstraint.activate(
             [
                 contentView.centerXAnchor
                 	.constraint(equalTo: contentView.centerXAnchor),
                 contentView.centerYAnchor
                 	.constraint(equalTo: contentView.centerYAnchor)
                 letterLabel.centerXAnchor
                 	.constraint(equalTo: contentView.centerXAnchor),
                 letterLabel.centerYAnchor
                 	.constraint(equalTo: contentView.centerYAnchor)
             ]
         )
     }
 }

So far so good. Now, adjust our view controller, it needs to know about the new section and new cell.

In func prepareCollectionView() register new cell:

collectionView.register(LetterCell.self, forCellWithReuseIdentifier: "letterCell")

Our UICollectionViewDataSource have to return two sections and handle cell preparation:

extension ViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 2
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        if section % 2 == 0 {
            return model.letters.count
        }
        return model.colors.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        if indexPath.section % 2 == 0 {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "letterCell", for: indexPath) as! LetterCell
            cell.letterLabel.text = String(model.letters[indexPath.row])
            return cell
        }
        
        
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
        cell.backgroundColor = model.colors[indexPath.row]
        cell.layer.cornerRadius = 5
        cell.layer.masksToBounds = true
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        let cell = collectionView.dequeueReusableSupplementaryView(
            ofKind: kind,
            withReuseIdentifier: kind,
            for: indexPath
        )
        
        if let header = cell as? HeaderView {
            header.label.text = kind
        }
        
        return cell
    }
}

We are almost there. The last thing is to modify our layout:

enum GridCompositionalLayout {
    static func generateLayout() -> UICollectionViewCompositionalLayout {
        
        let config = UICollectionViewCompositionalLayoutConfiguration()
        config.boundarySupplementaryItems = [makeCollectionHeader()]
        
        // 1
        return UICollectionViewCompositionalLayout(
            sectionProvider: { section, _ in
                
                if section % 2 == 0 {
                    return makeLetterSection()
                }
                return makeSection()
            },
            configuration: config
        )
    }
    /// old code unchanged
}

// new extension added below
extension GridCompositionalLayout {

     private static func makeLetterItem() -> NSCollectionLayoutItem {
         let item = NSCollectionLayoutItem(
             layoutSize: .init(
                 widthDimension: .fractionalWidth(1),
                 heightDimension: .fractionalHeight(0.5)
             )
         )

         item.contentInsets = .init(top: 3, leading: 3, bottom: 3, trailing: 3)
         return item
     }

     private static func makeLetterGroup() -> NSCollectionLayoutGroup {
         let group =  NSCollectionLayoutGroup.vertical(
             layoutSize: .init(
                 widthDimension: .fractionalWidth(3/8),
                 heightDimension: .fractionalWidth(6/8)
             ),
             subitems: [makeLetterItem()]
         )

         return group
     }

     private static func makeLetterSection() -> NSCollectionLayoutSection {
         let section = NSCollectionLayoutSection(group: makeLetterGroup())
         section.contentInsets = .init(
             top: 16,
             leading: 0,
             bottom: 16,
             trailing: 0
         )
         
         // 2
         section.orthogonalScrollingBehavior = .groupPagingCentered
         return section

     }
 }

The biggest changes are:

  1. Now instead of returning one section, we have a factory method returning different sections layouts depending on the section number
  2. New section layout. There is nothing special about it at the first glance. One item, one simple group. Biggest change is section.orthogonalScrollingBehavior = .groupPagingCentered - setting this makes the section scroll perpendicularly to the main axis of our collection view. And this is the only thing needed to make such a section work. No embedding, no delegation, just one simple line of code!

And the effect:

0:00
/

The full code can be found here.

Artur Gruchała

Artur Gruchała

I started learning iOS development when Swift was introduced. Since then I've tried Xamarin, Flutter, and React Native. Nothing is better than native code:)
Poland