【问题标题】:How to correctly invalidate layout for supplementary views in UICollectionView如何正确地使 UICollectionView 中补充视图的布局无效
【发布时间】:2018-08-22 13:58:15
【问题描述】:

我的数据集显示在 UICollectionView 中。数据集分为多个部分,每个部分都有一个标题。此外,每个单元格下方都有一个详细视图,单击该单元格时会展开该视图。

供参考:

为简单起见,我将详细信息单元格实现为默认隐藏的标准单元格(高度:0),当单击非详细信息单元格时,高度设置为非零值。这些单元格是使用invalidateItems(at indexPaths: [IndexPath]) 更新的,而不是在performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil) 中重新加载单元格,否则动画看起来会出现故障。

现在问题来了,invalidateItems 函数显然只更新单元格,而不是像节标题这样的补充视图,因此仅调用此函数将导致节标题溢出:

在谷歌上搜索了一段时间后,我发现要更新补充视图,必须致电invalidateSupplementaryElements(ofKind elementKind: String, at indexPaths: [IndexPath])。这可能会正确重新计算节标题的边界,但会导致内容不显示:

这很可能是因为func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView 似乎没有被调用。

如果有人能告诉我如何正确地使上述问题的补充意见无效,我将不胜感激。

代码

   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()
        }
    }

编辑: 演示此问题的简约项目:https://github.com/vongrad/so-expandable-collectionview

【问题讨论】:

  • 直接简述你在哪里卡住了。以及你尝试了什么
  • 为什么不使用collectionView.reloadSections(:)?
  • 我开始重新加载单个项目以及重新加载整个部分(如 @sj-r 所建议的那样),但这导致动画远非流畅。因此,我转到了失效上下文,并且几乎尝试了我的问题中提到的事情。
  • 尝试调用[supplementaryView setNeedsDisplay]。
  • 我已经试过了,但是没用

标签: ios swift uicollectionview


【解决方案1】:

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

首先,您需要创建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 作为我的标题。

【讨论】:

    【解决方案2】:

    我的建议是创建一个 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

    【讨论】:

    • 我过去已经尝试过 layoutIfNeeded,但没有成功。重新加载整个 collectionView 也不是解决方案,因为它效率低下并导致屏幕闪烁:/
    • 一旦您使补充视图无效,它们将不会得到更新,请尝试我在苹果文档中阅读的整个解决方案
    • 另外,invalidateSupplementaryViews 已正确注册
    • 在 func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) 中尝试打印语句 -> UICollectionReusableView 看它是否被调用
    • 如果它没有给你任何提示,那么你可以在 gitHub 或其他地方分享代码,如果你愿意,我可以尝试贡献
    【解决方案3】:

    performBatchUpdates(_:) 中重新加载Load cell 让它看起来有问题。

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

    collectionView.performBatchUpdates(nil, completion: nil)
    

    编辑:

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

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

    【讨论】:

      猜你喜欢
      • 2016-07-23
      • 1970-01-01
      • 2019-11-21
      • 1970-01-01
      • 2017-07-24
      • 1970-01-01
      • 1970-01-01
      • 2013-02-20
      • 2015-12-22
      相关资源
      最近更新 更多