18

I would like to replicate the paging in the multi-row App Store collection view:

enter image description here

So far I've designed it as close as possible to the way it looks, including showing a peek to the previous and next cells, but do not know how to make the paging to work so it snaps the next group of 3:

override func viewDidLoad() {
    super.viewDidLoad()

    collectionView.collectionViewLayout = MultiRowLayout(
        rowsCount: 3,
        inset: 16
    )
}

...

class MultiRowLayout: UICollectionViewFlowLayout {
    private var rowsCount: CGFloat = 0

    convenience init(rowsCount: CGFloat, spacing: CGFloat? = nil, inset: CGFloat? = nil) {
        self.init()

        self.scrollDirection = .horizontal
        self.minimumInteritemSpacing = 0
        self.rowsCount = rowsCount

        if let spacing = spacing {
            self.minimumLineSpacing = spacing
        }

        if let inset = inset {
            self.sectionInset = UIEdgeInsets(top: 0, left: inset, bottom: 0, right: inset)
        }
    }

    override func prepare() {
        super.prepare()

        guard let collectionView = collectionView else { return }
        self.itemSize = calculateItemSize(from: collectionView.bounds.size)
    }

    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        guard let collectionView = collectionView,
            !newBounds.size.equalTo(collectionView.bounds.size) else {
                return false
        }

        itemSize = calculateItemSize(from: collectionView.bounds.size)
        return true
    }
}

private extension MultiRowLayout {

    func calculateItemSize(from bounds: CGSize) -> CGSize {
        return CGSize(
            width: bounds.width - minimumLineSpacing * 2 - sectionInset.left,
            height: bounds.height / rowsCount
        )
    }
}

Unfortunately, the native isPagingEnabled flag on UICollectionView only works if the cell is 100% width of the collection view, so the user wouldn’t get a peek and the previous and next cell.

I have a working snap paging functionality but only for a single item per page, not this 3-row kind of collection. Can someone help make the snap paging work for the grouped rows instead of for a single item per page?

TruMan1
  • 33,665
  • 59
  • 184
  • 335
  • Use a tableview in your cell so that you only have a single collection view cell (and a little bit of the next/previous cells) in view – Paulw11 Jun 25 '18 at 00:51
  • A table view in a collection view cell seems a bit overkill, especially when the vertical layout with horizontal scrolling lays it out efficiently. – TruMan1 Jun 25 '18 at 01:05
  • Its up to you, but if you used a tableview you would probably be done already and you wouldn't need all of that complicated code. The paging/snapping would be handled for you by the collection view and the layout of the three rows would be handled by the tableview. – Paulw11 Jun 25 '18 at 01:40
  • You could use either `UIPageViewController` or use `scrollViewWillEndDragging` method of `UIScrollViewDelegate` to position when the ends scrolling – user1046037 Jun 25 '18 at 02:05
  • @Paulw11 even with a table view it wouldn’t be that simple because unfortunately the native pager only works when the collection view cell is full width, but the App Store example peeks a bit at the previous and next cell, so that complex paging logic would still be needed I think. – TruMan1 Jun 25 '18 at 03:21
  • Could even use a stack view instead, but still couldn’t use the native paging I think. Plus grouping the data source in 3’s would be really awkward dealing with. – TruMan1 Jun 25 '18 at 03:25

3 Answers3

31

There is no reason to subclass UICollectionViewFlowLayout just for this behavior.

UICollectionView is a subclass of UIScrollView, so its delegate protocol UICollectionViewDelegate is a subtype of UIScrollViewDelegate. This means you can implement any of UIScrollViewDelegate’s methods in your collection view’s delegate.

In your collection view’s delegate, implement scrollViewWillEndDragging(_:withVelocity:targetContentOffset:) to round the target content offset to the top left corner of the nearest column of cells.

Here's an example implementation:

override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    let layout = collectionViewLayout as! UICollectionViewFlowLayout
    let bounds = scrollView.bounds
    let xTarget = targetContentOffset.pointee.x

    // This is the max contentOffset.x to allow. With this as contentOffset.x, the right edge of the last column of cells is at the right edge of the collection view's frame.
    let xMax = scrollView.contentSize.width - scrollView.bounds.width

    if abs(velocity.x) <= snapToMostVisibleColumnVelocityThreshold {
        let xCenter = scrollView.bounds.midX
        let poses = layout.layoutAttributesForElements(in: bounds) ?? []
        // Find the column whose center is closest to the collection view's visible rect's center.
        let x = poses.min(by: { abs($0.center.x - xCenter) < abs($1.center.x - xCenter) })?.frame.origin.x ?? 0
        targetContentOffset.pointee.x = x
    } else if velocity.x > 0 {
        let poses = layout.layoutAttributesForElements(in: CGRect(x: xTarget, y: 0, width: bounds.size.width, height: bounds.size.height)) ?? []
        // Find the leftmost column beyond the current position.
        let xCurrent = scrollView.contentOffset.x
        let x = poses.filter({ $0.frame.origin.x > xCurrent}).min(by: { $0.center.x < $1.center.x })?.frame.origin.x ?? xMax
        targetContentOffset.pointee.x = min(x, xMax)
    } else {
        let poses = layout.layoutAttributesForElements(in: CGRect(x: xTarget - bounds.size.width, y: 0, width: bounds.size.width, height: bounds.size.height)) ?? []
        // Find the rightmost column.
        let x = poses.max(by: { $0.center.x < $1.center.x })?.frame.origin.x ?? 0
        targetContentOffset.pointee.x = max(x, 0)
    }
}

