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
- New Xcode project: App template,
DiscoverFeed, Swift, UIKit, programmatic (delete Main.storyboard). - Configure
SceneDelegateto makeDiscoverVCthe root inside aUINavigationController.
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
- Different hero card sizes by index — use
NSCollectionLayoutGroup.customto mix tall and short cards. - Pull-to-refresh that animates new items in via
dataSource.apply(_:animatingDifferences: true). - Swipe actions on the recent list — use
UICollectionLayoutListConfiguration’strailingSwipeActionsConfigurationProvider. - Adaptive layout — for compact width, make the featured section 1 column. Use the
NSCollectionLayoutEnvironment.container.effectiveContentSize.widthcheck. - Reorderable featured section — set
dataSource.reorderingHandlersand a long-press gesture (chapter 4.6). - Animated cell highlight on selection — override
isHighlightedinFeaturedCelland scalecontainer.transform.
Notes & troubleshooting
- Cells overlap headers: ensure
boundarySupplementaryItemsis on the section, not the layout config (the list-section helper handles this viaheaderMode = .supplementary). - List section ignores your header layout: list sections use
UICollectionLayoutListConfiguration’s own header. UseheaderMode = .supplementarythen 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:
Hashableconformance must be stable.FeedItem.idis aUUID— re-creating items will give new IDs and confuse the diff. Generate fixtures once and store.