Lab 4.2 — Custom collection layout

Goal: Build a multi-section feed screen with UICollectionViewCompositionalLayout: a horizontal hero carousel, a 2-column featured grid, and a full-width list. Use a diffable data source with multiple item types and supplementary section headers.

Time: ~120 minutes Phase prerequisites: Chapters 4.2, 4.3, 4.5

What you’ll build

A single-screen “Discover” feed that looks like Apple’s App Store today tab:

  • Section 1 — Hero carousel: full-width-ish cards, scroll horizontally, snap to page
  • Section 2 — Featured grid: 2 columns, square cards
  • Section 3 — Recent list: full-width row cells with image + title + subtitle

Each section has a header (supplementary view). All powered by one UICollectionView with one diffable data source.

Setup

  1. New Xcode project: App template, DiscoverFeed, Swift, UIKit, programmatic (delete Main.storyboard).
  2. Configure SceneDelegate to make DiscoverVC the root inside a UINavigationController.

Step 1 — Model the data

// Models/FeedItem.swift
import UIKit

enum FeedSection: Int, CaseIterable, Hashable {
    case hero, featured, recent

    var title: String {
        switch self {
        case .hero: return "Featured stories"
        case .featured: return "You might like"
        case .recent: return "Latest"
        }
    }
}

struct FeedItem: Hashable {
    let id: UUID
    let title: String
    let subtitle: String
    let color: UIColor
    let section: FeedSection
}

enum FeedFixtures {
    static func make() -> [FeedItem] {
        let colors: [UIColor] = [.systemRed, .systemBlue, .systemGreen, .systemOrange, .systemPurple, .systemTeal, .systemPink, .systemIndigo]
        var items: [FeedItem] = []
        for i in 0..<5 {
            items.append(FeedItem(id: UUID(), title: "Hero \(i + 1)", subtitle: "Editor's pick", color: colors[i % colors.count], section: .hero))
        }
        for i in 0..<8 {
            items.append(FeedItem(id: UUID(), title: "Featured \(i + 1)", subtitle: "Trending now", color: colors[(i + 2) % colors.count], section: .featured))
        }
        for i in 0..<20 {
            items.append(FeedItem(id: UUID(), title: "Article \(i + 1)", subtitle: "5 min read · Today", color: colors[(i + 4) % colors.count], section: .recent))
        }
        return items
    }
}

Step 2 — Cells

// Views/HeroCell.swift
import UIKit

final class HeroCell: UICollectionViewCell {
    static let reuseID = "HeroCell"

    private let titleLabel = UILabel()
    private let subtitleLabel = UILabel()
    private let container = UIView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        container.layer.cornerRadius = 16
        container.layer.cornerCurve = .continuous
        container.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(container)

        titleLabel.font = .preferredFont(forTextStyle: .title2)
        titleLabel.textColor = .white
        titleLabel.numberOfLines = 2
        titleLabel.translatesAutoresizingMaskIntoConstraints = false

        subtitleLabel.font = .preferredFont(forTextStyle: .caption1)
        subtitleLabel.textColor = .white.withAlphaComponent(0.85)
        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false

        container.addSubview(titleLabel)
        container.addSubview(subtitleLabel)

        NSLayoutConstraint.activate([
            container.topAnchor.constraint(equalTo: contentView.topAnchor),
            container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),

            subtitleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
            subtitleLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
            subtitleLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),

            titleLabel.leadingAnchor.constraint(equalTo: subtitleLabel.leadingAnchor),
            titleLabel.trailingAnchor.constraint(equalTo: subtitleLabel.trailingAnchor),
            titleLabel.bottomAnchor.constraint(equalTo: subtitleLabel.topAnchor, constant: -4),
        ])
    }

    required init?(coder: NSCoder) { fatalError() }

    func configure(with item: FeedItem) {
        titleLabel.text = item.title
        subtitleLabel.text = item.subtitle
        container.backgroundColor = item.color
    }
}

// Views/FeaturedCell.swift
final class FeaturedCell: UICollectionViewCell {
    static let reuseID = "FeaturedCell"

    private let titleLabel = UILabel()
    private let container = UIView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        container.layer.cornerRadius = 12
        container.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(container)

        titleLabel.font = .preferredFont(forTextStyle: .headline)
        titleLabel.textColor = .white
        titleLabel.numberOfLines = 0
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        container.addSubview(titleLabel)