// Velocity is measured in points per millisecond.
private var snapToMostVisibleColumnVelocityThreshold: CGFloat { return 0.3 }

Result:

demo

You can find the full source code for my test project here: https://github.com/mayoff/multiRowSnapper

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • It was working for me and worked beautifully, thank you @rob!! – TruMan1 Jul 05 '18 at 14:55
  • 1
    @Rob . Thank you for sharing your code! I have only two cells to display. The first cell is offset 20 points from the left. The second cell is offset 20 points from the right. I have `collectionView.contentInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)` . When the app launches, the left offset appears. When I start scrolling, I lose the offsets as the first cell appears pinned to the left, and the second cell appears pinned to the right . How can I maintain the offsets? – user1107173 Apr 18 '19 at 13:48
  • You need to post your own top-level question with more information. – rob mayoff Apr 18 '19 at 15:36
  • the OP said he wants to show the previous cell and the next cell – Omar N Shamali May 01 '20 at 17:04
12

With iOS 13 this became a lot easier!

In iOS 13 you can use a UICollectionViewCompositionalLayout.

This introduces a couple of concepts and I will try to give a gist over here, but I think is worth a lot to understand this!

Concepts

In a CompositionalLayout you have 3 entities that allow you to specify sizes. You can specify sizes using absolute values, fractional values (half, for instance) or estimates. The 3 entities are:

  • Item (NSCollectionLayoutItem)

Your cell size. Fractional sizes are relative to the group the item is in and they can consider the width or the height of the parent.

  • Group (NSCollectionLayoutGroup)

Groups allow you to create a set of items. In that app store example, a group is a column and has 3 items, so your items should take 0.33 height from the group. Then, you can say that the group takes 300 height, for instance.

  • Section(NSCollectionLayoutSection)

Section declares how the group will repeat itself. In this case it is useful for you to say the section will be horizontal.

Creating the layout

You create your layout with a closure that receives a section index and a NSCollectionLayoutEnvironment. This is useful because you can have different layouts per trait (on iPad you can have something different, for instance) and per section index (i.e, you can have 1 section with horizontal scroll and another that just lays out things vertically).

func createCollectionViewLayout() {
   let layout = UICollectionViewCompositionalLayout { sectionIndex, _ in
      return self.createAppsColumnsLayout()
   }

   let config = UICollectionViewCompositionalLayoutConfiguration()
   config.scrollDirection = .vertical
   config.interSectionSpacing = 31

   layout.configuration = config
   return layout
}

The app store example

In the case of the app store you have a really good video by Paul Hudson, from the Hacking with Swift explaining this. He also has a repo with this!

However, I will put here the code so this doesn't get lost:

func createAppsColumnsLayout(using section: Section) -> NSCollectionLayoutSection {
   let itemSize = NSCollectionLayoutSize(
      widthDimension: .fractionalWidth(1), 
      heightDimension: .fractionalHeight(0.33)
   )

   let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
   layoutItem.contentInsets = NSDirectionalEdgeInsets(
      top: 0, 
      leading: 5, 
      bottom: 0, 
      trailing: 5
   )

   let layoutGroupSize = NSCollectionLayoutSize(
       widthDimension: .fractionalWidth(0.93),
       heightDimension: .fractionalWidth(0.55)
   )
   let layoutGroup = NSCollectionLayoutGroup.vertical(
      layoutSize: layoutGroupSize, 
      subitems: [layoutItem]
   )

   let layoutSection = NSCollectionLayoutSection(group: layoutGroup)
   layoutSection.orthogonalScrollingBehavior = .groupPagingCentered

   return layoutSection
}

Finally, you just need to set your layout:

collectionView.collectionViewLayout = createCompositionalLayout()

One cool thing that came with UICollectionViewCompositionalLayout is different page mechanisms, such as groupPagingCentered, but I think this answer already is long enough to explain the difference between them

Tiago Almeida
  • 14,081
  • 3
  • 67
  • 82
3

A UICollectionView (.scrollDirection = .horizontal) can be used as an outer container for containing each list in its individual UICollectionViewCell.

Each list in turn cab be built using separate UICollectionView(.scrollDirection = .vertical).

Enable paging on the outer UICollectionView using collectionView.isPagingEnabled = true

Its a Boolean value that determines whether paging is enabled for the scroll view. If the value of this property is true, the scroll view stops on multiples of the scroll view’s bounds when the user scrolls. The default value is false.

Note: Reset left and right content insets to remove the extra spacing on the sides of each page. e.g. collectionView?.contentInset = UIEdgeInsetsMake(0, 0, 0, 0)

RGhate
  • 63
  • 1
  • 6
  • 2
    If you notice in the App Store screenshot, the previous and next cells can be seen at the edges. Unfortunately, the native paging only works properly if the cell is 100% width so the user wouldn’t be able to get a peek at the previous/next cells. – TruMan1 Jun 25 '18 at 12:15