DEV Community

Cover image for Mostrando os Pokémon com Coil em Android
Ronaldo Costa de Freitas
Ronaldo Costa de Freitas

Posted on • Edited on

Mostrando os Pokémon com Coil em Android

No último post, nós conseguimos construir uma arquitetura MVVM simples, conectar nosso app a PokéAPI e listar os pokémon de Kanto por meio de log. Mas não listamos os pokémon na tela da nossa Pokédex muito menos mostramos as imagens dos monstrinhos. Sendo assim, nesse post vamos refatorar um pouco nosso código, implementar uma Recycler View e usar o Coil para exibir os pokémon na tela.

Corrigindo um pequeno erro

Anterioirmente, eu acabei cometendo um erro no mapeamento da API, especificamente na estrutura do JSON que continha a imagem que queremos usar do pokémon. Eu mapeei assim:

"sprites": {
    "official-artwork": {
      "front-default": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/4.png"
    }
}
Enter fullscreen mode Exit fullscreen mode

O que deveria ser assim:

"sprites": {
    "other": {
        "official-artwork": {
            "front_default": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/4.png"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Para corrigir isso, precisamos fazer alguns ajustes simples: primeiro vamos criar a classe Other:

package br.com.pokedex.api

import com.google.gson.annotations.SerializedName

data class Other(
    @SerializedName("official-artwork") val officialArtwork: OfficialArtWork
)
Enter fullscreen mode Exit fullscreen mode

Após isso, basta modificar a classe Sprites dessa forma:

package br.com.pokedex.api

import com.google.gson.annotations.SerializedName

data class Sprites(
    @SerializedName("other") val other: Other
)
Enter fullscreen mode Exit fullscreen mode

E, por fim, corrigir o mapeamento front-default da classe OfficialArtWork para front_default:

package br.com.pokedex.api

import com.google.gson.annotations.SerializedName

data class OfficialArtWork(
    @SerializedName("front_default") val frontDefault: String? = null
)
Enter fullscreen mode Exit fullscreen mode

Pronto: agora nosso mapeamento da api está correto!

Refatorando o código

Outro “erro” do último post foi misturar os conceitos de classes Models e classes DTO (Data Transfer Object) ao fazer o mapeamento da API. Classes DTO são um padrão de software com o intuito de transferir informações entre as camadas de um sistema. Ele serve, por exemplo, para receber dados em um estado muito específico sem fazer contato entre as camadas inferiores da aplicação. No nosso caso, ele é útil justamente para serializar o JSON recebido como resposta pela PokéAPI. Sendo assim, vamos renomear todas as nossas antes classes Models para classes DTO e colocá-las em um pacote específico dto:

Classes DTO

Agora que já temos nossas classes DTO, vamos realmente fazer nossas classes Models, que servirão como receptoras dos dados armazenados pelas DTO. Primeiro temos a classe que irá representar um único pokémon, a SinglePokemon:

package br.com.pokedex.model

data class SinglePokemon(
    val name: String,
    val id: Int,
    val imageUrl: String,
    val types: List<Type>
)
Enter fullscreen mode Exit fullscreen mode

Agora vamos desenvolver a classe para representar os tipos do pokémon, a classe Type:

package br.com.pokedex.model

data class Type (
    val name: String
)
Enter fullscreen mode Exit fullscreen mode

Bem simples, não? Essas duas classes Model contém todos os dados dos pokémon que nós vamos precisar no momento. Agora vamos criar funções para mapear classes DTO para classes Models em um arquivo chamado PokedexMappers. A primeira delas é uma função para mapear uma SlotTypeDTO para uma Type:

package br.com.pokedex.data.mapper

import br.com.pokedex.api.dto.SlotTypeDTO
import br.com.pokedex.model.Type
import br.com.pokedex.util.emptyString

fun SlotTypeDTO.toModel() = Type(
    name = typeDTO.name ?: emptyString()
)
Enter fullscreen mode Exit fullscreen mode

Repare que nós usamos uma função chamada emptyString(), ela retorna apenas uma string vazia, como seu nome diz, fazemos isso para tornar nosso código mais idiomático, aqui seu código em um arquivo chamado StringExt:

package br.com.pokedex.util

fun emptyString() = ""
Enter fullscreen mode Exit fullscreen mode

Agora vamos fazer uma função para mapear uma lista de SlotTypeDTO para uma lista de Type:

package br.com.pokedex.data.mapper

import br.com.pokedex.api.dto.SlotTypeDTO
import br.com.pokedex.model.Type
import br.com.pokedex.util.emptyString

fun SlotTypeDTO.toModel() = Type(
    name = typeDTO.name ?: emptyString()
)

fun List<SlotTypeDTO>.toModel(): List<Type> {
    val types = mutableListOf<Type>()
    types.add(this.first().toModel())
    this.first().let { firstType ->
        this.last().let { secondType ->
            if(secondType != firstType) {
                types.add(secondType.toModel())
            } else {
                types.add(Type(emptyString()))
            }
        }
    }
    return types.toList()
}
Enter fullscreen mode Exit fullscreen mode

Essa é uma pouco mais complexa, mas o que fazemos aqui é criar uma MutableList de Type e adicionar o primeiro tipo do pokémon, depois analisamos se ele tem um segundo tipo obtendo o último elemento da lista de SlotTypeDTO, caso esse elemento seja diferente do primeiro, isso significa que há um segundo tipo, então nós o adicionamos na lista, caso seja igual, não há um segundo tipo, então nós adicionamos uma string vazia. Por fim, retornarmos a lista como uma List<Type>.

Agora basta mapearmos uma SinglePokemonDTO para uma SinglePokemon:

package br.com.pokedex.data.mapper

import br.com.pokedex.api.dto.SinglePokemonDTO
import br.com.pokedex.api.dto.SlotTypeDTO
import br.com.pokedex.model.SinglePokemon
import br.com.pokedex.model.Type
import br.com.pokedex.util.emptyString
import br.com.pokedex.util.zeroNumber

fun SlotTypeDTO.toModel() = Type(
    name = typeDTO.name ?: emptyString()
)

fun List<SlotTypeDTO>.toModel(): List<Type> {
    val types = mutableListOf<Type>()
    types.add(this.first().toModel())
    this.first().let { firstType ->
        this.last().let { secondType ->
            if(secondType != firstType) {
                types.add(secondType.toModel())
            } else {
                types.add(Type(emptyString()))
            }
        }
    }
    return types.toList()
}

fun SinglePokemonDTO.toModel() = SinglePokemon(
    name = name ?: emptyString(),
    id = id ?: zeroNumber(),
    imageUrl = sprites.other.officialArtworkDTO.frontDefault ?: emptyString(),
    types = types.toModel()
)
Enter fullscreen mode Exit fullscreen mode

Note que também usamos uma função zeroNumber() para deixar nosso código mais legível. Eis o código dela no arquivo IntExt:

package br.com.pokedex.util

fun zeroNumber() = 0
Enter fullscreen mode Exit fullscreen mode

Agora temos que substituir a menção a essas classes DTO no código para suas respectivas models, com especial atenção para a seguinte:

package br.com.pokedex.data.repository

import br.com.pokedex.api.PokemonApi
import br.com.pokedex.data.mapper.toModel
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

class PokemonRepositoryImpl {

    private val api: PokemonApi = Retrofit.Builder()
        .baseUrl("https://pokeapi.co/api/v2/")
        .addConverterFactory(GsonConverterFactory.create())
        .build()
        .create(PokemonApi::class.java)

    suspend fun getSinglePokemon(id: Int) = api.getSinglePokemon(id).toModel()
}
Enter fullscreen mode Exit fullscreen mode

Aqui nós usamos a função que mapeia para um único pokémon: em resumo, mudamos o tipo de retorno de getSinglePokemon() para SinglePokemon.

Por fim, mas não menos importante, vamos renomear nossa interface PokemonService para PokemonApi, com o intuito de deixar claro de que se trata da interface que contém as chamadas à PokéAPI:

package br.com.pokedex.api

import br.com.pokedex.api.dto.SinglePokemonDTO
import retrofit2.http.GET
import retrofit2.http.Path

interface PokemonApi {

    @GET("pokemon/{id}/")
    suspend fun getSinglePokemon(
        @Path("id") id: Int?
    ): SinglePokemonDTO

}
Enter fullscreen mode Exit fullscreen mode

E pronto: refatoramos nosso código! Agora ele está melhor do que antes nos quesitos de legibilidade e consistência de dados.

Construindo a Recycler View

Para a nossa Pokédex ser realmente uma Pokédex, precisamos mostrar nossos monstrinhos de bolso como uma lista e para isso existem diversas soluções em Android, como a Recycler View. Vamos usa ela por sua eficiência em exibir conjuntos grandes e dinâmicos de dados.

Com Recycler View nós fornecemos os dados e definimos a aparência de cada item, após isso, como o próprio nome indica, esses dados são reciclados: quando um item rola para fora da tela, o Recycler reutiliza sua visualização para novos itens que passarem a aparecer na tela. Isso melhora muito o desempenho, aperfeiçoando a capacidade de resposta do app e reduzindo o consumo de energia. Mais informações aqui: developer.android.

Implementar um Recycler View não é uma tarefa trivial: é necessário (1) primeiro decidir se será uma lista ou uma grade, (2) depois criar a aparência e o comportamento de cada elemento da lista, (3) estender da classe ViewHolder, responsável por fornecer todas as funcionalidades para os itens da lista, e por fim, (4) definir o Adapter que associa seus dados à visualização ViewHolder. Ufa, bastante coisa, não? Então vamos começar logo!

Para início de conversa, vamos fazer a nossa pokédex como uma lista por enquanto e definir como será a aparência dos itens. Sendo assim, criamos o layout pokemon_card.xml da seguinte forma:

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

    <ImageView
        android:id="@+id/pokemonImage"
        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"
        android:importantForAccessibility="no"
        tools:src="@drawable/bulbasaur"/>

    <TextView
        android:id="@+id/pokemonName"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toBottomOf="@id/pokemonImage"
        tools:text="Bulbasaur" />

    <TextView
        android:id="@+id/pokemonId"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="@id/pokemonName"
        app:layout_constraintEnd_toEndOf="@id/pokemonName"
        app:layout_constraintTop_toBottomOf="@id/pokemonName"
        tools:text="#001" />

    <TextView
        android:id="@+id/firstPokemonType"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="@id/pokemonName"
        app:layout_constraintEnd_toEndOf="@id/pokemonName"
        app:layout_constraintTop_toBottomOf="@id/pokemonId"
        tools:text="Grass" />

    <TextView
        android:id="@+id/secondPokemonType"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:visibility="gone"
        app:layout_constraintStart_toStartOf="@id/firstPokemonType"
        app:layout_constraintEnd_toEndOf="@id/firstPokemonType"
        app:layout_constraintTop_toBottomOf="@id/firstPokemonType"
        tools:text="Poison" />

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

É um layout bem simples: temos a imagem do pokémon, seu nome, seu id e seus tipos. Repare que o segundo tipo está gone como padrão, ou seja, não está na tela, faremos assim pois nem todo pokémon têm dois tipos. Com esse layout teremos esse resultado:

Preview do pokemon_card.xml

Em seguida vamos adicionar a ViewGroup do RecyclerView na nossa pokedex_activity_xml e informar que seu listitem é o layout que acabamos de construir:

<?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" />

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

Está feito, agora vamos criar nosso Adapter e por consequência estender e personalizar a ViewHolder, vamos chamar essa classe de PokedexAdapter:

package br.com.pokedex.presentation

import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import br.com.pokedex.databinding.PokemonCardBinding
import br.com.pokedex.model.SinglePokemon
import br.com.pokedex.util.showIf

class PokedexAdapter(
    private val context: Context,
    private val pokemon: List<SinglePokemon>
) : RecyclerView.Adapter<PokedexAdapter.PokemonViewHolder>() {

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

        fun bind(singlePokemon: SinglePokemon) {
            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())
            }
        }
    }

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

    override fun onBindViewHolder(
            holder: PokemonViewHolder, 
            position: Int
        ) {
        holder.bind(pokemon[position])
    }

    override fun getItemCount() = pokemon.size
}
Enter fullscreen mode Exit fullscreen mode