        NSLayoutConstraint.activate([
            container.topAnchor.constraint(equalTo: contentView.topAnchor),
            container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),

            titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
            titleLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
            titleLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12),
        ])
    }

    required init?(coder: NSCoder) { fatalError() }

    func configure(with item: FeedItem) {
        titleLabel.text = item.title
        container.backgroundColor = item.color
    }
}

// Views/RecentCell.swift — use the iOS 14 list content config
final class RecentCell: UICollectionViewListCell {
    static let reuseID = "RecentCell"

    func configure(with item: FeedItem) {
        var config = defaultContentConfiguration()
        config.text = item.title
        config.secondaryText = item.subtitle
        config.image = UIImage(systemName: "doc.text")
        config.imageProperties.tintColor = item.color
        contentConfiguration = config
        accessories = [.disclosureIndicator()]
    }
}

Step 3 — Section header

// Views/SectionHeader.swift
final class SectionHeader: UICollectionReusableView {
    static let reuseID = "SectionHeader"
    static let elementKind = "section-header"

    private let label: UILabel = {
        let l = UILabel()
        l.font = .preferredFont(forTextStyle: .title3).bold()
        l.adjustsFontForContentSizeCategory = true
        l.translatesAutoresizingMaskIntoConstraints = false
        return l
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(label)
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
            label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
            label.topAnchor.constraint(equalTo: topAnchor, constant: 8),
            label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
        ])
    }

    required init?(coder: NSCoder) { fatalError() }

    func configure(title: String) { label.text = title }
}

extension UIFont {
    func bold() -> UIFont {
        guard let desc = fontDescriptor.withSymbolicTraits(.traitBold) else { return self }
        return UIFont(descriptor: desc, size: 0)
    }
}

Step 4 — Layout

The heart of the lab. Build a UICollectionViewCompositionalLayout with a per-section provider:

// VCs/DiscoverVC+Layout.swift
extension DiscoverVC {
    func makeLayout() -> UICollectionViewLayout {
        UICollectionViewCompositionalLayout { sectionIndex, environment in
            guard let section = FeedSection(rawValue: sectionIndex) else { return nil }
            switch section {
            case .hero:    return self.makeHeroSection()
            case .featured: return self.makeFeaturedSection(env: environment)
            case .recent:   return self.makeRecentSection(env: environment)
            }
        }
    }

    private func makeHeroSection() -> NSCollectionLayoutSection {
        let item = NSCollectionLayoutItem(
            layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))
        )
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: .init(widthDimension: .fractionalWidth(0.85), heightDimension: .absolute(220)),
            subitems: [item]
        )
        let section = NSCollectionLayoutSection(group: group)
        section.orthogonalScrollingBehavior = .groupPagingCentered
        section.interGroupSpacing = 12
        section.contentInsets = .init(top: 0, leading: 16, bottom: 16, trailing: 16)
        section.boundarySupplementaryItems = [makeHeader()]
        return section
    }

    private func makeFeaturedSection(env: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
        let item = NSCollectionLayoutItem(
            layoutSize: .init(widthDimension: .fractionalWidth(0.5), heightDimension: .fractionalHeight(1))
        )
        item.contentInsets = .init(top: 4, leading: 4, bottom: 4, trailing: 4)
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .absolute(140)),
            subitems: [item]
        )
        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = .init(top: 0, leading: 12, bottom: 16, trailing: 12)
        section.boundarySupplementaryItems = [makeHeader()]
        return section
    }

    private func makeRecentSection(env: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection {
        var config = UICollectionLayoutListConfiguration(appearance: .plain)
        config.headerMode = .supplementary
        return NSCollectionLayoutSection.list(using: config, layoutEnvironment: env)
    }

    private func makeHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
        let header = NSCollectionLayoutBoundarySupplementaryItem(
            layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(44)),
            elementKind: SectionHeader.elementKind,
            alignment: .top
        )
        header.pinToVisibleBounds = false
        return header
    }
}

Step 5 — View controller

// VCs/DiscoverVC.swift
import UIKit

