Hello, this is Rasel Miah from iOS team. This time I will discuss about the modern way of creating collection views layout using the new declarative API called Compositional Layout
.
For years, UICollectionViewFlowLayout
helped us to create a simple grid layout with a little customization. We can also create a complex layout by subclassing UICollectionViewFlowLayout
, or creating our own layout by subclassing UICollectionViewLayout
.
But it is time consuming and of course not easy to make.
Gradly, at WWDC 2019, Apple introduced Compositional Layout
which is a powerful and declarative API that allows us to build a large layout by stitching together smaller layout groups. We'll start by creating a simple grid using UICollectionViewFlowLayout
and the try to achieve the same design using the Compositional Layout
. After that I'll show you how to build a complex layout using the new API.
Let's start coding.
As I used a basic collection view, I will just show the layout creating part here.
private func createLayout() -> UICollectionViewLayout { let layout = UICollectionViewFlowLayout() layout.minimumInteritemSpacing = 5 layout.minimumLineSpacing = 5 layout.sectionInset = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) return layout }
This is the layout customization part using flow layout. We also need to implement the func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
method of UICollectionViewDelegateFlowLayout
to provide a size based on the width of the collection views's current bounds.
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let width = collectionView.bounds.width let numberOfItemsPerRow: CGFloat = 3 let spacing: CGFloat = 5 let availableWidth = width - spacing * (numberOfItemsPerRow + 1) let itemDimension = floor(availableWidth / numberOfItemsPerRow) return CGSize(width: itemDimension, height: itemDimension) }
And that's it. We need to calculate the availableWidth
and then need to divide it by the numberOfItemsPerRow
. Easy right? 😊
UICollectionViewCompositionalLayout
Now it's time to achieve the same design with our new API. Forget everything you know about collection view layouts!
Before starting, we need to know some basic classes of Compositional Layout
.
NSCollectionLayoutSize
: A pair of width dimension and a height dimension. Every component of a collection view layout has an explicit size. You can express the dimensions using an absolute, estimated, or fractional value. According to the Apple's documentation -Use an absolute value to specify exact dimensions, like a 44 x 44 point square
Use an estimated value if the size of your content might change at runtime, such as when data is loaded or in response to a change in system font size. You provide an initial estimated size and the system computes the actual value later.
Use a fractional value to define a value that's relative to a dimension of the item's container. This option simplifies specifying aspect ratios. For example, the following item has a width and a height that are both equal to 20% of its container's width, creating a square that grows and shrinks as the size of its container changes.
NSCollectionLayoutItem
: Generally, an item is a cell. An item represents a single view that's rendered onscreen.NSCollectionLayoutGroup
: It holds the NSCollectionLayoutItem in either horizontal, vertical, or custom forms.NSCollectionLayoutSection
: A collection view layout has one or more sections. Sections provide a way to separate the layout into distinct pieces.
We have learnt a lot of basics. 😁 Let's demonstrate this.
private func createLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { (sectionIndex, _) -> NSCollectionLayoutSection? in /// Every group has 3 items. The full width of the group is ``` .fractionalWidth(1)```. /// So, the width of one item will be the ```.fractionalWidth(1/3)```. /// Height will be the same height of it's group. let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.33), heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) /// Add some paddings item.contentInsets = .init(top: 5, leading: 5, bottom: 0, trailing: 5) /// Group width will be the full width of it's section. For height, we can use an absolute value. /// But I want to make a squire, so i have passed the width as same as the item's width. let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.33)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) /// Finally create & return our section return NSCollectionLayoutSection(group: group) }
And that's it. You will get a same design that we built with the flow layout. At this point you may ask, "What's the benefit of Compositional Layout as we can build the same layout with flow layout by a little customization?". As I mentioned earlier, It's very easy to create simple grid with the flow layout. Now think about the following layout.
Here, we have 5 sections.
- Tokyo, kyoto, osaka: Horizontally scrollable.
- Okinawa: Grid
- Nagano: List. Looks like a table view.
Sections are a simple enum. I have used the Pexels API for image fetching.
enum PhotoQuery: Int, CaseIterable { case tokyo, kyoto, osaka, okinawa ,nagano var query: String { switch self { case .tokyo: return "tokyo" case .kyoto: return "kyoto" case .osaka: return "osaka" case .okinawa: return "okinawa" case .nagano: return "nagano" } } var radiusType: PhotoCell.PhotoType { switch self { case .tokyo: return .circle default: return .radius(4.0) } } }
First we will try to create tokyo, osaka and okinawa section. Because they are quite similar.
private func createLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { [unowned self] (sectionIndex, env) -> NSCollectionLayoutSection? in guard let section = PhotoService.PhotoQuery.init(rawValue: sectionIndex) else { return nil } switch section { case .tokyo: /// (1) /// Five items per group. So, width = 1/5 = 0.20, height = full = 1 let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.20), heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) item.contentInsets = .init(top: 5, leading: 5, bottom: 5, trailing: 5) /// (2) /// Group height = Same as item width let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.20)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) /// Enable horizontal scrolling section.orthogonalScrollingBehavior = .continuous /// Set supplementary header section.supplementariesFollowContentInsets = false section.boundarySupplementaryItems = [supplementaryHeaderItem()] return section case .osaka: /// (1) /// 1 item per group let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) /// (2) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.90), heightDimension: .fractionalWidth(0.60)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) /// Padding between group section.interGroupSpacing = 10 section.orthogonalScrollingBehavior = .groupPagingCentered section.supplementariesFollowContentInsets = false section.boundarySupplementaryItems = [supplementaryHeaderItem()] return section case .okinawa: /// (1) /// Five items per group let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.20), heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) item.contentInsets = .init(top: 5, leading: 5, bottom: 5, trailing: 5) /// (2) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.20)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.supplementariesFollowContentInsets = false section.boundarySupplementaryItems = [supplementaryHeaderItem()] return section default: return nil } } // Supplementary Header private func supplementaryHeaderItem() -> NSCollectionLayoutBoundarySupplementaryItem { NSCollectionLayoutBoundarySupplementaryItem( layoutSize: .init(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top ) }
They are quite similar without height & width. So, we can modify our section enum like this.
enum PhotoQuery: Int, CaseIterable { case tokyo, kyoto, osaka, okinawa ,nagano var query: String { switch self { case .tokyo: return "tokyo" case .kyoto: return "kyoto" case .osaka: return "osaka" case .okinawa: return "okinawa" case .nagano: return "nagano" } } var radiusType: PhotoCell.PhotoType { switch self { case .tokyo: return .circle default: return .radius(4.0) } } /// new properties ↓ var itemWidth: NSCollectionLayoutDimension { switch self { case .tokyo, .okinawa: return .fractionalWidth(0.20) case .osaka: return .fractionalWidth(1) default: return .fractionalWidth(1) } } var groupWidth: NSCollectionLayoutDimension { switch self { case .tokyo, .okinawa: return .fractionalWidth(1) case .osaka: return .fractionalWidth(0.90) default: return .fractionalWidth(1) } } var groupHeight: NSCollectionLayoutDimension { switch self { case .tokyo, .okinawa: return .fractionalWidth(0.20) case .osaka: return .fractionalWidth(0.60) default: return .fractionalWidth(1) } } var scrollBehavior: UICollectionLayoutSectionOrthogonalScrollingBehavior? { switch self { case .tokyo: return .continuous case .kyoto: return .paging case .osaka: return .groupPagingCentered default: return nil } } var itemInsets: NSDirectionalEdgeInsets? { switch self { case .tokyo, .okinawa: return .init(top: 5, leading: 5, bottom: 5, trailing: 5) default: return nil } } var sectionInterGroupSpacing: CGFloat { switch self { case .osaka: return 10.0 default: return 0.0 } } }
Now update the layout as well.
private func createLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { [unowned self] (sectionIndex, env) -> NSCollectionLayoutSection? in guard let section = PhotoService.PhotoQuery.init(rawValue: sectionIndex) else { return nil } switch section { case .tokyo, .osaka, .okinawa: /// (1) let itemSize = NSCollectionLayoutSize(widthDimension: section.itemWidth, heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) if let contentInsets = section.itemInsets { item.contentInsets = contentInsets } /// (2) let groupSize = NSCollectionLayoutSize(widthDimension: section.groupWidth, heightDimension: section.groupHeight) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let layoutSection = NSCollectionLayoutSection(group: group) layoutSection.interGroupSpacing = section.sectionInterGroupSpacing /// Enable horizontal scrolling if let scroll = section.scrollBehavior { layoutSection.orthogonalScrollingBehavior = scroll } /// Set supplementary header layoutSection.supplementariesFollowContentInsets = false layoutSection.boundarySupplementaryItems = [supplementaryHeaderItem()] return layoutSection default: return nil } }
Next, we will create the Nagano section. It's very easy. Just 3 line of code. Let's see.
private func createLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { [unowned self] (sectionIndex, env) -> NSCollectionLayoutSection? in guard let section = PhotoService.PhotoQuery.init(rawValue: sectionIndex) else { return nil } switch section { case .tokyo, .osaka, .okinawa: /// (1) let itemSize = NSCollectionLayoutSize(widthDimension: section.itemWidth, heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) if let contentInsets = section.itemInsets { item.contentInsets = contentInsets } /// (2) let groupSize = NSCollectionLayoutSize(widthDimension: section.groupWidth, heightDimension: section.groupHeight) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let layoutSection = NSCollectionLayoutSection(group: group) layoutSection.interGroupSpacing = section.sectionInterGroupSpacing /// Enable horizontal scrolling if let scroll = section.scrollBehavior { layoutSection.orthogonalScrollingBehavior = scroll } /// Set supplementary header layoutSection.supplementariesFollowContentInsets = false layoutSection.boundarySupplementaryItems = [supplementaryHeaderItem()] return layoutSection case .nagano: var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped) listConfig.headerMode = .supplementary return NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: env) default: return nil } }
Finally, it's time to create the Kyoto section. Here we have total 3 groups. First group contains the upper-left single item & upper-right two items. Inside this group, there is another inner group which contains the upper-right two items. The lower group contains five images horizontally. The nasted group consists of the upper group & lower group.
private func createLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { [unowned self] (sectionIndex, env) -> NSCollectionLayoutSection? in guard let section = PhotoService.PhotoQuery.init(rawValue: sectionIndex) else { return nil } switch section { case .tokyo, .osaka, .okinawa: /// (1) let itemSize = NSCollectionLayoutSize(widthDimension: section.itemWidth, heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) if let contentInsets = section.itemInsets { item.contentInsets = contentInsets } /// (2) let groupSize = NSCollectionLayoutSize(widthDimension: section.groupWidth, heightDimension: section.groupHeight) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let layoutSection = NSCollectionLayoutSection(group: group) layoutSection.interGroupSpacing = section.sectionInterGroupSpacing /// Enable horizontal scrolling if let scroll = section.scrollBehavior { layoutSection.orthogonalScrollingBehavior = scroll } /// Set supplementary header layoutSection.supplementariesFollowContentInsets = false layoutSection.boundarySupplementaryItems = [supplementaryHeaderItem()] return layoutSection case .nagano: var listConfig = UICollectionLayoutListConfiguration(appearance: .grouped) listConfig.headerMode = .supplementary return NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: env) case .kyoto: /// single item. let singleItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.60), heightDimension: .fractionalHeight(1)) let singleItem = NSCollectionLayoutItem(layoutSize: singleItemSize) singleItem.contentInsets = .init(top: 0, leading: 5, bottom: 5, trailing: 5) /// pair items. two images vertically let pairItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.5)) let pairItem = NSCollectionLayoutItem(layoutSize: pairItemSize) pairItem.contentInsets = .init(top: 0, leading: 5, bottom: 5, trailing: 5) let pairGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.40), heightDimension: .fractionalHeight(1)) let pairItemGroup = NSCollectionLayoutGroup.vertical(layoutSize: pairGroupSize, subitem: pairItem, count: 2) /// make a group with single item & pair items let singleWithPairGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(0.70)) let singleWithPairGroup = NSCollectionLayoutGroup.horizontal(layoutSize: singleWithPairGroupSize, subitems: [singleItem, pairItemGroup]) /// five items let fiveItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.33), heightDimension: .fractionalHeight(1)) let fiveItem = NSCollectionLayoutItem(layoutSize: fiveItemSize) fiveItem.contentInsets = .init(top: 0, leading: 5, bottom: 0, trailing: 5) let fiveItemGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.20)) let fiveItemGroup = NSCollectionLayoutGroup.horizontal(layoutSize: fiveItemGroupSize, subitem: fiveItem, count: 5) /// Nasted group let nastedGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalWidth(0.70)) let nastedGroup = NSCollectionLayoutGroup.vertical(layoutSize: nastedGroupSize, subitems: [singleWithPairGroup, fiveItemGroup]) let section = NSCollectionLayoutSection(group: nastedGroup) section.orthogonalScrollingBehavior = .paging section.supplementariesFollowContentInsets = false section.boundarySupplementaryItems = [supplementaryHeaderItem()] return section } }
That's all. we have created five kinds of layout with a single collection view without sub classing. We can even create more complex and exciting layouts using horizontal, vertical, and custom groups, badges in our compositional layout.
Compositional Layout
is really a powerful API that will save our time.
You can also watch the WWDC 2019 for better understanding.
Advances in Collection View Layout - WWDC19 - Videos - Apple Developer
At WWDC 2019, apple also introduced Diffable Data Source
. It works well with the compositional layouts as it uses type-safe identifiers to identify its sections and items. I want to discuss about it, But Not Today !!! May be in future post. 😀
Thank you for reading. Happy Coding... 👨💻