DEV Community

Cover image for Melhorando a performance da Pokédex com Paging 3 e Flow API
Ronaldo Costa de Freitas
Ronaldo Costa de Freitas

Posted on

Melhorando a performance da Pokédex com Paging 3 e Flow API

Até agora nós já conseguimos mostrar todos os pokémon da região de Kanto (os 151 primeiros) na tela do nosso app, junto com seus nomes, ids e tipos. Porém, nossa aplicação ainda está muito lenta: são necessários mais que 10 segundos para carregar apenas os primeiros monstrinhos de bolso. Por esse motivo, nesse post nós vamos melhorar a performance da nossa Pokédex usando a versão 3 da biblioteca Paging e a Flow API.

O que é Paging 3?

A Paging é uma biblioteca capaz de implementar um mecanismo chamado paginação, trata-se de carregar grandes quantidades de dados de forma gradual, reduzindo o uso de rede e de recursos do sistema. A versão 3 dessa biblioteca está estável, usa Kotlin coroutines por default e é por isso que vamos usá-la.

Vantagens de Paging 3:

  • Controla as chaves a serem utilizadas para recuperar a página seguinte e a anterior.
  • Automaticamente faz a requisição da página seguinte correta quando o usuário percorre a tela para o fim dos dados carregados.
  • Garante que múltiplas requisições não sejam disparadas ao mesmo tempo.
  • Rastreia o estado de carregamento e nos permite exibí-lo em uma RecyclerView e provê uma funcionalidade de retentativa fácil para casos de falha no carregamento.
  • Permite o uso de operações comuns como map ou filter na lista a ser exibida.
  • Provê um jeito fácil de implementar um separador de lista, como um footer, que iremos fazer.
  • Simplifica o cache dos dados, garantindo que nós não estaremos executando transformações de dados a cada mudança de configuração.

Como podemos ver, é uma ótima solução para o nosso problema de lentidão e limitação de exibição dos pokémon, com essa biblioteca seremos capazes de exibir todos os monstrinhos 🤩

O que é Flow API?

De forma resumida, em coroutines, um Flow é um tipo que pode emitir vários valores sequencialmente, ao contrário das suspend functions, que retornam apenas um valor. No nosso caso, o usaremos para emitir um fluxo de dados de pokémon a partir do Paging para criar a nossa pokédex.

Conceitualmente, um Flow é um stream (fluxo) de dados que pode ser computado de forma assíncrona. Os valores emitidos precisam ser do mesmo tipo. Por exemplo, um Flow<Int> é um fluxo que emite valores inteiros. Vejamos o exemplo abaixo:

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.delay

suspend fun main() {
    val flow: Flow<Int> = flow {
        (0..10).forEach {
            delay(2000)
            emit(it)
        }
    }.map {
        it * it
    }

    flow.collect {
        println(it)
    }
}
Enter fullscreen mode Exit fullscreen mode
0
1
4
9
16
25
36
49
64
81
100
Enter fullscreen mode Exit fullscreen mode

Podemos considerar esse exemplo como um Hello World de Flow, nele nós criamos um fluxo de dados de inteiros que são emitidos a cada 2 (dois) segundos. Esses dados são transformados usando a função map e depois coletados usando a função collect para enfim serem exibidos no console.

Com esse simples exemplo nós podemos enxergar e entender os componentes e entidades envolvidas no fluxo de dados: o Flow Builder (produtor), o Operator (intermediário) e o Collector (consumidor).

flow-entities.png

Em resumo, o Flow Builder realiza as tarefas e emite os dados, o Operator transforma os dados de um formato para outro e o Collector coleta os dados emitidos pelo Flow Builder que foram transformados pelo Operator.

Na nossa aplicação, a fonte da dados e o repositório representarão as tarefas do consumidor e intermediário, enquanto a UI será a consumidora.

Implementando a solução

Para implementar a nossa solução precisaremos criar um PagingSource, um Pager, um Flow de PagingData e um adaptador para a UI chamado PagingDataAdapter.

paging-components

Ok, mas o que é essa sopa de letrinhas? Calma, vamos entender um por um.

PagingSource

Um PagingSource é a definição de uma fonte de dados para a paginação e a forma como esses dados serão recuperados de uma única fonte. O PagingSource deve ser parte da camada repository.

Vamos sobrescrever as funções getRefreshKey(), que provê uma chave para a função load(), e a própria função load(), que recupera os dados paginados da fonte de dados e retorna esses dados carregados junto com as informações sobre as chaves posterior e anterior. Ambas essas funções são suspend functions e por isso poderemos usar as funções que fazem as requisições à PokéAPI.

Primeiro de tudo, adicionaremos a dependência da biblioteca Paging ao nosso arquivo gradle.build:

def pagingVersion = "3.1.1"

dependencies {
        ...

    // Paging
    implementation "androidx.paging:paging-runtime:$pagingVersion"

        ...
}
Enter fullscreen mode Exit fullscreen mode

Agora procederemos para o desenvolvimento da PagingSource, que chamaremos de PokedexPagingSource. O construtor primário da nossa classe vai receber uma instância de PokemonApi e ela vai herdar de PagingSource<Int, SinglePokemon>, ou seja, ela recebe uma chave inteira e devolve os dados de um pokémon. Iniciaremos com a construção do método getRefreshKey(), responsável por prover uma chave para o método load():

package br.com.pokedex.data.datasource.repository

import androidx.paging.PagingSource
import androidx.paging.PagingState
import br.com.pokedex.data.api.PokemonApi
import br.com.pokedex.domain.model.SinglePokemon

