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"
}
}
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"
}
}
}
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
)
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
)
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
)
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
:
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>
)
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
)
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()
)
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() = ""
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()
}
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()
)
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
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()
}
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
}
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>
É 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:
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>
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
}
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
}
}
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:
-
onCreateViewHolder()
:RecyclerView
chama esse método sempre que precisa criar um novoViewHolder
. O método cria e inicializa oViewHolder
e aView
associada, mas não preenche o conteúdo da visualização. OViewHolder
ainda não foi vinculado a dados específicos. -
onBindViewHolder()
:RecyclerView
chama esse método para associar umViewHolder
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 aRecyclerView
exibir uma lista de nomes, o método poderá encontrar o nome apropriado na lista e preencher o widgetTextView
do fixador de visualização. No nosso caso, os dados preenchidos são de um pokémon específico. -
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)
}
}
}
}
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")
...
}
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
}
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:
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:
Post anterior:
Próximo post:
Melhorando a arquitetura da Pokédex
Ronaldo Costa de Freitas ・ Nov 11 '22
Obrigado pela atenção e até a próxima!
Top comments (0)