Construindo um App com uma API do Github, uma Collection View e um sonho


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?
}
Enter fullscreen mode

Exit fullscreen mode

Que é populado a partir de uma request em um interactor, e depois apresentado via presenter

override func viewDidLoad() {
        super.viewDidLoad()
        showLoading()
        setupUI()
        interactor.loadInitialUsers()
}
Enter fullscreen mode

Exit fullscreen mode

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),

        ])
    }
Enter fullscreen mode

Exit fullscreen mode

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
    }()
Enter fullscreen mode

Exit fullscreen mode

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()
            }
        }
    }
}
Enter fullscreen mode

Exit fullscreen mode

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)

    }
}

Enter fullscreen mode

Exit fullscreen mode

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)
Enter fullscreen mode

Exit fullscreen mode

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.

Tela de busca retornada vazia

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)
        ])
    }
}

Enter fullscreen mode

Exit fullscreen mode

E ela, ao ser clicada, abre o fluxo UserDetailViewController (como descrito na função didSelectItemAt)

Imagem 1 Imagem 2

Você pode encontrar o código completo da UserListViewController no link do github



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *