Utilizando IGListKit com UICollectionView

Utilizando IGListKit com UICollectionView

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.

Empty UICollectionView

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.

Search photo

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.

UITableView UICollectionView IGListKit
UITableViewDataSource
numberOfSections(in:)
UICollectionViewDataSource
numberOfSections(in:)
ListAdapter -> ListAdapterDataSource
objects(for:)
UITableViewDataSource
tableView(_:numberOfRowsInSection:)
UICollectionViewDataSource
collectionView(_:numberOfItemsInSection:)
ListSectionController
numberOfItems()
UITableViewDataSource
tableView(_:cellForRowAt:)
UICollectionViewDataSource
collectionView(_:cellForItemAt:)
ListSectionController
cellForItem(at:)
UITableViewDelegate
tableView(_:heightForRowAt:)
UICollectionViewDelegateFlowLayout
collectionView(_:layout:sizeForItemAt:)
ListSectionController
sizeForItem(at:)
UITableViewDelegate
tableView(_:willDisplay:forRowAt:)
UICollectionViewDelegate
collectionView(_:willDisplay:forItemAt:)
ListAdapter -> UIScrollViewDelegate
scrollViewWillEndDragging(_:withVelocity:targetContentOffset:)
UITableViewDelegate
tableView(_:didSelectRowAt:)
UICollectionViewDelegate
collectionView(_:didSelectItemAt:)
ListSectionController
didSelectItem(at:)
UITableViewDataSourcePrefetching
tableView(_:prefetchRowsAt:)
UICollectionViewDataSourcePrefetching
collectionView(_:prefetchItemsAt:)
ListSectionController -> ListWorkingRangeDelegate
listAdapter(_:sectionControllerWillEnterWorkingRange:)

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 UICollectionViewCells para este tipo de dado.

IGListKit Architecture

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.

First execution 01

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.

Second execution 02

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.

Network Link Conditioner 01 Network Link Conditioner 02 Network Link Conditioner 03

Network Link Conditioner 04 Network Link Conditioner 05 Network Link Conditioner 06

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.

Third execution

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.

Working Range

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.

Publicidade

Deixe um comentário

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair /  Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair /  Alterar )

Conectando a %s