Ok, dessa vez o código é bem grande, vamos por partes. Primeiro de tudo, nossa classe estende de RecyclerView.Adapter e usa PokedexViewHolder para fazer o bind (ligação) de dados. Essa nossa classe tem duas propriedades: um context, que será usado para inflar o layout, e uma lista de pokémon, nossa pokédex.

Sendo assim, definimos nossa ViewHolder, que usa view binding para ter acesso às view do layout e tem todas as propriedades necessárias para representar um pokémon. Nossa ViewHolder também tem o método bind(), que serve justamente para ligar os dados da lista de pokémon com as views do layout. Repare que nele usamos uma função chamada showIf(), essa função serve para tornar um TextView visível de acordo com uma condição, nesse caso se o conteúdo não for uma string vazia. Segue o código contido no arquivo TextViewExt:

package br.com.pokedex.util

import android.widget.TextView

fun TextView.showIf(condition: Boolean) {
    if(condition) {
        visibility = TextView.VISIBLE
    }
}
Enter fullscreen mode Exit fullscreen mode

Note que temos três funções sobreescritas: onCreateViewHolder(), onBindViewHolder() e getItemCount(), esses métodos são o motor da RecyclerView. Abaixo seguem as funções de cada uma de acordo com o site oficial dos desenvolvedores android:

  1. onCreateViewHolder(): RecyclerView chama esse método sempre que precisa criar um novo ViewHolder. O método cria e inicializa o ViewHolder e a View associada, mas não preenche o conteúdo da visualização. O ViewHolderainda não foi vinculado a dados específicos.
  2. onBindViewHolder(): RecyclerView chama esse método para associar um ViewHolder aos dados. O método busca os dados apropriados e usa esses dados para preencher o layout do fixador de visualização. Por exemplo, se a RecyclerView exibir uma lista de nomes, o método poderá encontrar o nome apropriado na lista e preencher o widget TextViewdo fixador de visualização. No nosso caso, os dados preenchidos são de um pokémon específico.
  3. getItemCount(): a RecyclerView chama esse método para ver o tamanho do conjunto de dados. Por exemplo, em um app de lista de endereços, pode ser o número total de endereços. O RecyclerView usa essa função para determinar quando não há mais itens a serem exibidos.

