Compositional Layout in UICollectionView
UICollectionView has been a cornerstone of iOS development for displaying ordered data sets in customizable layouts. From simple lists to complex grids, it has empowered developers to create rich, dynamic interfaces. However, achieving truly intricate, adaptive layouts—especially those mixing different visual styles within a single collection view—often required significant boilerplate code, custom UICollectionViewLayout subclasses, or even multiple collection views.
Enter UICollectionViewCompositionalLayout. Introduced in iOS 13, this powerful declarative layout system revolutionized how we approach collection view layouts. It provides a flexible and intuitive way to describe your layout visually, empowering you to build highly complex and adaptive UIs with significantly less effort than ever before. If you've ever struggled with UICollectionViewFlowLayout's limitations or felt overwhelmed by custom layout subclassing, CompositionalLayout is here to be your new best friend.
This article will guide you through the core concepts of UICollectionViewCompositionalLayout, demonstrating how to construct various layouts, from simple lists to more complex, multi-section designs, all while keeping your code clean and maintainable.
The Evolution of UICollectionView Layouts
Historically, UICollectionViewFlowLayout was the go-to choice for UICollectionView. It's excellent for grid-based or line-based layouts, but its "flow" nature meant that all items in a section generally followed the same pattern. If you wanted a section with a horizontal scrolling list and another section with a three-column grid, you were looking at either hacky workarounds, multiple collection views, or diving deep into custom UICollectionViewLayout subclasses—a path often fraught with challenges.
UICollectionViewCompositionalLayout changes this paradigm entirely. Instead of calculating frames directly, you compose your layout from smaller, reusable components: items, groups, and sections. This declarative approach allows you to describe what your layout should look like, rather than how to calculate every single coordinate.
Understanding the Building Blocks
UICollectionViewCompositionalLayout is built upon a hierarchy of four fundamental components:
NSCollectionLayoutItem: The smallest unit. This represents a single cell in your collection view. You define its size relative to its container (the group).NSCollectionLayoutGroup: A container for one or more items. Groups define how items are arranged (e.g., horizontally, vertically, or custom). You define the group's size relative to its container (the section).NSCollectionLayoutSection: A container for one or more groups. Sections define the overall layout for a specific part of your collection view, including its content insets, inter-group spacing, and supplementary views (like headers/footers).UICollectionViewCompositionalLayout: The top-level object that takes one or moreNSCollectionLayoutSectioninstances to define the complete layout for yourUICollectionView.
This hierarchical structure allows for incredible flexibility. You define the layout from the inside out: item sizes are defined relative to their group, group sizes relative to their section, and section layouts make up the whole collection view layout.
Here's an ASCII diagram illustrating this hierarchy:
UICollectionViewCompositionalLayout
│
▼
NSCollectionLayoutSection
│
▼
NSCollectionLayoutGroup
│
▼
NSCollectionLayoutItem
Crafting Your First Compositional Layout: A Simple List
Let's start with a common scenario: a simple vertical list. Each item takes the full width and has a fixed height.
import UIKit
class ListViewController: UIViewController {
enum Section {
case main
}
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Int>!
override func viewDidLoad() {
super.viewDidLoad()
title = "Simple List"
configureHierarchy()
configureDataSource()
}
private func configureHierarchy() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemBackground
view.addSubview(collectionView)
// Register cell
collectionView.register(UICollectionViewListCell.self, forCellWithReuseIdentifier: "ListCell")
}
private func createLayout() -> UICollectionViewLayout {
// Item: full width, fixed height
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// Group: vertical stack of items, full width, dynamic height
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .estimated(44)) // Estimated height for self-sizing cells
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
// Section: contains the group
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
section.interGroupSpacing = 8 // Spacing between groups (items in this case)
return UICollectionViewCompositionalLayout(section: section)
}
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ListCell", for: indexPath) as! UICollectionViewListCell
var content = cell.defaultContentConfiguration()
content.text = "Item \(identifier)"
cell.contentConfiguration = content
return cell
}
// Initial snapshot
var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
snapshot.appendSections([.main])
snapshot.appendItems(Array(0..<30))
dataSource.apply(snapshot, animatingDifferences: false)
}
}
In this example:
itemSize:fractionalWidth(1.0)means the item will take 100% of its group's width.absolute(44)gives it a fixed height.groupSize:fractionalWidth(1.0)means the group will take 100% of its section's width.estimated(44)is important for vertical groups containing items withabsoluteheights, or for self-sizing cells.NSCollectionLayoutGroup.vertical: This convenience initializer creates a group that stacks its subitems vertically.NSCollectionLayoutSection: The section holds our group. We addcontentInsetsandinterGroupSpacingfor visual appeal.UICollectionViewCompositionalLayout(section: section): For a single-section layout, you can pass the section directly.
Adding Complexity: A Simple Grid Layout
Now, let's create a grid layout with multiple items per row. This is where CompositionalLayout really shines compared to FlowLayout for custom grid arrangements.
// ... (ListViewController structure, but for a new GridViewController)
class GridViewController: UIViewController {
enum Section {
case main
}
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Int>!
override func viewDidLoad() {
super.viewDidLoad()
title = "Simple Grid"
configureHierarchy()
configureDataSource()
}
private func configureHierarchy() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createGridLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemBackground
view.addSubview(collectionView)
collectionView.register(GridCell.self, forCellWithReuseIdentifier: GridCell.reuseIdentifier)
}
private func createGridLayout() -> UICollectionViewLayout {
// Item: one-third width, full height of its group
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
// Group: horizontal stack of 3 items, full width, one-third height of its section
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalWidth(1/3)) // Make group height proportional to width
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item, item])
// Or, more dynamically: NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, count: 3)
// Section: contains the group
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
return UICollectionViewCompositionalLayout(section: section)
}
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: GridCell.reuseIdentifier, for: indexPath) as! GridCell
cell.label.text = "\(identifier)"
return cell
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
snapshot.appendSections([.main])
snapshot.appendItems(Array(0..<90))
dataSource.apply(snapshot, animatingDifferences: false)
}
}
// Example custom cell for grid
class GridCell: UICollectionViewCell {
static let reuseIdentifier = "GridCell"
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configure() {
backgroundColor = .systemTeal.withAlphaComponent(0.6)
layer.cornerRadius = 8
label.font = UIFont.preferredFont(forTextStyle: .title2)
label.textAlignment = .center
label.textColor = .white
contentView.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
])
}
}
Key changes for the grid:
itemSize: Each item is1/3of the group's width and1.0(full) of the group's height.groupSize: We usefractionalWidth(1.0)for the group's width andfractionalWidth(1/3)for its height. This makes the group's height always1/3of its width, ensuring square cells if the items within the group are also square.NSCollectionLayoutGroup.horizontal(layoutSize:subitems:): This creates a horizontal row of items. We explicitly pass[item, item, item]to ensure 3 items per row. Alternatively,count: 3can be used.
Working with Multiple Sections and Different Layouts
The true power of CompositionalLayout comes when you need different layouts for different sections within the same collection view. This is achieved by creating an array of NSCollectionLayoutSection instances and passing a section provider closure to the UICollectionViewCompositionalLayout initializer.
Let's create a layout with a horizontal scrolling list in the first section and a grid in the second.
import UIKit
class MixedLayoutViewController: UIViewController {
enum Section: Int, CaseIterable {
case featuredProducts
case categories
}
var collectionView: UICollectionView!
var dataSource: UICollectionViewDiffableDataSource<Section, Int>!
override func viewDidLoad() {
super.viewDidLoad()
title = "Mixed Layout"
configureHierarchy()
configureDataSource()
}
private func configureHierarchy() {
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createMixedLayout())
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .systemBackground
view.addSubview(collectionView)
// Register cells
collectionView.register(ListCell.self, forCellWithReuseIdentifier: ListCell.reuseIdentifier)
collectionView.register(GridCell.self, forCellWithReuseIdentifier: GridCell.reuseIdentifier)
}
private func createMixedLayout() -> UICollectionViewLayout {
let layout = UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
guard let sectionKind = Section(rawValue: sectionIndex) else { return nil }
switch sectionKind {
case .featuredProducts:
// Horizontal scrolling list
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.9), // 90% of screen width
heightDimension: .absolute(150)) // Fixed height
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous // Make it horizontally scrollable
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
section.interGroupSpacing = 10
return section
case .categories:
// Grid layout (3 items per row)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3),
heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(100)) // Fixed height for grid row
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, item, item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
section.interGroupSpacing = 10
return section
}
}
return layout
}
private func configureDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, Int>(collectionView: collectionView) {
(collectionView: UICollectionView, indexPath: IndexPath, identifier: Int) -> UICollectionViewCell? in
guard let sectionKind = Section(rawValue: indexPath.section) else {
fatalError("Unknown section")
}
switch sectionKind {
case .featuredProducts:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ListCell.reuseIdentifier, for: indexPath) as! ListCell
cell.label.text = "Featured \(identifier)"
cell.backgroundColor = .systemRed.withAlphaComponent(0.6)
return cell
case .categories:
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: GridCell.reuseIdentifier, for: indexPath) as! GridCell
cell.label.text = "Cat \(identifier)"
cell.backgroundColor = .systemGreen.withAlphaComponent(0.6)
return cell
}
}
var snapshot = NSDiffableDataSourceSnapshot<Section, Int>()
snapshot.appendSections([.featuredProducts, .categories])
snapshot.appendItems(Array(0..<10), toSection: .featuredProducts)
snapshot.appendItems(Array(100..<120), toSection: .categories)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
// Reusing GridCell from before, and adding a ListCell
class ListCell: UICollectionViewCell {
static let reuseIdentifier = "ListCell"
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
configure()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configure() {
backgroundColor = .systemBlue.withAlphaComponent(0.6)
layer.cornerRadius = 8
label.font = UIFont.preferredFont(forTextStyle: .headline)
label.textAlignment = .center
label.textColor = .white
contentView.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
label.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor, constant: 5),
label.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -5)
])
}
}
In the createMixedLayout() function:
- We use the
UICollectionViewCompositionalLayout(sectionProvider:)initializer. This closure is called for each section index, allowing you to return a differentNSCollectionLayoutSectionbased on thesectionIndex. - For
.featuredProducts, we define a horizontal group and setsection.orthogonalScrollingBehavior = .continuousto make the section scroll horizontally. - For
.categories, we define our 3-column grid again. - The
layoutEnvironmentparameter in the section provider closure is crucial for adaptive layouts, allowing you to adjust layout based on the current trait collection, container size, etc.
Advanced Considerations
- Supplementary Views: You can easily add headers, footers, and other supplementary views to your sections using
NSCollectionLayoutBoundarySupplementaryItem. These are defined within theNSCollectionLayoutSection. - Decoration Views: For background visuals or custom separators that aren't tied to data,
NSCollectionLayoutDecorationItemallows you to add aesthetic elements directly to the layout. - Adaptive Layouts: The
layoutEnvironmentparameter in thesectionProviderclosure is key. You can checklayoutEnvironment.container.effectiveContentSizeorlayoutEnvironment.traitCollectionto create layouts that respond dynamically to size changes (e.g., device rotation, iPad multitasking). - Performance:
CompositionalLayoutis highly optimized. By defining layouts declaratively, the system can perform efficient calculations. When combined withUICollectionViewDiffableDataSource, you get excellent performance and simplified data management.
Summary
UICollectionViewCompositionalLayout offers a truly modern and flexible approach to building complex and adaptive collection view layouts. By embracing its hierarchical model of items, groups, and sections, you can create intricate designs with significantly less code and greater clarity than traditional methods. Its declarative nature simplifies layout logic, promotes reusability, and integrates seamlessly with DiffableDataSource for powerful and performant UIs. If you're building any non-trivial UICollectionView layout, CompositionalLayout is the way to go.
Happy Swifting!