How to correctly invalidate layout for supplementary views in UICollectionView

时间:2018-08-22 13:58:15

标签: ios swift uicollectionview

I am having a dataset displayed in a UICollectionView. The dataset is split into sections and each section has a header. Further, each cell has a detail view underneath it that is expanded when the cell is clicked.

For reference:

enter image description here

For simplicity, I have implemented the details cells as standard cells that are hidden (height: 0) by default and when the non-detail cell is clicked, the height is set to non-zero value. The cells are updates using invalidateItems(at indexPaths: [IndexPath]) instead of reloading cells in performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil) as the animations seems glitchy otherwise.

Now to the problem, the invalidateItems function obviously updates only cells, not supplementary views like the section header and therefore calling only this function will result in overflowing the section header:

enter image description here

After some time Googling, I found out that in order to update also the supplementary views, one has to call invalidateSupplementaryElements(ofKind elementKind: String, at indexPaths: [IndexPath]). This might recalculate the section header's bounds correctly, however results in the content not appearing:

enter image description here

This is most likely caused due to the fact that the func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView does not seem to be called.

I would be extremely grateful if somebody could tell me how to correctly invalidate supplementary views to the issues above do not happen.

Code:

   override func numberOfSections(in collectionView: UICollectionView) -> Int {
        return dataManager.getSectionCount()
    }

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        let count = dataManager.getSectionItemCount(section: section)
        reminder = count % itemsPerWidth
        return count * 2
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

        if isDetailCell(indexPath: indexPath) {
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Reusable.CELL_SERVICE, for: indexPath) as! ServiceCollectionViewCell
            cell.lblName.text = "Americano detail"

            cell.layer.borderWidth = 0.5
            cell.layer.borderColor = UIColor(hexString: "#999999").cgColor
            return cell

        } else {
            let item = indexPath.item > itemsPerWidth ? indexPath.item - (((indexPath.item / itemsPerWidth) / 2) * itemsPerWidth) : indexPath.item
            let product = dataManager.getItem(index: item, section: indexPath.section)

            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Reusable.CELL_SERVICE, for: indexPath) as! ServiceCollectionViewCell
            cell.lblName.text = product.name

            cell.layer.borderWidth = 0.5
            cell.layer.borderColor = UIColor(hexString: "#999999").cgColor

            return cell
        }
    }

    override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        switch kind {
        case UICollectionElementKindSectionHeader:
            if indexPath.section == 0 {
                let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: Reusable.CELL_SERVICE_HEADER_ROOT, for: indexPath) as! ServiceCollectionViewHeaderRoot
                header.lblCategoryName.text = "Section Header"
                header.imgCategoryBackground.af_imageDownloader = imageDownloader
                header.imgCategoryBackground.af_setImage(withURLRequest: ImageHelper.getURL(file: category.backgroundFile!))
                return header
            } else {
                let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionElementKindSectionHeader, withReuseIdentifier: Reusable.CELL_SERVICE_HEADER, for: indexPath) as! ServiceCollectionViewHeader
                header.lblCategoryName.text = "Section Header"
                return header
            }
        default:
            assert(false, "Unexpected element kind")
        }
    }

    // MARK: UICollectionViewDelegate

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let width = collectionView.frame.size.width / CGFloat(itemsPerWidth)

        if isDetailCell(indexPath: indexPath) {
            if expandedCell == indexPath {
                return CGSize(width: collectionView.frame.size.width, height: width)
            } else {
                return CGSize(width: collectionView.frame.size.width, height: 0)
            }
        } else {
            return CGSize(width: width, height: width)
        }
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
        if section == 0 {
            return CGSize(width: collectionView.frame.width, height: collectionView.frame.height / 3)
        } else {
            return CGSize(width: collectionView.frame.width, height: heightHeader)
        }
    }

    override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        if isDetailCell(indexPath: indexPath) {
            return
        }

        var offset = itemsPerWidth
        if isLastRow(indexPath: indexPath) {
            offset = reminder
        }

        let detailPath = IndexPath(item: indexPath.item + offset, section: indexPath.section)
        let context = UICollectionViewFlowLayoutInvalidationContext()

        let maxItem = collectionView.numberOfItems(inSection: 0) - 1
        var minItem = detailPath.item
        if let expandedCell = expandedCell {
            minItem = min(minItem, expandedCell.item)
        }

        // TODO: optimize this
        var cellIndexPaths = (0 ... maxItem).map { IndexPath(item: $0, section: 0) }

        var supplementaryIndexPaths = (0..<collectionView.numberOfSections).map { IndexPath(item: 0, section: $0)}

        for i in indexPath.section..<collectionView.numberOfSections {
            cellIndexPaths.append(contentsOf: (0 ... collectionView.numberOfItems(inSection: i) - 1).map { IndexPath(item: $0, section: i) })
            //supplementaryIndexPaths.append(IndexPath(item: 0, section: i))
        }

        context.invalidateSupplementaryElements(ofKind: UICollectionElementKindSectionHeader, at: supplementaryIndexPaths)
        context.invalidateItems(at: cellIndexPaths)

        if detailPath == expandedCell {
            expandedCell = nil
        } else {
            expandedCell = detailPath
        }

        UIView.animate(withDuration: 0.25) {
            collectionView.collectionViewLayout.invalidateLayout(with: context)
            collectionView.layoutIfNeeded()
        }
    }

