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.
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.
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.