Collection Compositional Layout is a new layout framework in the UIKit framework of iOS that was introduced in iOS 13. It provides a powerful and flexible way to build custom collection views in a modular and composable manner. With Collection Compositional Layout, you can define custom layouts for your collection views by composing various layout elements, such as sections, items, and groups, in a way that best suits your needs.
You can use Collection Compositional Layout to define layouts that support dynamic content and varying item sizes, and you can also easily modify the layout of your collection view on the fly. The framework also includes a number of built-in layout elements that can be used to create common collection view layouts, such as grid, list, and nested groups.
To use Collection Compositional Layout in your iOS app, you need to create an instance of UICollectionViewCompositionalLayout
, and then define one or more NSCollectionLayoutSection
objects that represent the layout for each section in your collection view. You can then set the collectionView
property of your layout to your UICollectionView
instance, and the layout will be automatically applied to your collection view.
Today, we will thoroughly examine how to create such a screen using Collection Compositional Layout
Initially, it is necessary to include two typealiases and a sections enum.
I would like to utilize DiffableDataSource to easily update the content and add new content if necessary
typealias DataSource = UICollectionViewDiffableDataSource<Section, PictureModel>
typealias DataSourceSnapshot = NSDiffableDataSourceSnapshot<Section, PictureModel>
enum Section: Int, CaseIterable {
case carousel
case widget
case pinterest
}
I set up the data source for a collection view. The function takes in two parameters: an [PictureModel]
and a boolean value "animatingDifferences". The function starts by deleting all the items in the data source's snapshot using the "deleteAllItems" method. Then, it appends all the cases of the Section
to the snapshot's sections.
Next, the function appends a range of pictures to each section.
Finally, the function applies the snapshot to the data source using apply
. The "animatingDifferences" parameter determines whether changes to the collection view are animated or not.
private func configureDataSource(pictures: [PictureModel], animatingDifferences: Bool) {
snapshot.deleteAllItems()
snapshot.appendSections(Section.allCases)
snapshot.appendItems(pictures[20...29].map { $0 }, toSection: .carousel)
snapshot.appendItems(pictures[10...19].map { $0 }, toSection: .widget)
snapshot.appendItems(pictures[0...9].map { $0 }, toSection: .pinterest)
dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
For more information how data source works you can find there:
iOS Tutorial: Collection View and Diffable Data Source
The first section is “Carousel-style” or “Multiple banner”
private static func carouselBannerSection() -> NSCollectionLayoutSection {
//1
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
//2
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalWidth(1)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
//3
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
//4
section.visibleItemsInvalidationHandler = { (items, offset, environment) in
items.forEach { item in
let distanceFromCenter = abs((item.frame.midX - offset.x) - environment.container.contentSize.width / 2.0)
let minScale: CGFloat = 0.8
let maxScale: CGFloat = 1.0 - (distanceFromCenter / environment.container.contentSize.width
let scale = max(maxScale, minScale)
item.transform = CGAffineTransform(scaleX: scale, y: scale)
}
}
return section
}
.fractionalWidth(1)
and a height dimension of .fractionalHeight(1)
.NSCollectionLayoutGroup.horizontal
. This group takes up the full width of the available space with a width dimension of .fractionalWidth(1)
and a height dimension equal to its width with .fractionalWidth(1)
.NSCollectionLayoutSection
is then created using the defined group, and its orthogonal scrolling behavior is set to .continuousGroupLeadingBoundary
, which means that the section will continuously scroll in the horizontal direction.visibleItemsInvalidationHandler
of the section is set to a closure that performs a scaling transformation on each item based on its distance from the center of the visible area. The amount of scaling is determined by the distance from the center, with items closer to the center being scaled up and items farther away being scaled down. The minimum and maximum scale values are defined as minScale
and maxScale
respectively.
This section is implemented almost the same way as the previous one. The only differences are in the configuration of the group
private static func widgetBannerSection() -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.contentInsets = .init(top: 0, leading: 5, bottom: 0, trailing: 5)
//1
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(0.2),
heightDimension: .fractionalWidth(0.3)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let section = NSCollectionLayoutSection(group: group)
//2
let supplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: .init(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(30)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .top
)
supplementaryItem.contentInsets = .init(
top: 0,
leading: 5,
bottom: 0,
trailing: 5
)
section.boundarySupplementaryItems = [supplementaryItem]
section.contentInsets = .init(top: 10, leading: 5, bottom: 10, trailing: 5)
section.orthogonalScrollingBehavior = .continuous
return section
}
NSCollectionLayoutGroup
object, which is horizontal and has a width and height defined as 20% and 30% of the collection view's width, respectively. The group consists of a single item, which is defined above.NSCollectionLayoutBoundarySupplementaryItem
. The header is placed at the top of the section and has the same width as the section, with a height of 30 points. The header's content inset by 5 points from the leading and trailing edges.
A Pinterest-style layout is a type of grid-based user interface design that arranges content into a series of evenly spaced columns, with variable-sized cells that contain images. The layout is commonly used in applications like photo sharing and social media platforms.
The Pinterest-style layout gives the layout a more organic, less structured feel than traditional grid-based designs, and can help to break up the monotony of a page filled with equally sized cells.
Сells in this section are presented in different ratios. So, models for that cells have to conform to the Ratioable
protocol that defines a single requirement, the ratio
property, which is a CGFloat
value.
protocol Ratioable {
var ratio: CGFloat { get }
}
An aspect ratio is the proportional relationship between the width and height of an object.
The implementation of this section is slightly more complicated than the previous ones. Therefore, I will create a separate PinterestLayoutSection
class for it.
The private properties inside of this class are:
private let numberOfColumns: Int
private let itemRatios: [Ratioable]
private let spacing: CGFloat
private let contentWidth: CGFloat
In order to correctly calculate the size of the section, we must pass [Ratioable]
array of elements that stores the ratio for each future cell. Also we need to have a certain number of columns and full content width.
For ease of understanding the code, let's add computed and lazy properties.
private var padding: CGFloat {
spacing / 2
}
// 1
private var insets: NSDirectionalEdgeInsets {
.init(
top: padding,
leading: padding,
bottom: padding,
trailing: padding
)
}
// 2
private lazy var frames: [CGRect] = {
calculateFrames()
}()
// 3
private lazy var sectionHeight: CGFloat = {
(frames
.map(\.maxY)
.max() ?? 0
) + insets.bottom
}()
// 4
private lazy var customLayoutGroup: NSCollectionLayoutGroup = {
let layoutSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(sectionHeight)
)
return NSCollectionLayoutGroup.custom(layoutSize: layoutSize) { _ in
self.frames.map { .init(frame: $0) }
}
}()
Padding around cells equal to the distance between cells.
The frames
property is a lazy property that calculates the frames for each item in the section.
The sectionHeight
calculates the height of the entire section based on the maximum
y-coordinate of all the items.
The customLayoutGroup
is a lazy property that calculates the layout group for the section. It specifies the size of the section and returns an array of layout items based on the calculated frames. The layout group is created using the NSCollectionLayoutGroup.custom
method.
The last but not the least. We need to define calculateFrames
method.
private func calculateFrames() -> [CGRect] {
var contentHeight: CGFloat = 0
// 1
let columnWidth = (contentWidth - insets.leading - insets.trailing) /
CGFloat(numberOfColumns)
// 2
let xOffset = (0..<numberOfColumns).map { CGFloat($0) * columnWidth }
var currentColumn = 0
var yOffset: [CGFloat] = .init(repeating: 0, count: numberOfColumns)
// Total number of frames
var frames = [CGRect]()
// 3
for index in 0..<itemRatios.count {
let aspectRatio = itemRatios[index]
// Сalculate the frame.
let frame = CGRect(
x: xOffset[currentColumn],
y: yOffset[currentColumn],
width: columnWidth,
height: columnWidth / aspectRatio.ratio
)
// Total frame inset between cells and along edges
.insetBy(dx: padding, dy: padding)
// Additional top and left offset to account for padding
.offsetBy(dx: 0, dy: insets.leading)
// 4
.setHeight(ratio: aspectRatio.ratio)
frames.append(frame)
// Сalculate the height
let columnLowestPoint = frame.maxY
contentHeight = max(contentHeight, columnLowestPoint)
yOffset[currentColumn] = columnLowestPoint
// 5
currentColumn = yOffset.indexOfMinElement ?? 0
}
return frames
}
The calculateFrames
is responsible for calculating the frames for each item. First, the width of each column is calculated by subtracting the margin from the total width and dividing it by the number of columns.
Sets up variables to store the x-coordinate offset for each column, the y-coordinate offset for each column, and an array of frames.
The function uses a loop to iterate through the itemRatios
array, calculate the frame for each item based on its aspect ratio, and append it to the frames
array.
The method updates the height to keep the correct aspect ratio. Use extension for it:
private extension CGRect {
func setHeight(ratio: CGFloat) -> CGRect {
.init(x: minX, y: minY, width: width, height: width / ratio)
}
}
Adding the next element to the minimum height column. We can move sequentially, but then there is a chance that some columns will be much longer than others. For convenience, add the extension for Array
. The computable property helps to find the index of the first minimum element in an array:
private extension Array where Element: Comparable {
var indexOfMinElement: Int? {
guard count > 0 else { return nil }
var min = first
var index = 0
indices.forEach { i in
let currentItem = self[i]
if let minumum = min, currentItem < minumum {
min = currentItem
index = i
}
}
return index
}
}
In order to connect all the sections together into one layout, we will create another class.
CustomCompositionalLayout
.
final class CustomCompositionalLayout {
static func layout(
ratios: [Ratioable],
contentWidth: CGFloat
) -> UICollectionViewCompositionalLayout {
.init { sectionIndex, enviroment in
guard let section = Section(rawValue: sectionIndex)
else { return nil }
switch section {
case .carousel :
return carouselBannerSection()
case .widget :
return widgetBannerSection()
case .pinterest:
return pinterestSection(ratios: ratios, contentWidth: contentWidth)
}
}
}
}
It has a static function named layout
that takes in two parameters, ratios
and contentWidth
.
Don't forget to add already implemented methods to this class.
The sections that can be returned are:
carouselBannerSection
for the .carousel
casewidgetBannerSection
for the .widget
casepinterestSection
for the .pinterest
caseThe returned value is a UICollectionViewCompositionalLayout
object that has different sections depending on the value of sectionIndex
.
This example of screen layout is a unique and visually appealing way to display content in a collection view. It is achieved through the use of the UICollectionViewCompositionalLayout
and the custom aspect ratios of each item in the collection.
Implementing this layout can greatly enhance the user experience and bring a fresh and dynamic look to your app. With its ability to handle varying aspect ratios and adjust content dynamically, the layout offers a versatile and practical solution for displaying content in a collection view.
Overall, the layout with Pinterest-style section is a valuable addition to any iOS developer's toolkit and can add a touch of creativity and design to your next app project.
This article was inspired by UICollectionView Custom Layout Tutorial: Pinterest.
The full implementation with network layer by MVVM pattern you can find on my Github.
Feel free to put stars here and on github :)