A grande maioria dos aplicativos no iOS utiliza um UITableView
ou um UICollectionView
para exibir um conjunto de dados ou uma lista de opções. Quando precisamos sincronizar estes dados com atualizações de uma API utilizamos o método updateData()
que força a UITableView
ou a UICollectionView
a redesenhar todas as rows. Este reload é perceptível pro usuário. Caso o cálculo necessário para a correta exibição dos dados seja mais demorado pode haver um travamento ou um reloading ainda mais perceptível.
O correto nestes casos seria comparar todas as alterações que ocorreram e usar o método beginUpdates()
ou performBatchUpdates(_:completion:)
para realizar todas as alterações ocorridas nos dados.
No post de hoje iremos conhecer a biblioteca IGListKit do Instagram. Ela resolve este problema de atualização dos dados com um eficiente algoritmo de comparação, além de separar a lógica de exibição dos dados do UICollectionView
de uma maneira que não cria um UIViewController
com várias linhas. Vamos utilizar o aplicativo Swift Photo Search que criamos no post Decodificando json com Swift 4. O projeto pode ser baixado aqui.
Abra o projeto, altere o Bundle Identifier, signing e os arquivos Router.swift
e Data.json
como demonstrado no post anterior. Execute o projeto e será exibido um UICollectionView
vazio.
A primeira coisa que teremos que fazer para utilizar o IGListKit é alterar o nosso model para implementar o protocolo ListDiffable
. Este dois métodos diffIdentifier()
e isEqual(toDiffableObject:)
são utilizados pelo IGListKit para identificar as alterações realizadas nos dados. Até o momento o o protocolo não é compatível com struct
do Swift e por isto iremos alterar o model Photo
para class
. Também iremos adicionar o import
necessário para a utilização da biblioteca.
import IGListKit import Foundation class Photo: Codable { ... required init(from decoder: Decoder) throws { ... } ... }
Como alteramos a struct
Photo
para class
devemos mudar a declaração dela para weak
onde ela é passada por referência.
Existe um exemplo de como implementar o protocolo com struct
aqui. Também está em discussão um Swift-bridge que irá simplificar a utilização do IGListKit com Swift. A discussão pode ser acompanhada aqui.
Em seguida criaremos um extension para implementar o protocolo ListDiffable
.
// MARK: - ListDiffable extension Photo: ListDiffable { func diffIdentifier() -> NSObjectProtocol { return id as NSObjectProtocol } func isEqual(toDiffableObject object: ListDiffable?) -> Bool { if let photo = object as? Photo { return width == photo.width && height == photo.height && user == photo.user } return false } }
O método diffIdentifier()
retorna um identificador único para o nosso model. O método isEqual(toDiffableObject:)
retorna se o dado foi alterado ou não.
Com esta modificação podemos utilizar a classe ListDiffPaths
para criar um array com as diferenças no nosso model. Exemplo:
// Se recebermos o array de photos updatedPhotos e quisermos saber quais fotos foram atualizadas poderíamos usar o comando abaixo. var diff = ListDiffPaths(fromSection: 0, toSection: 0, oldArray: result.photos, newArray: updatedPhotos, option: .equality) // Atualiza os dados. result.photos = updatedPhotos // Atualiza o UITableView tableView.beginUpdates() tableView.deleteRows(at: diff.deletes, with: .fade) tableView.insertRows(at: diff.inserts, with: .automatic) tableView.reloadRows(at: diff.updates, with: .none) tableView.endUpdates() var diffBatch = diff.forBatchUpdates() // Atualiza o UICollectionView collectionView.performBatchUpdates({ collectionView.deleteItems(at: diffBatch.deletes) collectionView.insertItems(at: diffBatch.inserts) collectionView.reloadItems(at: diffBatch.updates) diffBatch.moves.forEach { move in collectionView.moveItem(at: move.from, to: move.to) } }, completion: nil)
Com o nosso model pronto podemos ir para o MainViewController2.swift
implementar os métodos utilizados pela biblioteca IGListKit.
Para efeito de comparação vamos visualizar os principais método do UITableView
que utilizamos no aplicativo com o método correspondente do UICollectionView
e do UICollectionView
com IGListKit.
A biblioteca IgListKit utiliza a classe ListAdapter
para gerenciar os dados do UICollecionView
. O adapter aceita qualquer dado que esteja em conformidade com o protocolo ListDiffable
. Para cada tipo de dado é criado um ListSectionController
que irá criar e gerenciar as UICollectionViewCell
s para este tipo de dado.
Vamos começar adicionando a propriedade adapter
a classe MainViewController2
.
class MainViewController2: UIViewController { ... // MARK: - Properties lazy var adapter: ListAdapter = { return ListAdapter(updater: ListAdapterUpdater(), viewController: self) }() let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) ... }
O ListAdapterUpdater
é a classe padrão do IGListKit e serve para a maioria das situações mas caso precise implementar uma customizada basta que o seu updater implemente o protocolo IGListUpdatingDelegate
.
No método viewDidLoad()
atribuimos a UICollectionView
a propriedade collectionView
do adapter. Depois definimos o UIViewController
como delegate dos protocolos ListAdapterDataSource
e UIScrollViewDelegate
. O Xcode exibirá um erro pois não implementamos os dois protocolos. Comente o delegate scrollViewDelegate pois iremos implementa-lo posteriormente.
override func viewDidLoad() { super.viewDidLoad() view.addSubview(collectionView) adapter.collectionView = collectionView adapter.dataSource = self //adapter.scrollViewDelegate = self ... }
Vamos começar pelo protocolo ListAdapterDataSource
. Ele possui três métodos obrigatórios: objects(for:)
, listAdapter(_:sectionControllerFor:)
e emptyView(for:)
. O primeiro retorna uma lista com os dados que serão exibidos no UICollectionView
. Pode ser qualquer tipo de dado que implemente o protocolo ListDiffable
. O segundo retorna o ListSectionController
para cada tipo de dado. No nosso caso só temos Photo
por enquanto. O terceiro retorna uma view para ser exibida quando não houver dados.
// MARK: - extension MainViewController2: ListAdapterDataSource { // MARK: - ListAdapterDataSource func objects(for listAdapter: ListAdapter) -> [ListDiffable] { var objects = result.photos as [ListDiffable] return objects } func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController { return PhotoSectionController() } func emptyView(for listAdapter: ListAdapter) -> UIView? { return nil } }
Vamos criar a classe PhotoSectionController
do tipo ListSectionController
na pasta SectionControllers
.
Iniciaremos adicionando os imports necessários à nossa classe. Depois criaremos a propriedade photo
do tipo Photo
que conterá os dados a serem exibidos. Por último iremos implementar método sizeForItem(at:)
. O método é o responsável por definir o tamanho da cell que será exibida e podemos pegar o tamanho atual com a propriedade collectionContext
.
import UIKit import IGListKit import Kingfisher class PhotoSectionController: ListSectionController { // MARK: - Properties private weak var photo: Photo? // MARK: - Initializers // MARK: - Methods override func sizeForItem(at index: Int) -> CGSize { guard let photo = photo, let width = collectionContext?.containerSize.width else { return CGSize.zero } let height = CGFloat(photo.heightForThumbnail(withWidth: Double(width))) return CGSize(width: width, height: height) } }
O próximo método criará a UICollectionViewCell
. É o método cellForItem(at:)
e funciona da mesma forma que o UICollectionView
tradicional com a exceção de que criamos a cell utilizando o collectionContext
.
override func cellForItem(at index: Int) -> UICollectionViewCell { guard let cell = collectionContext?.dequeueReusableCell(of: PhotoCollectionViewCell.self, for: self, at: index) as? PhotoCollectionViewCell else { fatalError() } if let photo = photo { cell.photo = photo } return cell }
Por último implementaremos o método didUpdate(to:)
que recebe o nosso objeto Photo
. Este método será chamado antes da criação da cell. Temos que garantir que o objeto recebido é do tipo esperado.
override func didUpdate(to object: Any) { precondition(object is Photo) self.photo = object as! Photo }
Caso seu objeto vá exibir mais de uma cell, por exemplo uma cell com dados do User
abaixo da Photo ou uma cell que servirá como section header, você pode definir esta quantidade com o método numberOfItems()
.
Com o nosso ListSectionController
pronto precisamos fazer uma última alteração para que o aplicativo funcione. No método loadPhotos(from:)
altere o código que atualiza o UICollectionView
de collectionView.reloadData()
para adapter.performUpdates(animated: true, completion: nil)
.
private func loadPhotos(from data: Data) { DispatchQueue.main.async { do { let photos = try [Photo].decode(data: data) self.result.update(with: photos) self.adapter.performUpdates(animated: true, completion: nil) } catch DecodingError.dataCorrupted(let context) { print(context.debugDescription) } catch DecodingError.keyNotFound(let codingKey, let context) { print("Key: \(codingKey). \(context.debugDescription)") } catch DecodingError.typeMismatch(let type, let context) { print(context.debugDescription + "\(type)") } catch { print(error.localizedDescription) } } }
Faça o mesmo no método searchPhotos(by:page:)
.
private func searchPhotos(by query: String, page: Int = 1) { ... self.adapter.performUpdates(animated: true, completion: nil) ... }
Rode o aplicativo e as fotos serão exibidas.
Ao clicar no ícone de informações veremos os dados do usuário que postou a foto mas se clicarmos na foto não é exibida a photo em tamanho real. Podemos implementar esta funcionalidade da mesma forma que o botão de informações do usuário, utilizando NotificationCenter
, ou podemos utilizar delegates. Vamos implementar com a segunda opção.
Vamos começar criando um protocolo no fonte PhotoSectionController.swift
antes da classe PhotoSectionController
.
protocol PhotoSectionControllerDelegate: class { func didSelectedItem(_ sectionController: PhotoSectionController, photo: Photo) }
Depois adicionamos a propriedade delegate
para o nosso protocol na classe PhotoSectionController
.
class PhotoSectionController: ListSectionController { // MARK: - Properties private weak var photo: Photo? weak var delegate: PhotoSectionControllerDelegate? // MARK: - Initializers ... }
Por último chamamos o método do protocolo quando o usuário clicar no item do UICollectionView
.
override func didSelectItem(at index: Int) { if let photo = photo { delegate?.didSelectedItem(self, photo: photo) } }
Finalizado o ListSectionController
vamos a classe MainViewController2
implementar o nosso protocolo. Primeiro temos que definir a classe como o delegate. Fazemos isto no método listAdapter(_:sectionControllerFor:)
.
func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController { var sectionController: ListSectionController sectionController = PhotoSectionController() (sectionController as! PhotoSectionController).delegate = self return sectionController }
Agora criamos um extension
para implementar o protocolo.
// MARK: - extension MainViewController2: PhotoSectionControllerDelegate { // MARK: - PhotoSectionControllerDelegate func didSelectedItem(_ sectionController: PhotoSectionController, photo: Photo) { performSegue(withIdentifier: Segues.photoSegue, sender: photo) } }
Poderíamos chamar o PhotoViewController
diretamente do PhotoSectionController
mas preferi usar o delegate pra manter o padrão utilizado no último post.
Com isto podemos visualizar a foto clicando na cell correspondente.
Outra funcionalidade que tínhamos no aplicativo era o carregamento de novas fotos a medida que vamos rolando as imagens iniciais. Vamos implementar esta funcionalidade com o método scrollViewWillEndDragging(_:withVelocity:targetContentOffset:)
do protocolo UIScrollViewDelegate
.
Descomente a linha abaixo do viewDidLoad()
.
override func viewDidLoad() { super.viewDidLoad() view.addSubview(collectionView) adapter.collectionView = collectionView adapter.dataSource = self adapter.scrollViewDelegate = self ... }
Agora adicione a extension
abaixo.
// MARK: - extension MainViewController2: UIScrollViewDelegate { // MARK: - UIScrollViewDelegate func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) { let distance = scrollView.contentSize.height - (targetContentOffset.pointee.y + scrollView.bounds.height) if !result.updating && distance < 200 { result.updating = true adapter.performUpdates(animated: true, completion: nil) if result.query.isEmpty { loadPhotos(page: result.nextPage) } else { searchPhotos(by: result.query, page: result.nextPage) } } } }
Primeiramente calculamos a distancia até o fim das fotos e caso a mesma seja menor que 200 px e se já não estivermos fazendo o download de mais fotos, chamamos a função correspondente para buscar mais fotos. Note que chamamos o método performUpdates(animated:completion:)
antes de chamarmos a função de download. Utilizaremos esta atualização pra inserir uma célula que mostrará que estamos realizando o download de mais fotos.
Faremos isto alterando dois métodos do protocolo ListAdapterDataSource
. Primeiro no método objects(for:)
verificamos se estamos realizando o download de mais fotos com a propriedade updating
. Caso seja verdadeiro adicionamos a variável loading
no final do array de Photo
. Como String
pode ser representado como ListDiffable
não há problema em inserir-la no array.
func objects(for listAdapter: ListAdapter) -> [ListDiffable] { var objects = result.photos as [ListDiffable] if result.updating { objects.append(loading as ListDiffable) } return objects }
No método listAdapter(_:sectionControllerFor:)
verificamos se o objeto é a nossa String loading. Caso seja verdadeiro retornamos o SpinnerSectionController
.
func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController { var sectionController: ListSectionController if let object = object as? String, object == loading { sectionController = SpinnerSectionController() } else { sectionController = PhotoSectionController() (sectionController as! PhotoSectionController).delegate = self } return sectionController }
Antes de executarmos o projeto novamente vamos alterar a qualidade da conexão para que seja possível visualizarmos a célula de loading. Caso a internet seja muito rápida os dados serão carregados antes de visualizarmos a célula. Mais informações podem ser encontradas no post Network Link Conditioner do site NSHipster.
Rodando o projeto veremos a célula de loading ao chegar no final das fotos. Depois que as novas fotos são carregadas o loading sumirá porque o método objects(for:)
será chamado novamente e a propriedade updating
será false
. Com isto só teremos objetos do tipo Photo no array.
Para que o nosso projeto tenha todas as funcionalidades do projeto anterior teremos que implementar o carregamento de fotos que ainda não estão visíveis na tela. No IGListKit utilizamos working range para esta funcionalidade. Ele informa para os sections controllers sobre quais cells irão ser exibidas em breve mas que ainda não estão visíveis.
Configuramos esta propriedade ao declarar o ListAdaper
. No nosso caso estamos definindo como 2.
class MainViewController2: UIViewController { ... // MARK: - Properties lazy var adapter: ListAdapter = { return ListAdapter(updater: ListAdapterUpdater(), viewController: self, workingRangeSize: 2) }() ... }
Para termos acesso as células que serão exibidas em breve implementamos o protocolo ListWorkingRangeDelegate
no ListSectionController
. Vamos adicionar a extension
abaixo no nosso PhotoSectionController
.
// MARK: - extension PhotoSectionController: ListWorkingRangeDelegate { // MARK: - ListWorkingRangeDelegate func listAdapter(_ listAdapter: ListAdapter, sectionControllerWillEnterWorkingRange sectionController: ListSectionController) { if let photo = listAdapter.object(for: sectionController) as? Photo, let url = photo.thumbnailURL() { imagePrefetcher = ImagePrefetcher(urls: [url]) imagePrefetcher?.start() } } func listAdapter(_ listAdapter: ListAdapter, sectionControllerDidExitWorkingRange sectionController: ListSectionController) { } }
Após recuperarmos o objeto Photo com o método object(for:)
da variável listAdapter
utilizamos o ImagePrefetcher
do Kingfisher para realizar o download da imagem.
Para finalizar declaramos a propriedade imagePrefetcher
, definimos o workingRangeDelegate
como self
no método init
e interrompemos o download da imagem caso a célula seja destruida.
class PhotoSectionController: ListSectionController { // MARK: - Properties private weak var photo: Photo? private var imagePrefetcher: ImagePrefetcher? weak var delegate: PhotoSectionControllerDelegate? // MARK: - Initializers override init() { super.init() workingRangeDelegate = self } deinit { imagePrefetcher?.stop() } // MARK: - Methods ... }
Agora nosso aplicativo está baixando as fotos das duas próximas células a serem exibidas melhorando a navegação do aplicativo.
Faltam duas alterações na classe MainViewController2
para terminarmos o projeto. Alterarmos a chamada do método loadPhotos2()
para loadPhotos()
. Assim iremos utilizar a chamada a API assim que o aplicativo for iniciado.
Adicionar a chamada performUpdates(animated:completion:)
do nosso adapter no método viewWillTransition(to:with:)
para que as células sejam redimensionaras ao mudarmos a orientação do telefone.
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) adapter.performUpdates(animated: true, completion: nil) }
Finalizamos o aplicativo com todas as funcionalidades da versão anterior com o plus de termos uma experiência bem mais agradável na navegação do aplicativo ao carregarmos mais imagens.
Espero que tenham gostado do artigo. Qualquer dúvida, crítica ou sugestão de novos assuntos deixe um comentário abaixo. Caso queira entrar em contato pode me chamar no twitter @mateusfsilva. Se gostou compartilhe.