Ufa, finalmente terminamos nosso adapter, agora vamos configurar a RecyclerView na nossa activity. Para isso basta que definamos seu layout manager e o adapter que acabamos de construir. O layout manager por enquanto será o LinearLayoutManager, visto que vamos exibir uma lista. O código que configura a RecyclerView está contido na função setUpPokedexRecyclerView():

package br.com.pokedex.presentation

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import br.com.pokedex.databinding.ActivityPokedexBinding
import br.com.pokedex.model.SinglePokemon

class PokedexActivity: AppCompatActivity() {

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

    private lateinit var viewModel: PokedexViewModel

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

        viewModel = ViewModelProvider(this)[PokedexViewModel::class.java]

        viewModel.getPokemon()

        viewModel.pokemon.observe(this@PokedexActivity) { pokedex ->
            setUpPokedexRecyclerView(pokedex)
        }
    }

    private fun setUpPokedexRecyclerView(pokedex: List<SinglePokemon>?) {
        pokedex?.let { pokemonList ->
            binding.pokedexRecyclerView.apply {
                layoutManager = LinearLayoutManager(context)
                adapter = PokedexAdapter(context, pokemonList)
            }
        }
    }

} 
Enter fullscreen mode Exit fullscreen mode

Com isso já estamos quase acabando nosso trabalho de hoje, falta apenas exibirmos as imagens dos nossos monstrinhos com o Coil.