final class DiscoverVC: UIViewController {
    private var collectionView: UICollectionView!
    private var dataSource: UICollectionViewDiffableDataSource<FeedSection, FeedItem>!

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Discover"
        view.backgroundColor = .systemBackground

        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: makeLayout())
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        collectionView.backgroundColor = .systemBackground
        collectionView.delegate = self
        view.addSubview(collectionView)

        registerViews()
        configureDataSource()
        applyInitialSnapshot()
    }

    private func registerViews() {
        collectionView.register(HeroCell.self, forCellWithReuseIdentifier: HeroCell.reuseID)
        collectionView.register(FeaturedCell.self, forCellWithReuseIdentifier: FeaturedCell.reuseID)
        collectionView.register(RecentCell.self, forCellWithReuseIdentifier: RecentCell.reuseID)
        collectionView.register(
            SectionHeader.self,
            forSupplementaryViewOfKind: SectionHeader.elementKind,
            withReuseIdentifier: SectionHeader.reuseID
        )
    }

    private func configureDataSource() {
        dataSource = UICollectionViewDiffableDataSource<FeedSection, FeedItem>(collectionView: collectionView) { cv, indexPath, item in
            switch item.section {
            case .hero:
                let cell = cv.dequeueReusableCell(withReuseIdentifier: HeroCell.reuseID, for: indexPath) as! HeroCell
                cell.configure(with: item)
                return cell
            case .featured:
                let cell = cv.dequeueReusableCell(withReuseIdentifier: FeaturedCell.reuseID, for: indexPath) as! FeaturedCell
                cell.configure(with: item)
                return cell
            case .recent:
                let cell = cv.dequeueReusableCell(withReuseIdentifier: RecentCell.reuseID, for: indexPath) as! RecentCell
                cell.configure(with: item)
                return cell
            }
        }

        dataSource.supplementaryViewProvider = { cv, kind, indexPath in
            guard kind == SectionHeader.elementKind,
                  let section = FeedSection(rawValue: indexPath.section) else { return nil }
            let header = cv.dequeueReusableSupplementaryView(
                ofKind: kind,
                withReuseIdentifier: SectionHeader.reuseID,
                for: indexPath
            ) as! SectionHeader
            header.configure(title: section.title)
            return header
        }
    }

    private func applyInitialSnapshot() {
        let all = FeedFixtures.make()
        var snap = NSDiffableDataSourceSnapshot<FeedSection, FeedItem>()
        for section in FeedSection.allCases {
            snap.appendSections([section])
            snap.appendItems(all.filter { $0.section == section }, toSection: section)
        }
        dataSource.apply(snap, animatingDifferences: false)
    }
}

extension DiscoverVC: UICollectionViewDelegate {
    func collectionView(_ cv: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        cv.deselectItem(at: indexPath, animated: true)
        guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
        let detail = UIViewController()
        detail.title = item.title
        detail.view.backgroundColor = item.color
        navigationController?.pushViewController(detail, animated: true)
    }
}

Step 6 — Run

You should see:

  • Section header “Featured stories” and a horizontally-scrolling card carousel that snaps
  • Section header “You might like” with a 2-column grid
  • Section header “Latest” with a full-width list
  • Tapping any item pushes a placeholder detail VC

Rotate the device — the layout adapts because every dimension is fractional / estimated.

Stretch goals

  1. Different hero card sizes by index — use NSCollectionLayoutGroup.custom to mix tall and short cards.
  2. Pull-to-refresh that animates new items in via dataSource.apply(_:animatingDifferences: true).
  3. Swipe actions on the recent list — use UICollectionLayoutListConfiguration’s trailingSwipeActionsConfigurationProvider.
  4. Adaptive layout — for compact width, make the featured section 1 column. Use the NSCollectionLayoutEnvironment.container.effectiveContentSize.width check.
  5. Reorderable featured section — set dataSource.reorderingHandlers and a long-press gesture (chapter 4.6).
  6. Animated cell highlight on selection — override isHighlighted in FeaturedCell and scale container.transform.

Notes & troubleshooting

  • Cells overlap headers: ensure boundarySupplementaryItems is on the section, not the layout config (the list-section helper handles this via headerMode = .supplementary).
  • List section ignores your header layout: list sections use UICollectionLayoutListConfiguration’s own header. Use headerMode = .supplementary then provide the view via the supplementary provider.
  • Orthogonal scrolling stutters: this is usually because cells are doing heavy work in cellForItemAt (image decode on main thread). Move work off main; pre-decode images.
  • Snapshot animation looks weird: Hashable conformance must be stable. FeedItem.id is a UUID — re-creating items will give new IDs and confuse the diff. Generate fixtures once and store.

Next: Lab 4.3 — Form with Keychain