EDIT: Minimalistic project demonstrating this issue: https://github.com/vongrad/so-expandable-collectionview

3 个答案:

答案 0 :(得分:2)

您应该使用失效上下文。这有点复杂,但这里有一个纲要:

首先,您需要创建 UICollectionViewLayoutInvalidationContext 的自定义子类,因为大多数集合视图使用的默认子类只会刷新所有内容。不过,在某些情况下,您确实希望刷新所有内容;在我的例子中,如果集合视图的宽度发生变化,它必须再次布局所有单元格,所以我的解决方案如下所示:

class CustomInvalidationContext: UICollectionViewLayoutInvalidationContext {
    var justHeaders: Bool = false
    override var invalidateEverything: Bool { return !justHeaders }
    override var invalidateDataSourceCounts: Bool { return false }
}

现在你需要告诉布局使用这个上下文而不是默认值:

override class var invalidationContextClass: AnyClass {
    return CustomInvalidationContext.self
}

如果我们不告诉布局它需要在滚动时更新,则不会触发,因此:

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

我在这里传递 true 是因为当用户滚动集合视图时总会有一些东西要更新,即使它只是标题帧。在下一节中,我们将确定具体的更改内容。

既然它总是在边界改变时更新,我们需要向它提供关于哪些部分应该失效哪些不应该失效的信息。为了使这更容易,我有一个名为 getVisibleSections(in: CGRect) 的函数,它返回一个可选的整数数组,表示哪些部分与给定的边界矩形重叠。我不会在这里详细说明这一点,因为您的情况会有所不同。我还将集合视图的内容大小缓存为 _contentSize,因为这仅在发生完整布局时才会更改。

使用少量部分,您可能会使所有部分无效。尽管如此,我们现在需要告诉布局如何在边界发生变化时设置其失效上下文。

注意:确保您调用 super 来获取上下文,而不是自己创建上下文;这是做事的正确方式。

override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
    let context = super.invalidationContext(forBoundsChange: newBounds) as! CustomInvalidationContext
    
    // If we can't determine visible sections or the width has changed,
    // we need to do a full layout - just return the default.
    guard newBounds.width == _contentSize.width,
        let visibleSections = getVisibleSections(in: newBounds)
    else { return context }
    
    // Determine which headers need a frame change.
    context.justHeaders = true
    let sectionIndices = visibleSections.map { IndexPath(item: 0, section: $0) }
    context.invalidateSupplementaryElements(ofKind: "Header", at: sectionIndices)
    return context
}

请注意,我假设您的补充视图类型是“标题”;如果需要,请更改它。现在,假设您已正确实施 layoutAttributesForSupplementaryView 以返回合适的框架,您的标题(并且只有您的标题)应该在您垂直滚动时更新。

请记住,除非您进行完全失效,否则不会调用 prepare(),因此如果您需要进行任何重新计算,也可以覆盖 invalidateLayout(with:),在某个时候调用 super。我个人会计算在 layoutAttributesForSupplementaryView 中移动标题帧,因为它更简单,性能也一样。

哦,还有最后一个小提示:关于标题的布局属性,不要忘记将 zIndex 设置为比单元格中更高的值,这样它们肯定会出现在前面。默认值为 0,我的标题使用 1。

