如何将水平分页捕捉到像 App Store 这样的多行集合视图?

How to snap horizontal paging to multi-row collection view like App Store?

我想在多行 App Store 集合视图中复制分页:

到目前为止,我已经将它设计得尽可能接近它的外观,包括显示对上一个和下一个单元格的窥视,但不知道如何使分页工作以便它捕捉下一组共 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
        )
    }
}

不幸的是,UICollectionView 上的原生 isPagingEnabled 标志仅在单元格为集合视图的 100% 宽度时才有效,因此用户无法查看上一个和下一个单元格.

我有一个有效的 snap paging functionality,但每页仅针对一个项目,而不是这种 3 行类型的集合。有人可以帮助使快照分页适用于分组行而不是每页单个项目吗?

UICollectionView (.scrollDirection = .horizo​​ntal) 可以用作外部容器,用于将每个列表包含在其单独的 UICollectionViewCell 中。

每个列表依次可以使用单独的 UICollectionView(.scrollDirection = .vertical).

使用 collectionView.isPagingEnabled = true

在外部 UICollectionView 上启用分页

布尔值,判断滚动视图是否开启分页。 如果此 属性 的值为真,则当用户滚动时,滚动视图将停止在滚动视图边界的倍数处。默认值为 false。

注意:重置左右内容插图以删除每页两侧的额外间距。 例如collectionView?.contentInset = UIEdgeInsetsMake(0, 0, 0, 0)

没有理由仅仅为了这种行为而对 UICollectionViewFlowLayout 进行子类化。

UICollectionViewUIScrollView的子类,所以它的委托协议UICollectionViewDelegateUIScrollViewDelegate的子类。这意味着您可以在集合视图的委托中实现 UIScrollViewDelegate 的任何方法。

在您的集合视图的委托中,实现 scrollViewWillEndDragging(_:withVelocity:targetContentOffset:) 将目标内容偏移舍入到最近的单元格列的左上角。

这是一个示例实现:

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([=10=].center.x - xCenter) < abs(.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({ [=10=].frame.origin.x > xCurrent}).min(by: { [=10=].center.x < .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: { [=10=].center.x < .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 }

结果:

您可以在此处找到我的测试项目的完整源代码:https://github.com/mayoff/multiRowSnapper

使用 iOS 13 这变得容易多了!

在iOS13中你可以使用UICollectionViewCompositionalLayout.

这介绍了几个概念,我将尝试在这里给出一个要点,但我认为理解这一点很有价值!

概念

在 CompositionalLayout 中,您有 3 个允许您指定大小的实体。您可以使用绝对值、分数值(例如一半)或估计值来指定大小。这 3 个实体是:

  • 项目 (NSCollectionLayoutItem)

您的单元格大小。小数尺寸是相对于项目所在的组的,它们可以考虑父级的宽度或高度。

  • 组 (NSCollectionLayoutGroup)

群组允许您创建一组项目。在那个应用商店示例中,一个组是一列并且有 3 个项目,因此您的项目应该从组中获得 0.33 高度。那么,你可以说这个组占了300的高度,比如

  • 小节(NSCollectionLayoutSection)

部分声明小组将如何自我重复。在这种情况下,您可以说该部分是水平的。

正在创建布局

您使用接收部分索引和 NSCollectionLayoutEnvironment 的闭包创建布局。这很有用,因为每个特征可以有不同的布局(例如,在 iPad 上你可以有不同的东西)和每个部分索引(即,你可以有一个水平滚动的部分和另一个只是垂直布局的部分).

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

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

   layout.configuration = config
   return layout
}

应用商店示例

对于应用商店,您有一个 really good video by Paul Hudson, from the Hacking with Swift explaining this. He also has a repo

但是,我会把代码放在这里,这样就不会丢失:

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
}

最后,你只需要设置你的布局:

collectionView.collectionViewLayout = createCompositionalLayout()

UICollectionViewCompositionalLayout 附带的一件很酷的事情是不同的页面机制,例如 groupPagingCentered,但我认为这个答案已经足够长,足以解释它们之间的区别