Custom Collection View Layouts(三)

Sticky Headers

本节我们用 CollectionView Layout 来实现一个类似于 tableView 向上滚动时的 Sticky Headers 效果,该效果展示如下:Section Title 在当前 section 的 cells 未完全滚出界面时一直保持固定在页面顶部,直到下一个 section Title 将其顶出取代他的位置,依次反复

为了实现这种效果,我们需要遵守三个规则:

  • section 中第一个 cell 到屏幕顶部的距离为一个 header height 时,header 就不能再向上滑动了(被固定在屏幕顶部)
  • 向下滑动时,section header 一旦和下面的 section 底部距离超过一个 header height 就不能再向下了(同样被固定在屏幕顶部)
  • 在遵循前两个规则的基础上,其余的时间我们使用 contentOffset

实现这三个规则其实相当简单粗暴,在此之前,我们先来关注一下 Missing Headers(消失的 Headers),我们在 layoutAttributesForElementsInRect(_:) 方法中一般是先调用 super 方法返回一个包含 UICollectionViewLayoutAttributes 的数组,但这里要注意的是,只有和该方法传入的参数 rect 相交的部分,这部分元素的 layoutAttributes 才会被返回(下图中橙色线框区域)

此时会存在一个问题,当一个 Header 向上滑出屏幕后,调用该方法是不能得到屏幕外元素的 layoutAttributes,因此我们一开始就要手动标识这些需要的 header 元素;这一步是必须的,因为我们需要在 scroll collection view 时,实时调整 header 的位置,使其保持在屏幕顶端(正常情况下,早就滑出到屏幕外)

下面我们来看一下具体实现:

创建一个 UICollectionViewFlowLayout 的子类 StickyHeadersLayout 实现两个方法:

class StickyHeadersLayout: UICollectionViewFlowLayout {

    override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool {
        return true
    }

    override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {
        // 先调用 super,只返回当前可见 elements 的 attributes,包括 cells, supplementary views, 和 decoration views
    var layoutAttributes = super.layoutAttributesForElementsInRect(rect) as! [UICollectionViewLayoutAttributes]

    let headerNeedingLayout = NSMutableIndexSet()
        // TODO
    }
}

在 layoutAttributesForElementsInRect 方法中,我们先调用了 super,他会返回所有可视范围内的元素的 attributes,因为我们感兴趣的是 header,下面就定义一个 NSMutableIndexSet 对象来保持对 header 的索引,这样即使在他滑出屏幕外也能追踪到。

override func layoutAttributesForElementsInRect(rect: CGRect) -> [AnyObject]? {  
    // 先调用 super,只返回当前可见 elements 的 attributes,包括 cells, supplementary views, 和 decoration views
    var layoutAttributes = super.layoutAttributesForElementsInRect(rect) as! [UICollectionViewLayoutAttributes]

    let headerNeedingLayout = NSMutableIndexSet()
    // 找出当前 cell 对应的 section 索引
    for attributes in layoutAttributes {
    if attributes.representedElementCategory == .Cell {
          headerNeedingLayout.addIndex(attributes.indexPath.section)
        }
    }

    // 遍历当前屏幕上显示的所有 header,然后将还显示在屏幕上的 header 对应的索引从 headerNeedingLayout 中移除,这样就只保持了对刚刚移出屏幕 header 的追踪
    for attributes in layoutAttributes {
        if let elementKind = attributes.representedElementKind {
            if elementKind == UICollectionElementKindSectionHeader {
                headerNeedingLayout.removeIndex(attributes.indexPath.section)
            }
        }
    }

    // 将刚移出屏幕的 header(Missing Headers)加入 layoutAttributes
    headerNeedingLayout.enumerateIndexesUsingBlock { index, stop in
        let indexPath = NSIndexPath(forItem: 0, inSection: index)
        let attributes = self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionHeader, atIndexPath: indexPath)
        layoutAttributes.append(attributes)
    }

    // TODO
}

经过上面的操作,layoutAttributes 里保存着当前屏幕上所有元素 + 上一个 section header 的 attributes,接下来我们找出这两个 header 的 attributes(接上 TODO)

for attributes in layoutAttributes {  
    // 找出 header 的 attributes 如果是 Cell 的话 representedElementKind 就为 nil
    if let elementKind = attributes.representedElementKind {
        if elementKind == UICollectionElementKindSectionHeader {
            // 找出 header 当前所在的 section
            let section = attributes.indexPath.section

            // 分别返回当前 section 中第一个 item 和 最后一个 item 所对应的 attributes
            let attributesForFirstItemInSection = layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: 0, inSection: section))
            let attributesForLastItemInSection = layoutAttributesForItemAtIndexPath(NSIndexPath(forItem: collectionView!.numberOfItemsInSection(section) - 1, inSection: section))
            // 得到 header 的 frame
            var frame = attributes.frame
            // 找出当前的滑动距离
            let offset = collectionView!.contentOffset.y

          // 接下来我们来践行一开始提到的三个规则          
            let minY = CGRectGetMinY(attributesForFirstItemInSection.frame) - frame.height
            let maxY = CGRectGetMaxY(attributesForLastItemInSection.frame) - frame.height
            // minY ≤ offset ≤ maxY
            let y = min(max(offset, minY), maxY)
            frame.origin.y = y
            attributes.frame = frame
            attributes.zIndex = 99
        }
    }
}

我们根据当前 section 中的第一个 item 和最后一个 item 得到当前 header origin y 坐标的最大值和最小值,在此范围内,根据 contentOffset 动态调整 header origin.y,保持 header Sticky 在屏幕顶部,但一旦超过 maxY,header origin.y 就不再增大了,也就被移除屏幕

最后 finished demo 地址 请自行 convert to lastest swift syntax


-EOF-

如果感觉此文对你有帮助,请随意打赏支持作者 😘

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!