如何将水平分页捕捉到 App Store 等多行集合视图?

Posted

技术标签:

【中文标题】如何将水平分页捕捉到 App Store 等多行集合视图?【英文标题】:How to snap horizontal paging to multi-row collection view like App Store? 【发布时间】:2018-06-24 22:30:18 【问题描述】:

我想在多行 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 行的集合。有人可以帮助使快照分页适用于分组行而不是每页单个项目吗?

【问题讨论】:

在您的单元格中使用表格视图,以便您在视图中只有一个集合视图单元格(以及一点下一个/上一个单元格) 集合视图单元格中的表格视图似乎有点矫枉过正,尤其是当带有水平滚动的垂直布局可以有效地进行布局时。 这取决于你,但如果你使用 tableview,你可能已经完成了,你不需要所有复杂的代码。分页/捕捉将由集合视图为您处理,三行的布局将由表视图处理。 您可以使用UIPageViewController 或使用UIScrollViewDelegatescrollViewWillEndDragging 方法在结束滚动时定位 @Paulw11 即使有一个表格视图,它也不会那么简单,因为不幸的是,本机寻呼机仅在集合视图单元格为全宽时才有效,但 App Store 示例在前面和下一个单元格,所以我认为仍然需要复杂的分页逻辑。 【参考方案1】:

没有理由仅仅为了这种行为而对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($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 

结果:

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

【讨论】:

它对我有用,而且效果很好,谢谢@rob!! @Rob 。感谢您分享您的代码!我只有两个单元格要显示。第一个单元格从左侧偏移 20 个点。第二个单元格从右侧偏移 20 点。我有 collectionView.contentInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) 。当应用程序启动时,会出现左侧偏移。当我开始滚动时,我失去了偏移量,因为第一个单元格出现在左侧,第二个单元格出现在右侧。如何保持偏移量? 您需要发布自己的***问题并提供更多信息。 OP 说他想显示上一个单元格和下一个单元格【参考方案2】:

ios 13 中,这变得容易多了!

在 iOS 13 中,您可以使用 UICollectionViewCompositionalLayout

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

概念

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

项目 (NSCollectionLayoutItem)

您的单元格大小。小数大小与项目所在的组相关,它们可以考虑父项的宽度或高度。

群组 (NSCollectionLayoutGroup)

组允许您创建一组项目。在该应用商店示例中,组是一列并且有 3 个项目,因此您的项目应该从组中占据 0.33 的高度。然后,例如,您可以说该组需要 300 高度。

部分(NSCollectionLayoutSection)

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

创建布局

您使用接收节索引和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,作者是 Paul Hudson,来自 Hacking with Swift 对此进行了解释。他还有一个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,但我认为这个答案已经足够长,可以解释它们之间的区别?

【讨论】:

【参考方案3】:

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

每个列表依次使用单独的 UICollectionView(.scrollDirection = .vertical) 构建。

使用 collectionView.isPagingEnabled = true

在外部 UICollectionView 上启用分页

它是一个布尔值,用于确定是否为滚动视图启用分页。 如果此属性的值为 true,则当用户滚动时,滚动视图会在滚动视图边界的倍数处停止。默认值为 false。

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

【讨论】:

如果您在 App Store 屏幕截图中注意到,可以在边缘看到上一个和下一个单元格。不幸的是,只有当单元格为 100% 宽度时,本机分页才能正常工作,因此用户无法查看上一个/下一个单元格。

以上是关于如何将水平分页捕捉到 App Store 等多行集合视图?的主要内容,如果未能解决你的问题,请参考以下文章

UICollectionView 水平分页与页面之间的空间

UICollectionView 水平分页 3 项

UICompositionalLayout 未按预期工作(垂直表格滚动 + 水平分页)

UIScrollView :仅向左水平分页,向右弹跳

具有水平分页的 UIScrollView 不居中子视图

在 Test Flight 测试后提交应用到 App Store