Implementing 'UICollectionView Compositional' Layout with Pinterest Section

Written by vadimchistiakov | Published 2023/02/17
Tech Story Tags: swift | ios-app-development | ios-development | uikit | layout | pinterest | ui-ux | mobiledebugging

TLDRCollection 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. The framework also includes a number of built-in layout elements that can be used to create common collection view layouts.via the TL;DR App

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

Data Source

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

Carousel Section

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
}

  1. Let’s define the size of an individual item, which will be used as the basis for building the rest of the layout. This item will take up the full width and height of the available space, with a width dimension of .fractionalWidth(1) and a height dimension of .fractionalHeight(1).
  2. A group is created with the defined item size and set to be arranged horizontally using 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).
  3. The 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.
  4. The 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.

Widget section

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
}

  1. The group layout is defined by the 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.
  2. Section has a header defined as a 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.

Pinterest section

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) }
    }
}()

  1. Padding around cells equal to the distance between cells.

  2. The frames property is a lazy property that calculates the frames for each item in the section.

  3. The sectionHeight calculates the height of the entire section based on the maximum

    y-coordinate of all the items.

  4. 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
}

  1. 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.

  2. Sets up variables to store the x-coordinate offset for each column, the y-coordinate offset for each column, and an array of frames.

  3. 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.

  4. 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)
        }
    }
    
  5. 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
        }
    }
    

The final step

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 case
  • widgetBannerSection for the .widget case
  • pinterestSection for the .pinterest case

The returned value is a UICollectionViewCompositionalLayout object that has different sections depending on the value of sectionIndex.

Conclusion

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 :)


Written by vadimchistiakov | iOS team lead, developer. 6 years experience of specializing in mobile development. Super nerd who loves Apple and code
Published by HackerNoon on 2023/02/17