Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to correctly invalidate layout for supplementary views in 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

like image 829
Adam Avatar asked Aug 22 '18 13:08

Adam


2 Answers

You should use an Invalidation Context. It's a bit complex, but here's a rundown:

First, you need to create a custom subclass of UICollectionViewLayoutInvalidationContext since the default one used by most collection views will just refresh everything. There may be situations where you DO want to refresh everything though; in my instance, if the width of the collection view changes it has to layout all the cells again, so my solution looks like this:

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

Now you need to tell the layout to use this context instead of the default:

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

This won't trigger if we don't tell the layout it needs to update upon scrolling, so:

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

I'm passing true here because there will always be something to update when the user scrolls the collection view, even if it's only the header frames. We'll determine exactly what gets changed when in the next section.

Now that it is always updating when the bounds change, we need to provide it with information about which parts should be invalidated and which should not. To make this easier, I have a function called getVisibleSections(in: CGRect) that returns an optional array of integers representing which sections overlap the given bounds rectangle. I won't detail this here as yours will be different. I'm also caching the content size of the collection view as _contentSize since this only changes when a full layout occurs.

With a small number of sections you could probably just invalidate all of them. Be that as it may, we now need to tell the layout how to set up its invalidation context when the bounds changes.

Note: make sure you're calling super to get the context rather than just creating one yourself; this is the proper way to do things.

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
}

Note that I'm assuming your supplementary view kind is "Header"; change that if you need to. Now, provided that you've properly implemented layoutAttributesForSupplementaryView to return a suitable frame, your headers (and only your headers) should update as you scroll vertically.

Keep in mind that prepare() will NOT be called unless you do a full invalidation, so if you need to do any recalculations, override invalidateLayout(with:) as well, calling super at some point. Personally I do the calculations for shifting the header frames in layoutAttributesForSupplementaryView as it's simpler and just as performant.

Oh, and one last small tip: on the layout attributes for your headers, don't forget to set zIndex to a higher value than the one in your cells so that they definitely appear in front. The default is 0, I use 1 for my headers.

like image 59
Ash Avatar answered Nov 15 '22 03:11

Ash


What I suggest is to create a separate subclass of a UICollectionFlowView

and set it up respectivel look at this example:

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
    }

}

then add your collection view to your view controller and this way you will implement the invalidation methods which currently are not getting triggered.

source here

like image 21
AD Progress Avatar answered Nov 15 '22 03:11

AD Progress