swiftuicollectionviewuicollectionviewcompositionallayout

How can I set different backgrounds for different sections using compositional layout?


I would like to have different backgrounds for different sections in a collection view using compositional layout.

Currently I am using NSCollectionLayoutDecorationItem.background to set the background on a section. However this type of view is not dequeued or recycled the same way that a supplementary view or a cell would be. I can't make changes to it after it's initialized and I don't get to initialize it myself.

I would like to use a colour or background image that is part of the user's data that I retrieve from my back end, so the colour or image is not known to me at compile time.

Is there any way to dynamically set the background image or colour of a section?


Solution

  • After much struggle, I figured out a way to do this. By itself, it's not too hacky, but requires additional hacks to work around what appears to be a UIKit bug.

    Caveats

    1. This only works if the height of both your section content AND section headers are absolute and known ahead of time (at least in the section layout provider).

      This worked in my case because both my headers and cells are fixed height, and I was only using one row of horizontally-scrolling cells per section.

    2. The background view must not scroll horizontally (assuming a vertically-scrolling collection view.

      In my case I wanted the section background images to be fixed, with the cells scrolling horizontally (orthogonally) over top of them.

    Constraints/Assumptions

    1. We can only use compositional layout (in my case to get easy orthogonal scrolling).

    2. The facility for setting a background view (mentioned in the question) won't work because it cannot be configured per-section via a supplementary view registration or any other means. So we must use a regular supplementary item.

    3. The supplementary item must be on the section to get the right size, which means only using NSCollectionLayoutBoundarySupplementaryItem.

      3.1. While it is possible to trick a different type (e.g. a non-boundary supplementary item on the group) into being the right size and position, using tricks results in UIKit not knowing when the background is actually visible, which results in flickering in/out during scrolling.

    4. Despite being attached to the section, a section supplementary item will be positioned vertically relative to the cells only, ignoring the header part of the section.

    5. Worse, this alignment combined with setting the height equal to total section height results in the supplementary item stretching the height of the section below the cells, making the section too tall.

    6. There is a facility for setting a position offset for the supplementary item, however due to what I can only assume is a bug (even with an offset of 0), using it causes the section header height to collapse to zero, resulting in the cells below moving up and and underlapping it.

    Solution

    So, to work around all of that, what I did is:

    1. Set the background supplementary item size to be the known height of header + cells/row + any spacing between them.
    2. Pull the background item up under the header by offsetting vertically by the header height (using an offset causes the cells to move up; see constraint 6).
    3. Push the cells back down by padding the section insets top by the height of the header (hackInsets).
    4. Pull the background's leading/trailing edges out to the view/screen edges, to make it full-bleed (normally it would be inset like the content). We do this by setting negative margins on the background image view in the background supplementary view registration.
    5. Set a negative z-index on the background view to keep it under the cells and header.

    In code, the relevant parts look like this:

    let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(Self.headerHeight))
    let headerSupplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: ElementKind.header, alignment: .top)
    
    let hackInsets = NSDirectionalEdgeInsets(top: Self.headerHeight, leading: 0, bottom: 0, trailing: 0)
    let backgroundHeight = MySectionHeaderView.nominalSize.height + Self.interItemSpacing + MyCollectionViewCell.nominalSize.height
    let backgroundOffset = CGPoint(x: 0, y: -MySectionHeaderView.nominalSize.height)
    let backgroundAnchor = NSCollectionLayoutAnchor(edges: .top, absoluteOffset: backgroundOffset)
    let backgroundSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(backgroundHeight))
    let backgroundSupplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: backgroundSize, elementKind: ElementKind.background, containerAnchor: backgroundAnchor)
    backgroundSupplementaryItem.zIndex = -1
    
    let section = NSCollectionLayoutSection(group: group)
    section.orthogonalScrollingBehavior = .continuous
    section.contentInsets = Self.sectionInsets.summedWith(hackInsets)
    section.boundarySupplementaryItems = [headerSupplementaryItem, backgroundSupplementaryItem]
    

    iOS 17 Update

    One aspect of the above technique seems to have stopped working on iOS 17 or later. UIKit seems to have changed the order that it adds supplementary views and cells to the view hierarchy, leading to the background image being on top of (and obscuring) the cells/chapters.

    You'd think setting the zIndex (as we do in step 5 above) would compensate for that, however there doesn't seem to be any value that causes the cells to appear on top of the background supplementary view under iOS 17.

    I found a reference to this property not working that predates iOS 17 but appears to be what's happening now also. We can resolve it by setting zPosition on the CALayer of the background view as described in that answer.

    This fixes the visual appearance (cells now appear on top of the background again), but the background view is still in front in terms of tapping/interaction, so we also need to disable user interaction on the supplementary view.

    Together those two steps appear to make this a workable solution once again.