O que é Coil?

Coil é uma biblioteca de carregamento de imagens construída com Kotlin Coroutines. Ela é rápida, leve (adiciona mais ou menos 2000 métodos para a APK, o que comparado ao Picasso e Glide é significantemente menor), fácil de usar e moderna (Coil foi feita em Kotlin e usa bibliotecas modernas como Coroutines, OkHttp, Okio and AndroidX Lifecycles.

E é por isso que vamos usá-la.

Curiosidade: Coil é o acrônimode Coroutine Image Loader

Mostrando os Pokémon com Coil

De início precisamos incluir o Coil como uma dependência no nosso arquivo gradle:

dependencies {
    ...

    // Coil
    implementation("io.coil-kt:coil:2.2.2")

    ...
}
Enter fullscreen mode Exit fullscreen mode

Após sincronizarmos nosso arquivo gradle, já temos acesso às funções do gradle. Fique tranquilo, carregar as imagens dos pokémon será a parte mais fácil de hoje, analise o código abaixo:

package br.com.pokedex.presentation

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

class PokedexAdapter(
    private val context: Context,
    private val pokemon: List<SinglePokemon>
) : RecyclerView.Adapter<PokedexAdapter.PokemonViewHolder>() {

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

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

    override fun onBindViewHolder(holder: PokemonViewHolder, position: Int) {
        holder.bind(pokemon[position])
    }

    override fun getItemCount() = pokemon.size
}
Enter fullscreen mode Exit fullscreen mode

Conseguiu notar o que mudou? Nós apenas tivemos que usar a função load() do Coil para carregar cada imagem dos nossos pokémon e extraímos esse código para a função loadPokemon() 😱

E pronto! Basta rodarmos o app para vermos o resultado:

pokedex

Incrível né? Nós finalmente temos uma pokédex minimamente visualizável, parabéns!

Próximos posts

Nossa pokédex, apesar de visualizável, ainda está bem feia e lenta, levou cerca de 20 segundos para todas as imagens dos monstrinhos de bolso serem carregadas. Nós próximos posts vamos melhorar sua performance e deixá-la mais bonita.

Link do repositório no github:

GitHub logo ronaldocoding / pokedex

A simple Pokédex

Pokédex

A simple Pokédex






Post anterior:

Próximo post:

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

Top comments (0)