visit
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.
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.
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)
}
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.
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.
С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 }
}
The implementation of this section is slightly more complicated than the previous ones. Therefore, I will create a separate PinterestLayoutSection
class for it.
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.
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) }
}
}()
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
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.
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.
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
}
}
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
.
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.