ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Modern Collection Views

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.

A simple grid layout

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.

Hierarchy of a compositional layout

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.

drive.google.com

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.

https://www.pexels.com/

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.

Tokyo Section
Osaka Section
Okinawa Section

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.

Kyoto Section

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... 👨‍💻

unifa-e.com