Layout com UICollectionView CompositionalLayout

5 minswiftuikitlayout

A tela era uma home comum: uma seção “Categorias” rolando na horizontal, embaixo dela um grid de “Destaques” rolando na vertical junto com o resto da lista. Nada muito diferente do que qualquer app de loja mostra na primeira tela.

Tela com a seção Categorias rolando na horizontal e a seção Destaques em grid, na vertical CATEGORIAS DESTAQUES

Categorias corta na borda e continua pra fora da tela. Destaques rola junto com o resto da lista.

A primeira versão era uma UICollectionView dentro de outra

Antes de mexer em CompositionalLayout, a solução que peguei pronta no projeto era uma UICollectionView horizontal vivendo dentro da célula de outra UICollectionView vertical.

final class CategoriesCell: UICollectionViewCell {
    private lazy var innerCollectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
        view.dataSource = self
        return view
    }()

    private var categories: [Category] = []
    private var savedOffset: CGPoint = .zero

    func configure(with categories: [Category]) {
        self.categories = categories
        innerCollectionView.setContentOffset(savedOffset, animated: false)
        innerCollectionView.reloadData()
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        savedOffset = innerCollectionView.contentOffset
    }
}

prepareForReuse() guarda o offset antes da célula ser reciclada, e configure reaplica ele quando a célula volta com dados novos. Isso é necessário porque UICollectionView recicla célula, e sem esse cuidado a segunda seção “Categorias” nasce rolada pro meio, com o resto do conteúdo da primeira que estava naquela mesma célula reciclada.

Só que savedOffset é uma propriedade única por célula, não por seção. Rolei a lista de categorias da linha 3 pra direita, desci a tela até a linha 3 sair da tela, e quando a linha 9 reaproveitou aquela célula, ela abriu com o scroll horizontal já deslocado, herdado da linha 3. Dava pra corrigir guardando o offset num dicionário por identificador de seção em vez de na célula. Mas cada correção parecia destampar outro buraco parecido.

Trocando a lista aninhada por uma seção só

UICollectionViewCompositionalLayout resolve orthogonal scrolling nativamente, sem segunda UICollectionView. A ideia é declarar item, group e section, e deixar o scroll horizontal ser uma propriedade da section, não uma view dentro de uma célula.

func makeCategoriesSection() -> NSCollectionLayoutSection {
    let item = NSCollectionLayoutItem(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .absolute(96),
            heightDimension: .absolute(96)
        )
    )

    let group = NSCollectionLayoutGroup.horizontal(
        layoutSize: NSCollectionLayoutSize(
            widthDimension: .estimated(96),
            heightDimension: .absolute(96)
        ),
        subitems: [item]
    )
    group.interItemSpacing = .fixed(8)

    let section = NSCollectionLayoutSection(group: group)
    section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
    section.interGroupSpacing = 8
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)

    return section
}

E o layout inteiro escolhe qual função de section chamar, por índice:

let layout = UICollectionViewCompositionalLayout { sectionIndex, _ in
    switch Section(rawValue: sectionIndex) {
    case .categorias: return self.makeCategoriesSection()
    case .destaques: return self.makeDestaquesSection()
    default: return nil
    }
}

Não tem mais offset pra salvar em lugar nenhum. Cada section orthogonal mantém a própria posição de scroll sozinha, porque agora é uma seção da mesma UICollectionView, não uma view separada dentro de uma célula reciclável.

O pulo do gato ficou no header, não no scroll

Depois que a seção “Categorias” rolava direito, faltava o título “Categorias” em cima dela. Na primeira tentativa botei o título como se fosse só mais um item, dentro do mesmo group horizontal, do lado dos círculos de categoria.

Compilava, o título aparecia, e por um segundo pareceu resolvido. Até eu rolar a seção pra direita e ver o título sumir pela esquerda, junto com os outros itens.

Título como item do group rola junto com o scroll horizontal. Título como boundarySupplementaryItem da section fica fixo acima do group. ERRADO Categ. título = item do group CERTO Categorias título = boundary da section

O motivo é que tudo dentro de um NSCollectionLayoutGroup rola junto. Header de section não é conteúdo do group, é um boundarySupplementaryItem da própria NSCollectionLayoutSection, que fica fixo enquanto o group rola por baixo dele.

let header = NSCollectionLayoutBoundarySupplementaryItem(
    layoutSize: NSCollectionLayoutSize(
        widthDimension: .fractionalWidth(1),
        heightDimension: .estimated(32)
    ),
    elementKind: UICollectionView.elementKindSectionHeader,
    alignment: .top
)

section.boundarySupplementaryItems = [header]
flowchart TD
    Section --> Group
    Group --> Item1[Item]
    Group --> Item2[Item]
    Section -->|fica fixo| Header[boundarySupplementaryItem]

Tirar o título de dentro do group e registrar ele como supplementary da section foi a diferença entre “aparece na tela” e “se comporta como cabeçalho”.

orthogonalScrollingBehavior também tem mais opção do que eu esperava no começo. Testei .continuous primeiro, e soltando o dedo no meio de um item, a seção ficava parada ali, com um círculo cortado ao meio na borda. Troquei pra .continuousGroupLeadingBoundary, que ajusta a posição final pra alinhar a borda de um group inteiro, sem virar paginação rígida como .groupPaging faria.

O que ficou mais fácil, e o que não ficou

O ganho maior não foi só livrar da UICollectionView aninhada. Foi passar a descrever cada seção como um valor, montado por uma função, em vez de uma célula com estado próprio pra manter sincronizado.

Nem tudo ficou mais simples. Uma das seções da tela era um banner com cards de tamanhos alternados, um grande e dois pequenos ao lado, repetindo o padrão. Dava pra montar isso com group aninhado dentro de group, mas o resultado ficou mais difícil de ler do que a versão anterior com layout customizado que já existia pra esse caso específico. Deixei essa seção fora do CompositionalLayout, numa UICollectionView separada, só pra ela.