Depois de ler sobre a flexibilidade da Collection View, achei interessante usá-la lado a lado de uma Table View e comentar um pouco das diferenças que podemos encontrar.
O projeto lista usuários com table view e collection view, e os busca usando a API do Github.
Diferente da table view, projetada para exibir dados em uma única coluna vertical, como uma lista, UICollectionView é um componente muito mais flexível, projetado para exibir dados em layouts personalizáveis e multidimensionais, podemos fazer carrosséis e também layouts em grade.
Esses usuários são exibidos a partir desse objeto:
import Foundation
struct GithubUserViewModel {
let avatarURL: URL?
let name: String
let login: String
let description: String?
let language: String?
let publicRepos: Int?
let following: Int?
let followers: Int?
}
Que é populado a partir de uma request em um interactor, e depois apresentado via presenter
override func viewDidLoad() {
super.viewDidLoad()
showLoading()
setupUI()
interactor.loadInitialUsers()
}
private func setupUI() {
title = "GitHub Users"
navigationController?.navigationBar.titleTextAttributes = [
.foregroundColor: UIColor.label
]
view.backgroundColor = .systemBackground
view.addSubview(searchBar)
view.addSubview(switchContainer)
view.addSubview(tableView)
view.addSubview(scrollView)
scrollView.addSubview(collectionView)
switchContainer.addSubview(switchLabel)
switchContainer.addSubview(switchButton)
NSLayoutConstraint.activate([
searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
searchBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
switchContainer.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 8),
switchContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
switchContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
switchContainer.heightAnchor.constraint(equalToConstant: 40),
switchLabel.leadingAnchor.constraint(equalTo: switchContainer.leadingAnchor),
switchLabel.centerYAnchor.constraint(equalTo: switchContainer.centerYAnchor),
switchButton.trailingAnchor.constraint(equalTo: switchContainer.trailingAnchor),
switchButton.centerYAnchor.constraint(equalTo: switchContainer.centerYAnchor),
tableView.topAnchor.constraint(equalTo: switchContainer.bottomAnchor, constant: 8),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: switchContainer.bottomAnchor, constant: 8),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
collectionView.topAnchor.constraint(equalTo: scrollView.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 8),
collectionView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
collectionView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
collectionView.heightAnchor.constraint(equalToConstant: 480),
])
}
Ja escrevi sobre VIP por aqui, então não passarei por essa parte
O UICollectionViewLayout é quem define como os itens são organizados, permitindo grades, carrosséis, layouts circulares, etc.
private lazy var collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 10
layout.minimumInteritemSpacing = 10
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(UserProfileCollectionViewCell.self, forCellWithReuseIdentifier: UserProfileCollectionViewCell.reuseIdentifier)
collectionView.register(ErrorCollectionViewCell.self, forCellWithReuseIdentifier: ErrorCollectionViewCell.reuseIdentifier)
collectionView.register(LongCardUserProfileCollectionViewCell.self, forCellWithReuseIdentifier: LongCardUserProfileCollectionViewCell.reuseIdentifier)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.isHidden = true
return collectionView
}()
Abaixo vamos ver um pouco mais sobre o que iremos encontrar desenvolvendo uma Collection View, e mais abaixo veremos o código de exemplo para elas
A UICollectionViewCell representa cada elemento exibido na Collection View, no exemplo, a célula está representando os dados de cada usuário, mas também pode exibir uma ErrorCollectionViewCell, por exemplo.
A classe UICollectionReusableView representa visualizações reutilizáveis na Collection View. Ela é usada para criar componentes reutilizáveis, como cabeçalho, rodapé ou componentes intermediários personalizados.
O protocolo UICollectionViewDataSource é responsável por fornecer os dados para a sua UICollectionView. Ele responde a perguntas como quantas seções a coleção tem, quantos itens existem em cada seção e
qual célula deve ser exibida para cada item no índice específico?
O protocolo UICollectionViewDelegate lida com eventos como o que acontece quando o usuário toca em uma célula, qual deve ser o tamanho de cada célula ou cabeçalho e como a coleção deve reagir ao ser rolada.
Implementando os protocolos de data source, delegate e flow layout, eu costumo criar uma extension para implementá-los e ficar mais organizado, algo assim
// MARK: - UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout
extension UserListViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
if showErrorCell && users.isEmpty {
return 1
}
return users.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if showErrorCell && users.isEmpty {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ErrorCollectionViewCell.reuseIdentifier, for: indexPath) as? ErrorCollectionViewCell else {
return UICollectionViewCell()
}
return cell
}
if !isGridMode {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: LongCardUserProfileCollectionViewCell.reuseIdentifier, for: indexPath) as? LongCardUserProfileCollectionViewCell else {
return UICollectionViewCell()
}
cell.configure(with: users[indexPath.item])
return cell
}
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: UserProfileCollectionViewCell.reuseIdentifier, for: indexPath) as? UserProfileCollectionViewCell else {
return UICollectionViewCell()
}
cell.configure(with: users[indexPath.item])
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return CGSize(width: 250, height: 250)
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
if !showErrorCell {
let selectedUser = users[indexPath.item]
let detailVC = UserDetailViewController(user: selectedUser)
navigationController?.pushViewController(detailVC, animated: true)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == collectionView {
let offsetX = scrollView.contentOffset.x
let contentWidth = scrollView.contentSize.width
let width = scrollView.frame.size.width
if offsetX > contentWidth - width - 100, !isLoading, hasMoreData, isCollectionViewMode {
isLoading = true
interactor.loadMoreUsers()
}
} else {
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
let height = scrollView.frame.size.height
if offsetY > contentHeight - height - 100, !isLoading, hasMoreData, !isCollectionViewMode {
isLoading = true
interactor.loadMoreUsers()
}
}
}
}
Essa apresentação faz parte da Collection View em carrossel, perceba que ela tem uma altura fixa e não divide a largura da view, por exemplo, para fazer um layout em grid, como veremos no código abaixo.
UICollectionViewDelegateFlowLayout
O sizeForItemAt define o tamanho (CGSize) de cada célula em um indexPath específico.
Os tamanhos são diferentes para vermos as possibilidades de exibir como carrossel e grid.
// MARK: - UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout
extension UserListGridViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if showErrorCell && users.isEmpty {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ErrorCollectionViewCell.reuseIdentifier, for: indexPath) as? ErrorCollectionViewCell else {
return UICollectionViewCell()
}
return cell
}
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: UserProfileCollectionViewCell.reuseIdentifier, for: indexPath) as? UserProfileCollectionViewCell else {
return UICollectionViewCell()
}
cell.configure(with: users[indexPath.item])
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let padding: CGFloat = 10
let itemsPerRow: CGFloat = 2
let rowsPerColumn: CGFloat = 4
let availableWidth = collectionView.bounds.width - padding * (itemsPerRow + 1)
let cellWidth = availableWidth / itemsPerRow
let availableHeight = collectionView.bounds.height - padding * (rowsPerColumn + 1)
let cellHeight = availableHeight / rowsPerColumn
return CGSize(width: cellWidth, height: cellHeight)
}
}
Percebem a diferença entre o sizeForItemAt anterior e o atual, isso significa que vamos exibir o layout em grade e precisamos desses valores diferentes para mapear como essa grade será exibida, abaixo explico um pouco de cada valor:
Cria uma constante chamada padding (preenchimento ou margem) com o valor 10. Esse valor representa o espaçamento entre as células e também as margens da coleção.
Define uma constante itemsPerRow (itens por linha) com o valor 2. Isso significa que a intenção é ter duas células por linha.
Define uma constante rowsPerColumn (linhas por coluna) com o valor 4. Isso indica que o objetivo é ter quatro linhas na tela.
Calcula a largura disponível para as células.
collectionView.bounds.width é a largura total da UICollectionView.
padding * (itemsPerRow + 1) calcula o espaço total ocupado pelas margens. Se você tem 2 itens por linha, há uma margem no início, uma entre os dois itens, e uma no final, totalizando 3 margens (2 + 1). Então, o cálculo subtrai esse espaço total da largura da tela.
Divide a largura disponível (availableWidth) pelo número de itens por linha (itemsPerRow). O resultado é a largura que cada célula deve ter para caber na linha.
De forma análoga ao cálculo da largura, essa linha calcula a altura disponível para as células.
collectionView.bounds.height é a altura total da UICollectionView.
padding * (rowsPerColumn + 1) calcula o espaço total das margens verticais. Se você tem 4 linhas, haverá 5 margens (4 + 1).
Divide a altura disponível (availableHeight) pelo número de linhas (rowsPerColumn). O resultado é a altura que cada célula deve ter.
return CGSize(width: cellWidth, height: cellHeight)
Finalmente, a função retorna um objeto CGSize, que é uma estrutura que contém a largura e a altura calculadas. Esse valor é usado pelo UICollectionView para desenhar cada célula na tela com o tamanho correto.
UICollectionViewDataSource
O numberOfItemsInSection informa à collection view quantos itens ela deve exibir em uma determinada seção, se houver um erro, apenas 1 é exibido, ou se houver sucesso, exibirá users.count, o total de usuários.
O cellForItemAt configura e retorna a célula que será exibida em um indexPath específico
UICollectionViewDelegate
O didSelectItemAt é chamado sempre que o usuário toca em uma célula.
O scrollViewDidScroll, parte do ScrollViewDelegate (que a UICollectionView herda), é acionado toda vez que o usuário rola a tela. Usamos geralmente para trabalhar com paginação.
Agora criando a célula de sucesso que exibimos no cellForItemAt:
import UIKit
final class UserProfileCollectionViewCell: UICollectionViewCell {
static let reuseIdentifier = "UserProfileCollectionViewCell"
private lazy var avatarImageView: UIImageView = {
let image = UIImageView()
image.translatesAutoresizingMaskIntoConstraints = false
image.contentMode = .scaleAspectFill
image.clipsToBounds = true
return image
}()
private lazy var titleLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = .systemFont(ofSize: 18, weight: .bold)
label.textAlignment = .center
return label
}()
private lazy var descriptionComponent: IconTextComponent = {
let component = IconTextComponent()
return component
}()
private lazy var reposComponent: IconTextComponent = {
let component = IconTextComponent()
return component
}()
private lazy var followersComponent: IconTextComponent = {
let component = IconTextComponent()
return component
}()
private lazy var stackView: UIStackView = {
let stackView = UIStackView(arrangedSubviews: [titleLabel, descriptionComponent, reposComponent, followersComponent])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.spacing = 4
stackView.alignment = .leading
return stackView
}()
override init(frame: CGRect) {
super.init(frame: frame)
setupLayout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
avatarImageView.layer.cornerRadius = avatarImageView.bounds.width / 2
}
func configure(with viewModel: GithubUserViewModel) {
titleLabel.text = viewModel.name
let descriptionText = (viewModel.description == nil || viewModel.description == "") ? "No description" : viewModel.description!
descriptionComponent.configure(imageName: "pencil", text: descriptionText)
let reposText = "\(viewModel.publicRepos ?? 0) public repos"
reposComponent.configure(imageName: "folder", text: reposText)
let followersText = "\(viewModel.followers ?? 0) followers"
followersComponent.configure(imageName: "person", text: followersText)
avatarImageView.load(url: viewModel.avatarURL)
}
private func setupLayout() {
contentView.addSubview(avatarImageView)
contentView.addSubview(stackView)
contentView.layer.cornerRadius = 12
contentView.layer.borderWidth = 1
contentView.layer.borderColor = UIColor.lightGray.cgColor
contentView.clipsToBounds = true
NSLayoutConstraint.activate([
avatarImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
avatarImageView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
avatarImageView.widthAnchor.constraint(equalToConstant: 80),
avatarImageView.heightAnchor.constraint(equalToConstant: 80),
stackView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 12),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
stackView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16)
])
}
}
E ela, ao ser clicada, abre o fluxo UserDetailViewController (como descrito na função didSelectItemAt)
![]() |
![]() |
Você pode encontrar o código completo da UserListViewController no link do github