class PokedexPagingSource(
    private val api: PokemonApi
) : PagingSource<Int, SinglePokemon>() {

    override fun getRefreshKey(state: PagingState<Int, SinglePokemon>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A chave que esse método retorna deve fazer com que load() carregue itens suficientes para preencher a viewport ao redor da última posição acessada, permitindo que a próxima geração os anime de forma transparente.

A última posição acessada pode ser recuperada via state.anchorPosition, a qual é tipicamente o mais alto ou mais baixo item na viewport devido ao acesso ser disparado pela ligação dos itens conforme eles rolam na tela. Mas no nosso caso, a última posição acessada não é o bastante, nós precisamos saber qual a chave/página mais próxima a essa posição e é por isso que usamos a função closestPageToPosition.

Por exemplo, digamos que temos que carregar 20 itens por página, carregar duas páginas significariam usar as chaves 1 e 2 e ter 40 itens carregados no total. Nessa situação, quando formos procurar pelo item na posição 30, state.anchorPosition retornaria 30, mas essa não pode ser nossa chave, nós precisa saber em qual página esse item está, ou seja, precisamos saber que é a página 2. É por isso que usamos state.anchorPosition e tentamos recuperar sua chave anterior, chave 1, plus(1) (”mais um”), resultando na chave 2. Caso a chave anterior seja nula, tentamos recuperar através da chave posterior, chave 3, minus(1) (”menos um”), resultando também na chave 2.

Chegou a hora de implementar a tão falada função load(). O seu retorno padrão é do tipo LoadResult<Key, Value>, no nosso caso é LoadResult<Int, SinglePokemon>. A classe LoadResult é uma sealed class. Uma sealed class é uma classe em que todas as suas subclasses devem ser desenvolvidas dentro dela mesma ou no mesmo arquivo. Essa nossa classe de retorno tem três subclasses: Error, Invalid e Page. Error representa um erro esperado (como uma falha de conexão de rede), Invalid representa a invalidação de qualquer futura requisição do PagingSource e Page representa o objeto retornado com sucesso.

A classe Page tem as seguintes propriedades relevantes para nós:

  • data: os dados carregados
  • prevKey: a chave para a página anterior, caso ela exista
  • nextKey: a chave para a página posterior, caso ela exista

Implementação da função load():

package br.com.pokedex.data.datasource.repository

import androidx.paging.PagingSource
import androidx.paging.PagingState
import br.com.pokedex.data.api.PokemonApi
import br.com.pokedex.data.datasource.Constants.LAST_OFFSET
import br.com.pokedex.data.datasource.Constants.LAST_POSITION
import br.com.pokedex.data.datasource.Constants.POKEMON_OFFSET
import br.com.pokedex.data.datasource.Constants.POKEMON_STARTING_OFFSET
import br.com.pokedex.data.mapper.toModel
import br.com.pokedex.domain.model.SinglePokemon
import okio.IOException
import retrofit2.HttpException

class PokedexPagingSource(
    private val api: PokemonApi
) : PagingSource<Int, SinglePokemon>() {

    // ...

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, SinglePokemon> {
        val position = params.key ?: POKEMON_STARTING_OFFSET
        return try {
            val response = api.getPokemon(
                if (position == LAST_POSITION) {
                    LAST_OFFSET
                } else {
                    position * POKEMON_OFFSET
                }
            )
            val pokemon = mutableListOf<SinglePokemon>()
            response.body()?.results?.map { result ->
                val singlePokemon = api.getSinglePokemon(result.name)
                singlePokemon.body()?.toModel()?.let { pokemon.add(it) }
            }
            LoadResult.Page(
                data = pokemon,
                prevKey = if (position == POKEMON_STARTING_OFFSET) null else position,
                nextKey = if (position == LAST_POSITION) null else position + 1
            )
        } catch (exception: IOException) {
            return LoadResult.Error(exception)
        } catch (exception: HttpException) {
            return LoadResult.Error(exception)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Objeto Constants:

package br.com.pokedex.data.datasource

object Constants {
    const val BASE_URL = "https://pokeapi.co/api/v2/"
    const val POKEMON_STARTING_OFFSET = 0
    const val POKEMON_OFFSET = 20
    const val LAST_OFFSET = 885
    const val LAST_POSITION = 45
    const val PAGE_SIZE = 20
}
Enter fullscreen mode Exit fullscreen mode

Também foi necessário modificar a PokemonApi com dois novos métodos: getPokemon(), que retorna certo número de pokémon de acordo com um offset e getSinglePokemon(), que obtêm os dados do pokémon a partir do seu nome. Além disso, o retorno dos métodos agora é do tipo Response<T>, necessário para a implementação com Paging:

package br.com.pokedex.data.api

import br.com.pokedex.data.api.dto.PokemonDTO
import br.com.pokedex.data.api.dto.SinglePokemonDTO
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query

interface PokemonApi {

    @GET("pokemon/")
    suspend fun getPokemon(
        @Query("offset") offset: Int?
    ): Response<PokemonDTO>

    @GET("pokemon/{name}")
    suspend fun getSinglePokemon(
        @Path("name") name: String?
    ) : Response<SinglePokemonDTO>

}
Enter fullscreen mode Exit fullscreen mode

Classe PokemonDTO:

package br.com.pokedex.data.api.dto

import com.google.gson.annotations.SerializedName

data class PokemonDTO(
    @SerializedName("results") val results: List<PokemonResultDTO>
)
Enter fullscreen mode Exit fullscreen mode

Iremos entender o código passo-a-passo.

Obtemos o valor da chave atual usando params.key, ou seja, usamos a função getRefreshKey(), caso seja nulo, usamos a constante POKEMON_STARTING_OFFSET:

val position = params.key ?: POKEMON_STARTING_OFFSET
Enter fullscreen mode Exit fullscreen mode

O retorno da função é um try-catch em que tentamos recuperar os dados de 20 pokémon usando as constantes LAST_POSITION, LAST_OFFSET e POKEMON_OFFSET. A lógica é simples: para evitar obter pokémon com dados inconsistentes (além do id 905) nós passamos apenas o offset final, caso seja a última posição, caso não, fazemos o cálculo de position * POKEMON_OFFSET para obter os dados dos pokémon da vez. Além disso mapeamos o resultado dessa requisição para uma lista do tipo SinglePokemon. Por fim instanciamos um LoadResult e damos os valores às suas propriedades:

  • data recebe a lista de pokémon antes mencionada
  • prevKey recebe null, caso a posição atual seja 0 (zero), caso não, recebe a posição atual
  • nextKey recebe null, caso seja a última posição, caso não, recebe a posição atual mais 1
val response = api.getPokemon(
    if (position == LAST_POSITION) {
        LAST_OFFSET
    } else {
        position * POKEMON_OFFSET
    }
)
val pokemon = mutableListOf<SinglePokemon>()
response.body()?.results?.map { result ->
    val singlePokemon = api.getSinglePokemon(result.name)
    singlePokemon.body()?.toModel()?.let { pokemon.add(it) }
}
LoadResult.Page(
    data = pokemon,
    prevKey = if (position == POKEMON_STARTING_OFFSET) null else position,
    nextKey = if (position == LAST_POSITION) null else position + 1
)
Enter fullscreen mode Exit fullscreen mode

Finalmente temos os catchs para protegermos nossa aplicação quanto a problemas de IO (conexão de rede, por exemplo) e HTTP (respostas com códigos diferentes de 2XX):

catch (exception: IOException) {
    return LoadResult.Error(exception)
} catch (exception: HttpException) {
    return LoadResult.Error(exception)
}
Enter fullscreen mode Exit fullscreen mode

Ufa! Bastante coisa né? Mas calma que ainda tem mais.

Pager e Flow<PagingData<T>>

Agora que implementamos uma forma de lidar com nossa fonte de dados remota, já podemos modificar nosso repositório para essa mudança. A partir desse momento nosso repositório irá retornar um fluxo de dados do tipo PagingData<SinglePokemon> a partir de um Pager, mas o que é um Pager?

Um Pager nada mais é do que um construtor para um fluxo reativo (reactive stream) de PagingData, ele, unido ao Flow, lida com todos os estados da paginação, retornando um objeto em caso de sucesso e um erro em caso contrário.

Para instanciar um Pager, precisamos de um PageConfig, uma configuração para um Pager, no qual nós informamos o tamanho da página, 20, e além disso também é necessário informar a pagingSourceFactory, ou seja, como serão fabricados os dados daquele Pager, a saber, o PokédexPagingSource que definimos a pouco:

package br.com.pokedex.data.datasource.repository

import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import br.com.pokedex.data.api.PokemonApi
import br.com.pokedex.data.datasource.Constants.PAGE_SIZE
import br.com.pokedex.domain.model.SinglePokemon
import br.com.pokedex.domain.repository.PokemonRepository
import kotlinx.coroutines.flow.Flow

class PokemonRepositoryImpl(private val api: PokemonApi) : PokemonRepository {

    override fun getSinglePokemon(): Flow<PagingData<SinglePokemon>> {
        return Pager(
            config = PagingConfig(
                pageSize = PAGE_SIZE
            ),
            pagingSourceFactory = { PokedexPagingSource(api) }
        ).flow
    }
}
Enter fullscreen mode Exit fullscreen mode

Por fim usamos a propriedade flow de Pager para indicar que será retornado um fluxo de PagingData, isto é, serão emitidas novas instâncias de PagingData em certas circunstâncias.

Interface PokemonRepository modificada:

package br.com.pokedex.domain.repository

import androidx.paging.PagingData
import br.com.pokedex.data.api.Resource
import br.com.pokedex.data.api.dto.SinglePokemonDTO
import br.com.pokedex.domain.model.SinglePokemon
import kotlinx.coroutines.flow.Flow

interface PokemonRepository {

    fun getSinglePokemon(): Flow<PagingData<SinglePokemon>>
}
Enter fullscreen mode Exit fullscreen mode

PagingDataAdapter

Como já implementamos a camada de repository, vamos modificar a ViewModel para refletir essas mudanças. Não usaremos mais LiveData, usaremos Flow:

package br.com.pokedex.presentation

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import br.com.pokedex.domain.interactor.GetSinglePokemonUseCase
import br.com.pokedex.domain.model.SinglePokemon
import kotlinx.coroutines.flow.Flow

class PokedexViewModel(
    private val useCase: GetSinglePokemonUseCase
) : ViewModel() {

    fun getPokemonFlow(): Flow<PagingData<SinglePokemon>> {
        return useCase.execute().cachedIn(viewModelScope)
    }

}
Enter fullscreen mode Exit fullscreen mode

Ficou bem mais simples, né? Nós basicamente mudamos o tipo de retorno da função getPokemonFlow(), antes chamada de getPokemon(), para Flow<PagingData<SinglePokemon>> e chamamos o UseCase, mas com a diferença que aplicamos uma nova função a essa chamada: cachedIn(). O que essa função faz?

A função cachedIn() cria um cache (área de memória de acesso rápido) de PagingData . Isso faz com que o flow seja mantido ativo enquanto o dado escopo está ativo, no nosso caso o escopo da ViewModel. Dessa forma garantimos que, qualquer que seja a mudança de configuração (rotação da tela, por exemplo), a nova Activity já irá receber os dados existentes, ao invés de fazer novas requisições para obter os dados do zero.

Note que o código do UseCase não foi alterado, graças a sintaxe enxuta do Kotlin:

package br.com.pokedex.domain.interactor

import br.com.pokedex.domain.repository.PokemonRepository

class GetSinglePokemonUseCase(private val repository: PokemonRepository) {

    fun execute() = repository.getSinglePokemon()
}
Enter fullscreen mode Exit fullscreen mode

Pronto, já podemos criar o PagingDataAdapter que vai tornar possível conectar tudo isso com a UI. Basicamente vamos alterar o adapter já existente para refletir as mudanças que fizemos. O PokemonViewHolder se manterá o mesmo:

class PokemonViewHolder(binding: PokemonCardBinding) :
        RecyclerView.ViewHolder(binding.root) {
        private val image = binding.pokemonImage
        private val name = binding.pokemonName
        private val id = binding.pokemonId
        private val firstType = binding.firstPokemonType
        private val secondType = binding.secondPokemonType

        fun bind(singlePokemon: SinglePokemon) {
            loadPokemonImage(image, singlePokemon.imageUrl)
            name.text = singlePokemon.name
            id.text = singlePokemon.id.toString()
            firstType.text = singlePokemon.types.first().name
            secondType.text = singlePokemon.types.last().name
            secondType.apply {
                showIf(text.isNotEmpty())
            }
        }

        private fun loadPokemonImage(image: ImageView, imageUrl: String) {
            image.load(imageUrl)
        }
}
Enter fullscreen mode Exit fullscreen mode

O retorno da nossa classe será PagingDataAdapter, no entanto, para utilizarmos esse tipo de adapter é necessário implementar a classe DiffUtil.ItemCallback. Essa classe é responsável por realizar um callback (passar uma função como parâmetro de outra) que calcula a diferença entre dois itens não-nulos na lista. Em outras palavras, ela serve para verificar se dois objetos representam o mesmo item e checar se dois itens tem os mesmos dados.

Vamos implementá-la como uma singleton class, uma classe de instância única. Kotlin desenvolve esse padrão de projeto com o uso da palavra chave object e como essa singleton class ficará dentro de outra, ela será um companion object:

companion object {
    private val POKEMON_COMPARATOR = object
        : DiffUtil.ItemCallback<SinglePokemon>() {

        override fun areItemsTheSame(
            oldItem: SinglePokemon,
            newItem: SinglePokemon
        ): Boolean =
            oldItem.id == newItem.id

        override fun areContentsTheSame(
            oldItem: SinglePokemon,
            newItem: SinglePokemon
        ): Boolean =
            oldItem == newItem
    }
}
Enter fullscreen mode Exit fullscreen mode

Como podemos ver, sobrescrevemos dois métodos de DiffUtil.ItemCallback: areItemsTheSame() e areContentsTheSame(). Eles são encarregados de checar se dois itens são o mesmo item e se dois itens têm o mesmo conteúdo, respectivamente.

Nossa implementação é bem simples: para verificar se dois itens são o mesmo item, basta compararmos os ids dos pokémon e para verificar se dois itens têm o mesmo conteúdo podemos fazer uma comparação direta mesmo.

Podemos partir para o desenvolvimento das funções onBindViewHolder() e onCreateViewHolder(), não será necessário sobrescrevermos o método getItemCount(), o PagingDataAdapter será responsável por isso.

Nosso onBindViewHolder() ficará dessa forma:

override fun onBindViewHolder(holder: PokemonViewHolder, position: Int) {
    val singlePokemon = getItem(position)
    singlePokemon?.let {
        holder.bind(it)
    }
}
Enter fullscreen mode Exit fullscreen mode

Aqui usamos o método getItem() fornecido pelo PagingDataAdapter que nos retorna o item que precisamos a partir da sua posição, visto que o retorno pode ser nulo, usamos uma safe call e finalmente realizamos o bind().

E quanto ao nosso método onCreateViewHolder(), nada muda:

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PokemonViewHolder {
        return PokemonViewHolder(
            PokemonCardBinding.inflate(
                LayoutInflater.from(context),
                parent,
                false
            )
        )
}
Enter fullscreen mode Exit fullscreen mode

Está feito! Temos o nosso adapter:

package br.com.pokedex.presentation

import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import br.com.pokedex.databinding.PokemonCardBinding
import br.com.pokedex.domain.model.SinglePokemon
import br.com.pokedex.util.showIf
import coil.load

class PokedexAdapter(
    private val context: Context
) : PagingDataAdapter<SinglePokemon, PokedexAdapter.PokemonViewHolder>(
    POKEMON_COMPARATOR
) {

    override fun onBindViewHolder(holder: PokemonViewHolder, position: Int) {
        val singlePokemon = getItem(position)
        singlePokemon?.let {
            holder.bind(it)
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PokemonViewHolder {
        return PokemonViewHolder(
            PokemonCardBinding.inflate(
                LayoutInflater.from(context),
                parent,
                false
            )
        )
    }

    inner class PokemonViewHolder(binding: PokemonCardBinding) :
        RecyclerView.ViewHolder(binding.root) {
        private val image = binding.pokemonImage
        private val name = binding.pokemonName
        private val id = binding.pokemonId
        private val firstType = binding.firstPokemonType
        private val secondType = binding.secondPokemonType

        fun bind(singlePokemon: SinglePokemon) {
            loadPokemonImage(image, singlePokemon.imageUrl)
            name.text = singlePokemon.name
            id.text = singlePokemon.id.toString()
            firstType.text = singlePokemon.types.first().name
            secondType.text = singlePokemon.types.last().name
            secondType.apply {
                showIf(text.isNotEmpty())
            }
        }

        private fun loadPokemonImage(image: ImageView, imageUrl: String) {
            image.load(imageUrl)
        }
    }

    companion object {
        private val POKEMON_COMPARATOR = object
            : DiffUtil.ItemCallback<SinglePokemon>() {

            override fun areItemsTheSame(
                oldItem: SinglePokemon,
                newItem: SinglePokemon
            ): Boolean =
                oldItem.id == newItem.id

            override fun areContentsTheSame(
                oldItem: SinglePokemon,
                newItem: SinglePokemon
            ): Boolean =
                oldItem == newItem
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Conectando o adapter na UI

Procederemos para a conexão do adapter na UI. As duas funções mais importantes que construiremos serão as seguintes: setUpAdapter() e getPokemon(). A função setUpAdapter() se encarregará de configurar o nosso adapter:

private fun setUpAdapter() {
        pokedexAdapter.addLoadStateListener { loadState ->
            when(loadState.refresh) {
                is LoadState.Loading -> setUpLoadingView()
                is LoadState.Error -> setUpErrorView()
                else -> setUpSuccessView()
            }
        }
}
Enter fullscreen mode Exit fullscreen mode

O PagingDataAdapter nos fornece uma excelente classe para tratamento de erros chamada CombinedLoadStates, que provê todos os estados de carregamento do adapter. O que nos interessa aqui é o estado refresh, que representa o estado do adapter no seu primeiro carregamento: Loading, Error e NotLoading, nos interessa apenas os dois primeiros. Caso esse estado seja Loading, configuramos a UI para representar um carregamento, caso seja Erorr, configuramos a UI para representar um erro e caso não seja nenhum desses dois, consideramos sucesso e apresentamos a lista de pokémon.

Métodos de configuração da UI:

private fun setUpSuccessView() {
        binding.apply {
            pokedexRecyclerView.showView()
            pokedexCircularProgressIndicator.hideView()
            pokedexErrorMessage.hideView()
        }
}

private fun setUpErrorView() {
    binding.apply {
        pokedexRecyclerView.hideView()
        pokedexCircularProgressIndicator.hideView()
        pokedexErrorMessage.showView()
    }
}

private fun setUpLoadingView() {
    binding.apply {
        pokedexRecyclerView.hideView()
        pokedexCircularProgressIndicator.showView()
        pokedexErrorMessage.hideView()
    }
}
Enter fullscreen mode Exit fullscreen mode

Já conhecemos pokedexRecyclerView, mas aqui referenciamos duas outras views: pokedexCircularProgressIndicator e pokedexErrorMessage. A primeira se trata de uma progress bar em forma de círculo fornecida pelo Material Design e a segunda é apensa um TextView que apresenta uma mensagem de erro. Segue código modificado de activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/pokedexRecyclerView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:listitem="@layout/pokemon_card"
        android:visibility="visible"/>

    <TextView
        android:id="@+id/pokedexErrorMessage"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textAlignment="center"
        app:layout_constraintTop_toBottomOf="@id/pokedexRecyclerView"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:text="@string/error_message"
        android:visibility="gone"/>

    <com.google.android.material.progressindicator.CircularProgressIndicator
        android:id="@+id/pokedexCircularProgressIndicator"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        android:visibility="gone"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
Enter fullscreen mode Exit fullscreen mode

Deixamos as duas views auxiliares gone por padrão e controlamos as suas visibilidades por meio dos seguintes métodos:

package br.com.pokedex.util

import com.google.android.material.progressindicator.CircularProgressIndicator

fun CircularProgressIndicator.showView() {
    visibility = CircularProgressIndicator.VISIBLE
}

fun CircularProgressIndicator.hideView() {
    visibility = CircularProgressIndicator.GONE
}
Enter fullscreen mode Exit fullscreen mode
package br.com.pokedex.util

import android.widget.TextView

fun TextView.showIf(condition: Boolean) {
    if(condition) {
        visibility = TextView.VISIBLE
    }
}

fun TextView.showView() {
    visibility = TextView.VISIBLE
}

fun TextView.hideView() {
    visibility = TextView.GONE
}
Enter fullscreen mode Exit fullscreen mode
package br.com.pokedex.util

import androidx.recyclerview.widget.RecyclerView

fun RecyclerView.showView() {
    visibility = RecyclerView.VISIBLE
}

fun RecyclerView.hideView() {
    visibility = RecyclerView.GONE
}
Enter fullscreen mode Exit fullscreen mode

Também atualizamos o arquivo strings.xml, que contém as strings do nosso projeto:

<resources>
    <string name="app_name">Pokédex</string>
    <string name="error_message">Something went wrong\nPlease, check your network connection and try reopen the app</string>
</resources>
Enter fullscreen mode Exit fullscreen mode

Agora vamos analisar o método getPokemon(). Esse método é responsável por coletar os dados a partir da ViewModel e submetê-los para o adapter. Para fazer isso usamos as funções collectLatest do Flow e submitData() do PagingDataAdapter, respectivamente:

private fun getPokemon() {
        lifecycleScope.launch {
            viewModel.getPokemonFlow().collectLatest { pokemon ->
                pokedexAdapter.submitData(pokemon)
            }
        }
}
Enter fullscreen mode Exit fullscreen mode

Por fim, nossa classe PokedexActivity ficará dessa forma:

package br.com.pokedex.presentation

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.LinearLayoutManager
import br.com.pokedex.databinding.ActivityPokedexBinding
import br.com.pokedex.util.hideView
import br.com.pokedex.util.showView
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel

class PokedexActivity : AppCompatActivity() {

    private val binding by lazy {
        ActivityPokedexBinding.inflate(layoutInflater)
    }

    private val viewModel: PokedexViewModel by viewModel()
    private val pokedexAdapter by lazy {
        PokedexAdapter(this)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        setUpAdapter()
        setUpPokedexRecyclerView()
        getPokemon()
    }

    private fun setUpAdapter() {
        pokedexAdapter.addLoadStateListener { loadState ->
            when(loadState.refresh) {
                is LoadState.Loading -> setUpLoadingView()
                is LoadState.Error -> setUpErrorView()
                else -> setUpSuccessView()
            }
        }
    }

    private fun setUpSuccessView() {
        binding.apply {
            pokedexRecyclerView.showView()
            pokedexCircularProgressIndicator.hideView()
            pokedexErrorMessage.hideView()
        }
    }

    private fun setUpErrorView() {
        binding.apply {
            pokedexRecyclerView.hideView()
            pokedexCircularProgressIndicator.hideView()
            pokedexErrorMessage.showView()
        }
    }

    private fun setUpLoadingView() {
        binding.apply {
            pokedexRecyclerView.hideView()
            pokedexCircularProgressIndicator.showView()
            pokedexErrorMessage.hideView()
        }
    }

    private fun setUpPokedexRecyclerView() {
        binding.pokedexRecyclerView.apply {
            layoutManager = LinearLayoutManager(context)
            adapter = pokedexAdapter
        }
    }

    private fun getPokemon() {
        lifecycleScope.launch {
            viewModel.getPokemonFlow().collectLatest { pokemon ->
                pokedexAdapter.submitData(pokemon)
            }
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Já podemos rodar o app e vermos os três tipos de visualização:

Tela de loading:

loading.png

Tela de erro:

error-view.png

Tela de sucesso:

sucesso.png

Com essa implementação conseguimos reduzir o tempo de espera de carregamento dos pokémon para menos de 5 segundos, uma melhora de mais de 50%! Parabéns para nós 😎👊

Mas ainda temos um problema: apesar de tratarmos possíveis erros no primeiro carregamento da tela, não fazemos isso depois. Ou seja, caso a internet do celular pare de funcionar por algum motivo, o usuário ficará sem saber o que fazer. Precisamos melhorar essa experiência. Como podemos fazer isso? Adicionando um footer!

Melhorando a experiência com footer

A biblioteca Paging oferece diversas ferramentas para melhorar a experiência da sua implementação, uma delas é o footer, que nada mais é do que um rodapé, um texto, botão ou imagem que sempre vai ficar no fim da tela. Usaremos esse footer para exibir uma progress bar circular de carregamento ou uma mensagem de erro e um botão para tentar carregar os dados restantes novamente.

Antes de tudo vamos criar um layout para esse footer, ele será composto de um CircularProgressIndicator, para representar o carregamento, um TextView, o texto de erro e um MaterialButton, o botão para tentar novamente. Todos eles terão visibilidade gone por default:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <TextView
        android:id="@+id/loadStateErrorMessage"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/error_message"
        android:layout_gravity="center"
        android:textAlignment="center"
        android:visibility="gone"/>

    <com.google.android.material.button.MaterialButton
        android:id="@+id/loadStateTryAgainButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/try_again"
        android:layout_gravity="center"
        android:visibility="gone"
        android:layout_marginTop="16dp"/>

    <com.google.android.material.progressindicator.CircularProgressIndicator
        android:id="@+id/loadStateProgressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        android:layout_gravity="center" />

</LinearLayout>
Enter fullscreen mode Exit fullscreen mode

Arquivo strings.xml modificado:

<resources>
    <string name="app_name">Pokédex</string>
    <string name="error_message">Something went wrong\nCheck your network connection and try again</string>
    <string name="try_again">Try again</string>
    <string name="load_error_message">Something went wrong\nTry again</string>
</resources>
Enter fullscreen mode Exit fullscreen mode

Para implementarmos esse footer também precisaremos criar um adapter, o LoadStateAdapter e esse adapter precisa de um ViewHolder. Vamos chamá-lo de PokedexLoadStateViewHolder:

package br.com.pokedex.presentation

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.LoadState
import androidx.recyclerview.widget.RecyclerView
import br.com.pokedex.R
import br.com.pokedex.databinding.PokedexLoadStateFooterBinding
import br.com.pokedex.util.hideView
import br.com.pokedex.util.showView

class PokedexLoadStateViewHolder(
    private val binding: PokedexLoadStateFooterBinding,
    tryAgain: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    init {
        binding.loadStateTryAgainButton.setOnClickListener { tryAgain.invoke() }
    }

    fun bind(loadState: LoadState) {
        when(loadState) {
            is LoadState.Loading -> {
                binding.loadStateProgressBar.showView()
                binding.loadStateErrorMessage.hideView()
                binding.loadStateTryAgainButton.hideView()
            }
            is LoadState.Error -> {
                binding.loadStateErrorMessage.showView()
                binding.loadStateTryAgainButton.showView()
                binding.loadStateProgressBar.hideView()
            }
            is LoadState.NotLoading -> {
                // Do nothing
            }
        }
    }

    companion object {
        fun create(parent: ViewGroup, tryAgain: () -> Unit) : PokedexLoadStateViewHolder {
            val view = LayoutInflater.from(parent.context)
                .inflate(R.layout.pokedex_load_state_footer, parent, false)
            val binding = PokedexLoadStateFooterBinding.bind(view)
            return PokedexLoadStateViewHolder((binding), tryAgain)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Código das extension functions do MaterialButton:

package br.com.pokedex.util

import com.google.android.material.button.MaterialButton

fun MaterialButton.showView() {
    visibility = MaterialButton.VISIBLE
}

fun MaterialButton.hideView() {
    visibility = MaterialButton.GONE
}
Enter fullscreen mode Exit fullscreen mode

O nosso ViewHolder recebe duas coisas no seu construtor: uma propriedade binding, que contém as referências às views do nosso layout, um callback da função que será executada ao clicar no botão de tentar novamente. Aqui também usamos uma initializer block, trata-se de um bloco de código que será executado sempre que a classe for criada. Nele nós informamos que a ação de clique no botão irá invocar a função callback tryAgain(), ou seja, irá executá-la.

class PokedexLoadStateViewHolder(
    private val binding: PokedexLoadStateFooterBinding,
    tryAgain: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {

    init {
        binding.loadStateTryAgainButton.setOnClickListener { tryAgain.invoke() }
    }

    // ...

}
Enter fullscreen mode Exit fullscreen mode

Depois disso criamos a função bind(), que irá ligar o adapter ao ViewHolder. Em resumo, ela usa os loadStates para tornar a UI reativa, faz com que ela reaja aos estados de sucesso, erro e carregamento:

fun bind(loadState: LoadState) {
        when(loadState) {
            is LoadState.Loading -> {
                binding.loadStateProgressBar.showView()
                binding.loadStateErrorMessage.hideView()
                binding.loadStateTryAgainButton.hideView()
            }
            is LoadState.Error -> {
                binding.loadStateErrorMessage.showView()
                binding.loadStateTryAgainButton.showView()
                binding.loadStateProgressBar.hideView()
            }
            is LoadState.NotLoading -> {
                // Do nothing
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Por fim, desenvolvemos uma função que retorna uma instância desse ViewHolder em um companion object, ela recebe uma ViewGroup, que chamamos de parent, e uma função callback tryAgain(). O parent trata-se do layout-pai desse outro layout, isto é, o activity_main.xml, e o tryAgain() é a função que será executada ao clicar no botão de tentar novamente:

companion object {
    fun create(parent: ViewGroup, tryAgain: () -> Unit) : PokedexLoadStateViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.pokedex_load_state_footer, parent, false)
        val binding = PokedexLoadStateFooterBinding.bind(view)
        return PokedexLoadStateViewHolder((binding), tryAgain)
    }
}
Enter fullscreen mode Exit fullscreen mode

O código do nosso adapter será bem simples: sobrescrevemos os métodos onBindViewHolder() e onCreateViewHolder(). No primeiro, chamamos a função bind() do ViewHolder e no segundo chamamos a função create():

package br.com.pokedex.presentation

import android.view.ViewGroup
import androidx.paging.LoadState
import androidx.paging.LoadStateAdapter

class PokedexLoadStateAdapter(
    private val tryAgain: () -> Unit
) : LoadStateAdapter<PokedexLoadStateViewHolder>() {

    override fun onBindViewHolder(holder: PokedexLoadStateViewHolder, loadState: LoadState) {
        holder.bind(loadState)
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        loadState: LoadState
    ): PokedexLoadStateViewHolder {
        return PokedexLoadStateViewHolder.create(parent, tryAgain)
    }

}
Enter fullscreen mode Exit fullscreen mode

Note que o PokedexLoadStateAdapter recebe por parâmetro uma função callback, se trata da função que é executada ao clicar no botão de tentar novamente. Estamos a propagando desde a UI até o ViewHolder a passando na função create() no método onCreateViewHolder().

Estamos quase finalizando, mas antes precisamos fazer uma alteração em PokedexAdapter. Da forma como desenvolvemos o footer ele será exibido com o tamanho de um único span no RecyclerView e como planejamos apresentar os pokémon na forma de um grid de duas colunas, temos que consertar isso.

A lógica é a seguinte: caso o item de visualização seja um pokémon, esse item terá tamanho de span um, caso não seja, terá tamanho de span dois. Vamos começar sobrescrevendo a função getViewType() da PokedexAdapter:

override fun getItemViewType(position: Int): Int {
    return if (position == itemCount) {
        NETWORK_VIEW_TYPE
    } else {
        POKEMON_VIEW_TYPE
    }
}
Enter fullscreen mode Exit fullscreen mode

Se a posição atual for igual a última, ou seja, igual a itemCount, o tipo de item de visualização será NETWORK_VIEW_TYPE, isto é, será um item que irá exibir a progress bar ou o erro. Caso não seja, será um item do tipo POKEMON_VIEW_TYPE, em outras palavras, o item exibirá um pokémon.

Objeto Constants modificado:

package br.com.pokedex.util

object Constants {
    const val BASE_URL = "https://pokeapi.co/api/v2/"
    const val POKEMON_STARTING_OFFSET = 0
    const val POKEMON_OFFSET = 20
    const val LAST_OFFSET = 885
    const val LAST_POSITION = 45
    const val PAGE_SIZE = 20
    const val NETWORK_VIEW_TYPE = 2
    const val POKEMON_VIEW_TYPE = 1
}
Enter fullscreen mode Exit fullscreen mode

Pronto, já podemos começar a conectar o adpter à UI. A principal modifição será no método setUpPokedexRecyclerView(). É nele que vamos implementar essa lógica de tamanho de span:

package br.com.pokedex.presentation

import ...

private const val SPAN_COUNT = 2

class PokedexActivity : AppCompatActivity() {

    //...    

    private fun setUpPokedexRecyclerView() {
        binding.pokedexRecyclerView.apply {
            val gridLayoutManager = GridLayoutManager(context, SPAN_COUNT)
            gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
                override fun getSpanSize(position: Int): Int {
                    val viewType = pokedexAdapter.getItemViewType(position)
                    return if (viewType == POKEMON_VIEW_TYPE) ONE_SPAN_SIZE
                    else TWO_SPANS_SIZE
                }
            }
            layoutManager = gridLayoutManager
            adapter = pokedexAdapter.withLoadStateFooter(
                footer = PokedexLoadStateAdapter { pokedexAdapter.retry() }
            )
        }
    }

    //...

}
Enter fullscreen mode Exit fullscreen mode

Objeto Constants modificado:

package br.com.pokedex.util

object Constants {
    const val BASE_URL = "https://pokeapi.co/api/v2/"
    const val POKEMON_STARTING_OFFSET = 0
    const val POKEMON_OFFSET = 20
    const val LAST_OFFSET = 885
    const val LAST_POSITION = 45
    const val PAGE_SIZE = 20
    const val NETWORK_VIEW_TYPE = 2
    const val POKEMON_VIEW_TYPE = 1
    const val ONE_SPAN_SIZE = 1
    const val TWO_SPANS_SIZE = 2
}
Enter fullscreen mode Exit fullscreen mode

Primeiro nós criamos um GridLayoutManager, com span_count igual a dois. Depois nós instanciamos uma nova classe de SpanSizeLookUp como uma classe singleton e sobrescrevemos o método getSpanSizeLookUp(). Dentro desse método, nós obtemos o viewType do item atual, caso seja um pokémon, seu span terá tamanho um, caso não, seu span terá tamanho dois. Após isso, definimos que o gridLayout que configuramos será o layoutManager do RecyclerView e na definição do adapter fazemos uma modificação no código: informamos que agora a pokedexAdapter terá um footer, o PokedexLoadStateAdapter. Note que aqui finalmente passamos a função que será executada ao clicar no botão de tentar novamente: pokedexAdapter.retry(), essa função tenta recarregar qualquer requisição que falhou durante a paginação.

Para prover essa funcionalidade de retry() também no carregamento inicial, modificamos nosso layout da activity, inserindo um MaterialButton que quando clicado executa a função retry():

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/pokedexRecyclerView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:listitem="@layout/pokemon_card" />

    <TextView
        android:id="@+id/pokedexErrorMessage"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/error_message"
        android:textAlignment="center"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/pokedexRecyclerView" />

    <com.google.android.material.button.MaterialButton
        android:id="@+id/pokedexTryAgainButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="@string/try_again"
        android:visibility="gone"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/pokedexErrorMessage" />

    <com.google.android.material.progressindicator.CircularProgressIndicator
        android:id="@+id/pokedexCircularProgressIndicator"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:indeterminate="true"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
Enter fullscreen mode Exit fullscreen mode

Para relembrar como está o strings.xml:

<resources>
    <string name="app_name">Pokédex</string>
    <string name="error_message">Something went wrong\nCheck your network connection and try again</string>
    <string name="try_again">Try again</string>
    <string name="load_error_message">Something went wrong\nTry again</string>
</resources>
Enter fullscreen mode Exit fullscreen mode

Sendo assim, a nossa classe PokedexActivity ficará dessa forma:

package br.com.pokedex.presentation

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.GridLayoutManager
import br.com.pokedex.databinding.ActivityPokedexBinding
import br.com.pokedex.util.Constants.NETWORK_VIEW_TYPE
import br.com.pokedex.util.Constants.ONE_SPAN_SIZE
import br.com.pokedex.util.Constants.POKEMON_VIEW_TYPE
import br.com.pokedex.util.Constants.TWO_SPANS_SIZE
import br.com.pokedex.util.hideView
import br.com.pokedex.util.showView
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.viewModel

private const val SPAN_COUNT = 2

class PokedexActivity : AppCompatActivity() {

    private val binding by lazy { ActivityPokedexBinding.inflate(layoutInflater) }
    private val pokedexAdapter by lazy { PokedexAdapter(this) }
    private val viewModel: PokedexViewModel by viewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        setUpAdapter()
        setUpTryAgainButton()
        setUpPokedexRecyclerView()
        getPokemon()
    }

    private fun setUpAdapter() {
        pokedexAdapter.addLoadStateListener { loadState ->
            when(loadState.refresh) {
                is LoadState.Loading -> setUpLoadingView()
                is LoadState.Error -> setUpErrorView()
                else -> setUpSuccessView()
            }
        }
    }

    private fun setUpSuccessView() {
        binding.apply {
            pokedexRecyclerView.showView()
            pokedexCircularProgressIndicator.hideView()
            pokedexErrorMessage.hideView()
            pokedexTryAgainButton.hideView()
        }
    }

    private fun setUpErrorView() {
        binding.apply {
            pokedexRecyclerView.hideView()
            pokedexCircularProgressIndicator.hideView()
            pokedexErrorMessage.showView()
            pokedexTryAgainButton.showView()
        }
    }

    private fun setUpLoadingView() {
        binding.apply {
            pokedexRecyclerView.hideView()
            pokedexCircularProgressIndicator.showView()
            pokedexErrorMessage.hideView()
            pokedexTryAgainButton.hideView()
        }
    }

    private fun setUpTryAgainButton() {
        binding.pokedexTryAgainButton.setOnClickListener {
            pokedexAdapter.refresh()
        }
    }

    private fun setUpPokedexRecyclerView() {
        binding.pokedexRecyclerView.apply {
            val gridLayoutManager = GridLayoutManager(context, SPAN_COUNT)
            gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
                override fun getSpanSize(position: Int): Int {
                    val viewType = pokedexAdapter.getItemViewType(position)
                    return if (viewType == POKEMON_VIEW_TYPE) ONE_SPAN_SIZE
                    else TWO_SPANS_SIZE
                }
            }
            layoutManager = gridLayoutManager
            adapter = pokedexAdapter.withLoadStateFooter(
                footer = PokedexLoadStateAdapter { pokedexAdapter.retry() }
            )
        }
    }

    private fun getPokemon() {
        lifecycleScope.launch {
            viewModel.getPokemonFlow().collectLatest { pokemon ->
                pokedexAdapter.submitData(pokemon)
            }
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Finalmente nosso trabalho por hoje acabou! Vamos rodar o app e ver como ficaram as telas.

Tela de erro modificada:

erro_tela_inicial.png

Tela de pokémon com footer de carregamento:

loading_pokemon.png

Tela de pokémon com o footer de erro:

erro_pokemon.png

Próximos posts

Nos próximos posts vamos embelezar nossa Pokédex usando Navigation e Material Design, também vamos salvar os dados dos pokémon em um banco de dados usando Android Room e testar nosso app usando JUnit e Mockk.

Obrigado pela atenção e até a próxima!

Repo no github:

GitHub logo ronaldocoding / pokedex

A simple Pokédex

Pokédex

A simple Pokédex

Post anterior:

Top comments (0)