UICollectionViewController flow layout with readable content width

Viewed 1971

I have a UICollectionView using flow layout, and I'm trying to achieve the same margins as a UITableViewController with readable content width.

The closest I've come to matching this layout behavior is to embed a UICollectionViewController within a UIViewController and have the embedded view "Follow Readable Width".

UICollectionViewController embedded in UIViewController

Here the teal color is the UIViewController and the salmon color is the UICollectionViewController. The problem is the teal area doesn't scroll the UICollectionView and the scroll indicators are also not along the edge of the screen like you would expect.

My question is:

How can I achieve this layout without having to embed a UICollectionViewController?

My guess is that I can somehow set the UICollectionView left and right section insets to match the readable content guide margins and update them by overriding viewWillTransition(to size: with coordinator:) and observing UIContentSizeCategoryDidChange notifications, but I'm not sure how to go about this.

2 Answers

In order to solve your problem with Swift 5.1 and iOS 13, you can set your flow layout's sectionInsetReference property to .fromContentInset, set your collection view's insetsLayoutMarginsFromSafeArea property to false and set your collection view's contentInsets to match the view's readableContentGuide's insets.


The following sample code shows a possible implementation in order to have a collection view that has a readable content width:

ViewController.swift

import UIKit

class ViewController: UIViewController {

    let collectionView = UICollectionView(
        frame: .zero,
        collectionViewLayout: ColumnFlowLayout(cellsPerRow: 5)
    )
    var collectionViewNeedsLayout = true

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "Demo"

        view.addSubview(collectionView)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])

        collectionView.backgroundColor = .white
        collectionView.insetsLayoutMarginsFromSafeArea = false
        collectionView.dataSource = self
        collectionView.register(Cell.self, forCellWithReuseIdentifier: "Cell")
    }

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()

        if collectionViewNeedsLayout {
            let margin = view.readableContentGuide.layoutFrame.origin.x
            collectionView.contentInset.left = margin
            collectionView.contentInset.right = margin
            collectionViewNeedsLayout = false
        }
    }

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        collectionViewNeedsLayout = true
    }

}
extension ViewController: UICollectionViewDataSource {

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 59
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! Cell
        return cell
    }

}

ColumnFlowLayout.swift

import UIKit

class ColumnFlowLayout: UICollectionViewFlowLayout {

    let cellsPerRow: Int

    init(cellsPerRow: Int) {
        self.cellsPerRow = cellsPerRow

        super.init()

        self.sectionInsetReference = .fromContentInset
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func prepare() {
        super.prepare()

        guard let collectionView = collectionView else { return }

        let marginsAndInsets = collectionView.contentInset.left + collectionView.contentInset.right + sectionInset.left + sectionInset.right + minimumInteritemSpacing * CGFloat(cellsPerRow - 1)
        let itemWidth = ((collectionView.bounds.width - marginsAndInsets) / CGFloat(cellsPerRow)).rounded(.down)
        itemSize = CGSize(width: itemWidth, height: itemWidth)
    }

}

Cell.swift

import UIKit

class Cell: UICollectionViewCell {

    override init(frame: CGRect) {
        super.init(frame: frame)

        contentView.backgroundColor = .orange
    }

    required init?(coder: NSCoder) {
        fatalError("not implemnted")
    }

}

Display on iPhone 11 Pro Max:

enter image description here

You hunch is correct, the simplest way to do this is via the collection view's insetForSection. In my experience, the following is required:

  • Return the collection view's directionalLayoutMargins as the inset (use .leading and .trailing
  • Disable automatic safe area handling via collectionView.insetsLayoutMarginsFromSafeArea = false
  • Manually set the collection view's directionalLayoutMargins using view.readableContentGuide.layoutFrame.origin.x. Note that I am using view.readableContentGuide, not that of the collectionView.
  • Update the directionalLayoutMargins in exactly the same way whenever viewLayoutMarginsDidChange

Still would be good to know how to accomplish this without any type of container

Related