答案 1 :(得分:1)

不要让 performBatchUpdates(_:) 中的 reloadLoad 单元看起来有问题。

只需像下面一样传递 nil 即可更新单元格的高度。

collectionView.performBatchUpdates(nil, completion: nil)

编辑:

我最近发现 performBatchUpdates(_:) 只移动标题以及从 sizeForItemAt 函数返回的单元格新高度。如果使用集合视图单元格大小调整,您的补充视图可能会与单元格重叠。然后 collectionViewLayout.invalidateLayout 将修复而不显示动画。

如果您想在调用 performBatchUpdates(_:) 后使用缩放动画,请尝试在 sizeForItemAt 中计算(然后缓存)并返回单元格的大小。它对我有用。

答案 2 :(得分:0)

我建议创建一个UICollectionFlowView的单独子类

并分别设置它,请看以下示例:

import UIKit

class StickyHeadersCollectionViewFlowLayout: UICollectionViewFlowLayout {

    // MARK: - Collection View Flow Layout Methods

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

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let layoutAttributes = super.layoutAttributesForElements(in: rect) else { return nil }

        // Helpers
        let sectionsToAdd = NSMutableIndexSet()
        var newLayoutAttributes = [UICollectionViewLayoutAttributes]()

        for layoutAttributesSet in layoutAttributes {
            if layoutAttributesSet.representedElementCategory == .cell {
                // Add Layout Attributes
                newLayoutAttributes.append(layoutAttributesSet)

                // Update Sections to Add
                sectionsToAdd.add(layoutAttributesSet.indexPath.section)

            } else if layoutAttributesSet.representedElementCategory == .supplementaryView {
                // Update Sections to Add
                sectionsToAdd.add(layoutAttributesSet.indexPath.section)
            }
        }

        for section in sectionsToAdd {
            let indexPath = IndexPath(item: 0, section: section)

            if let sectionAttributes = self.layoutAttributesForSupplementaryView(ofKind: UICollectionElementKindSectionHeader, at: indexPath) {
                newLayoutAttributes.append(sectionAttributes)
            }
        }

        return newLayoutAttributes
    }

    override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        guard let layoutAttributes = super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath) else { return nil }
        guard let boundaries = boundaries(forSection: indexPath.section) else { return layoutAttributes }
        guard let collectionView = collectionView else { return layoutAttributes }

        // Helpers
        let contentOffsetY = collectionView.contentOffset.y
        var frameForSupplementaryView = layoutAttributes.frame

        let minimum = boundaries.minimum - frameForSupplementaryView.height
        let maximum = boundaries.maximum - frameForSupplementaryView.height

        if contentOffsetY < minimum {
            frameForSupplementaryView.origin.y = minimum
        } else if contentOffsetY > maximum {
            frameForSupplementaryView.origin.y = maximum
        } else {
            frameForSupplementaryView.origin.y = contentOffsetY
        }

        layoutAttributes.frame = frameForSupplementaryView

        return layoutAttributes
    }

    // MARK: - Helper Methods

    func boundaries(forSection section: Int) -> (minimum: CGFloat, maximum: CGFloat)? {
        // Helpers
        var result = (minimum: CGFloat(0.0), maximum: CGFloat(0.0))

        // Exit Early
        guard let collectionView = collectionView else { return result }

        // Fetch Number of Items for Section
        let numberOfItems = collectionView.numberOfItems(inSection: section)

        // Exit Early
        guard numberOfItems > 0 else { return result }

        if let firstItem = layoutAttributesForItem(at: IndexPath(item: 0, section: section)),
           let lastItem = layoutAttributesForItem(at: IndexPath(item: (numberOfItems - 1), section: section)) {
            result.minimum = firstItem.frame.minY
            result.maximum = lastItem.frame.maxY

            // Take Header Size Into Account
            result.minimum -= headerReferenceSize.height
            result.maximum -= headerReferenceSize.height

            // Take Section Inset Into Account
            result.minimum -= sectionInset.top
            result.maximum += (sectionInset.top + sectionInset.bottom)
        }

        return result
    }

}

然后将集合视图添加到视图控制器,这样您将实现当前未触发的失效方法。

来源here