Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

UICollectionView remove section breaks with UICollectionViewFlowLayout

I have a dataset that is divided into multiple sections, however, I'd like to display this in a collectionView without breaks between sections. Here's an illustration of what I want to achieve:

Instead of:
0-0 0-1 0-2
0-3
1-0 1-1
2-0
3-0

I want:
0-0 0-1 0-2
0-3 1-0 1-1
2-0 3-0

I realize the solution likely lies with a custom UICollectionViewLayout subclass, but I'm not sure how to achieve something like this.

Thanks

like image 985
Jimmy Xu Avatar asked Apr 27 '16 02:04

Jimmy Xu


2 Answers

You are correct that you need to subclass UICollectionViewLayout. The essence to understand before starting is that you need to calculate at least position and size for every cell in the collection view. UICollectionViewLayout is just a structured way to provide that information. You get the structure, but you have to provide everything else yourself.

There are 4 methods you need to override:

  • prepare
  • invalidateLayout
  • layoutAttributesForItemAtIndexPath
  • layoutAttributesForElementsInRect

One trick is to cache the layout attributes in a lookup table (dictionary):

var cachedItemAttributes = [IndexPath: UICollectionViewLayoutAttributes]()

In prepare, you calculate the layout attributes for each indexPath in your collectionView:

override func prepare() {
    super.prepare()
    calculateAttributes()
}

In invalidateLayout you reset the cached layout attributes and recalculate them:

override func invalidateLayout() {
    super.invalidateLayout()
    cachedItemAttributes = [:]
    calculateAttributes()
} 

In layoutAttributesForItemAtIndexPath you use the lookup table to return the right layout attributes:

override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
    return cachedItemAttributes[indexPath]
}

In layoutAttributesForElementsInRect you filter your lookup table for the elements within the specified rect:

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    return cachedItemAttributes.filter { rect.intersects($0.value.frame) }.map { $0.value }
}

The final piece of the puzzle is the actual calculation of the layout attributes. Here I will provide only pseudo-code:

func calculateAttributes() {
    // For each indexpath (you can get this from the collectionView property using numberOfSections and numberOfItems:inSection )
    // calculate the frame, i.e the origin point and size of each cell in your collectionView and set it with UICollectionViewLayoutAttributes.frame
    // There are many other properties on UICollectionViewLayoutAttributes that you can tweak for your layout, but frame is a good starting point from which you can start experimenting.
    // Add the layout attributes to the lookup table
    // end loop
}

To answer your question, here is pseudo-code to calculate the position of each cell:

// If width of cell + current width of row + spacing, insets and margins exceeds the available width
// move to next row.
// else
// cell origin.x = current width of row + interitem spacing
// cell origin.y = number of rows * (row height + spacing)
// endif

If you need your custom layout to be configurable, then either use UICollectionViewDelegateFlowLayout if the available signatures are sufficient, or define your own that inherits from UICollectionViewDelegateFlowLayout or UICollectionViewDelegate. Because your protocol inherits from UICollectionViewDelegateFlowLayout, which itself inherits from UICollectionViewDelegate, you can set it directly as the collectionView delegate in your viewcontroller. In your custom collection view layout you just need to cast the delegate from UICollectionViewDelegate to your custom protocol to use it. Remember to handle cases where the casting fails or where the protocol methods are not implemented by the delegate.

like image 137
Marmoy Avatar answered Oct 18 '22 14:10

Marmoy


I found that for me, Marmoy's answer is missing one additional element:

overriding collectionViewContentSize.

Otherwise, depending on the size of your collectionView, you may get a call to layoutAttributesForElements(in rect: CGRect) which has a zero width or height, which will miss many of the cells. This is especially true if you're trying to dynamically size items in the collection view.

So a more complete version of Marmoy's answer would be:

import UIKit

class NoBreakSectionCollectionViewLayout: UICollectionViewLayout {

    var cachedItemAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
    var cachedContentSize = CGSize.zero

    override func prepare() {
        super.prepare()
        calculateAttributes()
    }

    override func invalidateLayout() {
        super.invalidateLayout()
        cachedItemAttributes = [:]
        calculateAttributes()
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return cachedItemAttributes[indexPath]
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return cachedItemAttributes.filter { rect.intersects($0.value.frame) }.map { $0.value }
    }

    override var collectionViewContentSize: CGSize {
        return cachedContentSize
    }

    func calculateAttributes() {
        var y = CGFloat(0)
        var x = CGFloat(0)
        var lastHeight = CGFloat(0)
        let xSpacing = CGFloat(5)
        let ySpacing = CGFloat(2)
        if let collectionView = collectionView, let datasource = collectionView.dataSource, let sizeDelegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout {
            let sections = datasource.numberOfSections?(in: collectionView) ?? 1
            for section in 0..<sections {
                for item in 0..<datasource.collectionView(collectionView, numberOfItemsInSection: section){
                    let indexPath = IndexPath(item: item, section: section)
                    let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
                    if let size = sizeDelegate.collectionView?(collectionView, layout: self, sizeForItemAt: indexPath) {
                        if x > 0 && (x + size.width + xSpacing) > collectionView.bounds.width {
                            y += size.height + ySpacing
                            x = CGFloat(0)
                        }
                        attributes.frame = CGRect(x: x, y: y, width: size.width, height: size.height)
                        lastHeight = size.height
                        x += size.width + xSpacing
                    }
                    cachedItemAttributes[indexPath] = attributes
                }
            }
            cachedContentSize = CGSize(width: collectionView.bounds.width, height: y + lastHeight)
        }

    }
}

Additionally, it's important for your delegate to implement UICollectionViewDelegateFlowLayout in the example above... Alternately, you can just calculate item sizes in the Layout if you know them without knowing about the cell content.

like image 3
Bingosabi Avatar answered Oct 18 '22 14:10

Bingosabi