Consumir APIs con Retrofit 2 es una tarea casi obligatoria para tener una app útil e incluso para buscar trabajo. Si a esto le sumas las corrutinas, obtienes la forma más actual de trabajar con APIs.

Este capítulo será la actualización del antiguo capítulo 20 donde consumíamos APIS con Retrofit 2 y Anko.

¿Qué es un API REST?

Podríamos definir API REST como un servicio que nos provee de las funciones que necesitamos para poder obtener información de un cliente externo, como por ejemplo, una base de datos alojada en cualquier parte del mundo desde dentro de nuestra propia aplicación.

Vamos a pensar en Instagram, una aplicación con millones de usuarios. Es inviable tener la información de cada usuario dentro de la aplicación ¿verdad? Pues para solventar el problema usan servicios API REST. Lo primero que hacemos al entrar en la app es un login, esto sería el primero de los servicios, pues nosotros le mandamos al servidor el usuario y contraseña y este nos devolvería la información que debemos mostrar en la app.

Disponemos de cuatro tipos distintos de peticiones como norma general.

  • Get: Son las peticiones más sencillas, solo nos devuelven información. Si necesitamos pasarle un parámetro a la petición será a través de la url. Es decir si por ejemplo tenemos que hacer una petición que depende de una id (ej la identificación del usuario) la url se formaría así https://ejemplo.com/informacion/1, siendo 1 el parámetro que le pasamos. El problema de esto es que es poco seguro para pasar información delicada.
  • Post: Similar a Get pero los parámetros no se pasan por url, por lo que es más seguro para mandar información.
  • Put: Se suele usar para crear la entidad, es decir, si pensamos en un servicio como el acceso a una base de datos, este crearía el usuario por ejemplo.
  • Delete: Sería el último de los cuatro que nos permitiría borrar los registros de la base de datos.

La información suele venir en dos formatos distintos, XML o JSON. Para no meternos mucho en el tema, solo hablaremos del JSON que es el formato más habitual y con el que nosotros vamos a trabajar.

Formato JSON

Json es un formato de texto simple, es el acrónimo de JavaScript Object Notation. Se trata de uno de los estándar para el traspaso de información entre plataformas, tiene una forma muy legible que nos permite entender su contenido sin problema. Un ejemplo sencillo sería este.

{
  "employees": {
    "employee": [
      {
        "id": "1",
        "firstName": "Tom",
        "lastName": "Cruise",
        "photo": "https://jsonformatter.org/img/tom-cruise.jpg"
      },
      {
        "id": "2",
        "firstName": "Maria",
        "lastName": "Sharapova",
        "photo": "https://jsonformatter.org/img/Maria-Sharapova.jpg"
      },
      {
        "id": "3",
        "firstName": "Robert",
        "lastName": "Downey Jr.",
        "photo": "https://jsonformatter.org/img/Robert-Downey-Jr.jpg"
      }
    ]
  }
}

Todo formato Json empieza y termina con llaves y tiene una clave-valor. La clave employees contiene a su vez una lista de employee (fijaros que en vez de llaves tiene corchetes), que este almacena idfirstNamelastNamephoto. Así podemos pasarnos gran cantidad de información de una plataforma a otra con unos estándar que nos ayudan a simplificar el proceso.

Si aún así se os complica la lectura de estos ficheros al principio, podemos hacer uso de multitud de webs que simplifican la forma de verlo, como por ejemplo JsonEditOnline.

Curso programación android en kotlin
Otras formas de leer un Json

Let’s code!

En este capítulo vamos a crear una app completa, se tratará de un buscador de razas de perros. Es decir, en el buscador vamos a poner una raza de perro (en ingles) y recuperaremos imágenes de dicha raza que mostraremos en un RecyclerView, es decir accederemos a internet para mostrar imágenes que nuestra app no tiene.

El API que utilizaremos en este capítulo será Dog API, es totalmente gratuita y tiene una documentación sencilla y práctica. Si vamos a documentación podemos ver todos los tipos de llamada que hay, a nosotros nos interesará uno solo By breed, es decir por raza.

La llamada será la siguiente:

https://dog.ceo/api/breed/hound/images

Fíjate que si la abres en el propio navegador te aparecerá un JSON similar a este

{
  "message": [
    "https:\/\/images.dog.ceo\/breeds\/hound-afghan\/n02088094_1003.jpg",
    "https:\/\/images.dog.ceo\/breeds\/hound-walker\/n02089867_1931.jpg",
    "https:\/\/images.dog.ceo\/breeds\/hound-walker\/n02089867_1965.jpg",
    "https:\/\/images.dog.ceo\/breeds\/hound-walker\/n02089867_1987.jpg",
    "https:\/\/images.dog.ceo\/breeds\/hound-walker\/n02089867_1988.jpg"
  ],
  "status": "success"
}

Y esto es básicamente lo que nuestra app hará. Accederá a internet, hará una petición similar a esta para recuperar la información y luego la modificará para que nuestra aplicación pueda entenderla y pintarla.

Lo primero que haremos será crear un proyecto nuevo llamado DogList.

Una vez tengamos nuestro proyecto creado tendremos que pedir el acceso a internet, ya que nuestra app se conectará para consumir la API que comentamos anteriormente.

Para ello vamos al fichero AndroidManifest.xml de nuestro proyecto y en la parte superior añadimos el permiso necesario.

    <uses-permission android:name="android.permission.INTERNET"/>

Si no sabes lo que acabo de hacer pásate por el capítulo 21, permisos en Android.

El siguiente paso será importar las librerías necesarias que usaremos en nuestra app. Como siempre todas estas librerías se añaden en el fichero build.gradle del módulo app, si tienes la vista en modo project la ruta será DogList>app>build.gradle

Las librerías que vamos a importar son las siguientes:

  • Picasso: Esta librería nos permitirá transformar esas urls en imágenes.
  • Retrofit 2: Librería encargada del consumo de las API.
  • Retrofit 2 Converter Gson: Esta herramienta será un complemento a la anterior y nos simplificará el proceso de pasar un JSON a una Data Class que es con lo que trabajaremos en nuestro proyecto.
  • Corrutinas: Entre otras muchas cosas nos permitirá hacer las peticiones de Retrofit en segundo plano para no bloquear la interfaz del usuario (lo explicaré más a fondo durante el artículo).

Como sabes las librerías se van actualizando, peor a día de hoy las últimas versiones son las que voy a poner aquí que son las mismas que las del capítulo en vídeo.

    implementation "com.squareup.picasso:picasso:2.71828"
    implementation "com.squareup.retrofit2:retrofit:2.9.0"
    implementation "com.squareup.retrofit2:converter-gson:2.9.0"
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.6'

Para terminar con este fichero dejaremos configurado ya el View Binding que como sabes es la forma más óptima para acceder a nuestras vistas desde las clases. En esto no perderé mucho tiempo ya que lo tienes todo explicado en el capítulo 29, View Binding.

Así que para ello dentro de la etiqueta android{} añadiremos el View Binding.

  buildFeatures{
        viewBinding = true
    }

Ya podemos sincronizar.

Ahora es el turno de ir a nuestro MainActivity y terminaremos de implementar el View Binding.

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
    }
    
}

Lo siguiente será crear la vista. Vamos a activity_main.xml y añadiremos dos componentes, un RecyclerView que ya lo conocemos y un SearchView, un componente muy sencillo que nos va a permitir implementar un buscador de una rápida.

<?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:id="@+id/viewRoot"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.appcompat.widget.SearchView
        android:id="@+id/svDogs"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintVertical_bias="0"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvDogs"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintTop_toBottomOf="@+id/svDogs"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

La vista es bastante sencilla, fíjate que está hecha con ConstraintLayouts, así que si no sabes como funcionan te recomiendo mirar este capítulo.

De Json a Data Class

Ya tenemos casi toda la parte visual de la app en un momentito, pero ahora tenemos que pasar a nuestro modelo de datos. Es decir, recuerda que la API nos devolverá un JSON y nosotros tenemos que convertirlo en una Data Class.

Para ello tenemos que fijarnos en la estructura del JSON de nuestra API, en este caso es bastante sencillo. Para empezar tenemos un campo message que básicamente es un listado de Strings, son urls de imágenes pero a los ojos de la app son solo texto y además tenemos el campo status que contendrá el valor success si ha ido todo bien así que será otro String.

Vamos a crear una nueva clase, en el mismo directorio donde tenemos el MainActivity hacemos clic derecho New> Kotlin File/Class y lo llamaremos DogsResponse. Antes de darle a enter asegúrate de seleccionar Class.

Creando nuestra Data Class.

Para convertir nuestra class en una data class solo tendremos que añadir la palabra reservada data antes de class y nos saldrá error porque habrá que quitar las llaves y deberá tener al menos un parámetro, en este caso le pondremos los dos que hemos mencionado anteriormente.

data class DogsResponse(var status: String, var message: List<String>)

Así quedaría nuestra clase, fíjate que el nombre de los campos es exactamente igual que el del JSON, es obligatorio para poder recuperar la información del JSON y pasarlo a nuestro modelo de datos.

Pero aquí veo un problema y es que este el nombre message no me gusta ya que no me parece descriptivo, pero como he comentado el nombre tiene que ser exactamente igual al del JSON. Obviamente hay una solución para este dilema y es usar la anotación SerializedName, esta anotación nos hará de «puente» entre el nombre obligatorio y el que queramos ponerle.

data class DogsResponse(
    @SerializedName("status") var status: String,
    @SerializedName("message") var images: List<String>
)

Fíjate lo que he hecho, antes de definir cada variable he usado @SerializedName y entre paréntesis he puesto el nombre exacto que aparece en el JSON, así que con eso ya podemos llamar a nuestra variable como queramos, en mi caso a message lo he llamado images. Esta anotación la pondremos siempre que trabajemos con Retrofit.

APIService

Lo siguiente que haremos será crear el contrato que defina la llamada de Retrofit, es decir, vamos a crear una interfaz que lo que hará será definir el tipo de consumo de API y lo que nos va a devolver.

Verás lo sencillo que es 🙂

Crearemos una interfaz del mismo modo que creamos una clase, New Kotlin File/Class, seleccionaremos interface y la llamaremos APIService.

En esta interfaz solo vamos a definir el método que usará Retrofit y su configuración.

interface APIService {
    @GET
    suspend fun getDogsByBreeds(@Url url:String):Response<DogsResponse>
}

Analicemos el código anterior, para empezar vamos a seleccionar el tipo de llamada que es (recuerda al principio del capítulo que habían cuatro tipos GET, POST, PUT y DELETE). En este caso será de tipo GET y para ello usamos la etiqueta @GET.

Lo siguiente extraño que habrás visto es que antes de la palabra fun hay una nueva palabra reservada llamada suspend, esta será necesaria para trabajar con corrutinas, es decir, siempre que queramos hacer llamadas en segundo plano usando corrutinas tendremos que añadirla para que funcione nuestro código.

Antes de seguir quiero que recuerdes la url del API que vamos a usar.

https://dog.ceo/api/breed/hound/images

Si nos fijamos un poco más podemos ver que hay dos partes en esta url, la primera sería https://dog.ceo/api/breed/ que es la parte inmutable, es decir, esta parte será siempre fija. Luego hay que pasarle raza de perro seguido de /images, por lo que podríamos decir que hound/images es la parte mutable. ¿Porqué hago hincapié en esto? Pues porque la segunda parte de la url «raza/images» la tendremos que pasar como parámetro, y aunque sea una String tenemos que ponerle la etiqueta @Url al principio. Si no te ha quedado claro echa un vistazo al vídeo del capítulo donde creo que se entiende algo mejor.

Ya entendemos casi toda la función que hemos escrito, nos falta la respuesta. Todo lo que venga de Retrofit lo capturaremos a través de la clase Response es por ello que siempre devolveremos un Response<NuestroModeloDeDatos>, en este caso Response<DogsResponse>

Y ya tenemos explicada la función de nuestra interfaz. Como ya he dicho por escrito parece un poco complicado así que te animo a ver el vídeo (y suscribirte).

Creando instancia de Retrofit 2

Ya lo complicado ha terminado, pero obviamente todavía no estamos usando Retrofit, así que para ello volvemos a nuestro MainActivity. Esta instancia de Retrofit que vamos a crear será la que tenga el resto de la url del endpoint, se encargará de convertir el JSON a DogResponse y tendrá toda la configuración para hacer la llamada del API.

    private fun getRetrofit():Retrofit{
        return Retrofit.Builder()
            .baseUrl("https://dog.ceo/api/breed/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

Nuestra función getRetrofit() retornará una instancia de Retrofit. Para configurarlo fíjate que llamamos a la función .Builder() y una vez lo hayamos hecho le añadiremos la baseUrl (que es la parte fija de nuestra API) y luego añadimos .addConverterFactory(GsonConverterFactory.create(), esta línea implementará la librería del principio que hará todo el trabajo de recuperar el JSON y pasarlo a DogsResponse. Para que todo esto se aplique terminamos con .build()

IMPORTANTE: la baseURL siempre tiene que terminar con una barra horizontal «/»

Manejo de hilos con corrutinas

Antes de continuar te quiero hablar de los hilos. Cada uno de los procesos que se ejecutan en nuestra app se hacen en hilos, es decir, un conjunto de procesos que tienen su consumo de memoria y realizan X operación.

Es importante saber esto porque toda la parte visual de Android, es decir, todos los componentes, interacciones y demás se ejecutan en el hilo principal, por lo que debemos intentar realizar todas las operaciones largas o potentes fuera de dicho hilo para no bloquear la interfaz del usuario.

Si hacemos una llamada a internet será no instantáneo, ya que tenemos que acceder a la api y dependiendo de nuestra velocidad de internet tardará más o menos y es por ello que debemos realizar esta operación en otro hilo asíncrono, es decir, nosotros haremos la petición a nuestra API y esa lógica la haremos en un proceso fuera del hilo principal y cuando se haya terminado nos avisará. Y para hacer todo esto usaremos las corrutinas que añadimos al principio del capítulo.

Ahora crearemos una función que recibirá una String por parámetro, ya que al final de la app esta función será llamada por el buscador y le mandará la raza de perro que ha buscado.

Lo primero que hacemos dentro del método es llamar a CoroutineScope(Dispatchers.IO).launch{} esto hará que todo lo que esté dentro de esas llaves de genere en un hilo asíncrono.

   private fun searchByName(query:String){
        CoroutineScope(Dispatchers.IO).launch {
        }
    }

Lo siguiente es un poco extraño, dentro de nuestra corrutina, creamos una variable llamada call que será la encargada de llamar al método que nos devuelve Retrofit (el que creamos antes) y a esa instancia de Retrofit tendremos que llamar a la función create(), la cual recibirá la interfaz que queramos (podemos tener varias interfaces para retrofit) y al hacer eso nos permitirá llamar a la función getDogsByBreeds().

  private fun searchByName(query:String){
        CoroutineScope(Dispatchers.IO).launch {
            val call = getRetrofit().create(APIService::class.java).getDogsByBreeds("$query/images")
        }
    }

Fíjate el texto que le estoy pasando a la función getDogsByBreeds(), si te fijas anteriormente al crear Retrofit pusimos la ruta fija de la API, pero nos faltaba «razaPerro/images» eso es justo lo que le mandamos, la variable query contendrá la raza y tenemos que juntarlo con la parte final de la llamada /images.

Ahora nuestra variable call contendrá un Response<DogsResponse>, fíjate que es como nuestra data class pero con un Response por fuera, ya que es lo que devolvía nuestro método en la interfaz de Retrofit.

Este objeto response puede llegar a ser muy útil, ya que llamando a call.isSuccessful() nos dirá si la llamada ha ido bien, y luego para recuperar el objeto real de DogsResponse solo habría que llamar al call.body().

    private fun searchByName(query:String){
        CoroutineScope(Dispatchers.IO).launch {
            val call = getRetrofit().create(APIService::class.java).getDogsByBreeds("$query/images")
            val puppies = call.body()
                if(call.isSuccessful){
                   //show Recyclerview
                }else{
                    //show error
                }
            }
        }
    }

Nuestra función quedaría así por ahora, lo único que he añadido desde que mostré el código es la creación de la variable puppies que debería contener nuestro DogsResponse y un if sin terminar que comprobará si la llamada ha funcionado correctamente. Si ha ido bien crearemos un RecyclerView, sino mostraremos un error.

RecyclerView

Ya empezamos a ver el final de la app, en este momento empezaremos a crear nuestro RecyclerView, iremos bastante rápido porque es lo que más hemos hecho en el blog, si todavía no has trabajado con RecyclerView echa un vistazo al capítulo 15.

Como ya sabes un RecyclerView consta de 3 partes, el Adapter, la celda y el ViewHolder, lo primero que haremos será crear la celda, es decir, el diseño. Dentro de res>layout crearemos un layout llamado item_dog.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    app:cardCornerRadius="16dp"
    android:background="@color/black"
    android:layout_margin="16dp"
    android:layout_height="320dp">

    <ImageView
        android:id="@+id/ivDog"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scaleType="centerCrop"/>

</androidx.cardview.widget.CardView>

Este diseño es muy sencillo, consta de una cardView, que no es más que un componente que nos permite crear una especie de tarjetas de bordes redondeados con sombra y en su interior un ImageView que será donde carguemos la URL de los perros.

Una vez terminado es paso del ViewHolder, crearemos una nueva clase y la llamaremos DogViewHolder.

class DogViewHolder(view: View):RecyclerView.ViewHolder(view) {

    private val binding = ItemDogBinding.bind(view)

    fun bind(image:String){
        Picasso.get().load(image).into(binding.ivDog)
    }
}

Esta clase es muy sencilla, lo único que tiene es una función bind() que se llamará desde el adapter y le pasará una url en formato String, una vez dentro usaremos la librería de Picasso para cargar esa URL en nuestro iVDog.

Ya solo nos quedaría crear un simple adapter, que recibirá un listado de imágenes (las fotos de los perros). Lo llamaremos DogAdapter.

class DogAdapter(private val images: List<String>) : RecyclerView.Adapter<DogViewHolder>() {
    
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DogViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        return DogViewHolder(layoutInflater.inflate(R.layout.item_dog, parent, false))
    }

    override fun getItemCount(): Int = images.size


    override fun onBindViewHolder(holder: DogViewHolder, position: Int) {
        val item = images[position]
        holder.bind(item)
    }
}

Volveremos a nuestro MainActivity para terminar de configurar el RecyclerView, pero antes quiero que te fijes en que en el momento de crear la activity no tenemos imágenes pero para crear nuestro adapter necesitamos pasarle un listado de imagenes, además dicho listado tiene que ir variando al cambiar la búsqueda, por lo que los datos del RecyclerView tienen que poder variar. Para ello vamos a la parte superior de la clase (fuera de cualquier método) y crearemos un adapter con la función lateinit es decir, que lo inicializaremos más tarde, también crearemos una variable llamada dogImages que será una mutableList de Strings.

private lateinit var adapter: DogAdapter
private val dogImages = mutableListOf<String>()

Este listado de Strings lo hemos creado por dos motivos, el primero, empieza siendo una lista vacía y es el que usaremos para crear nuestro RecyclerView, pero modificaremos los items una vez el usuario haya buscado una raza de perro y así podremos cambiar las imágenes que se muestran en el listado.

Ahora ya podemos crear nuestra función initRecyclerView().

 private fun initRecyclerView() {
        adapter = DogAdapter(dogImages)
        binding.rvDogs.layoutManager = LinearLayoutManager(this)
        binding.rvDogs.adapter = adapter
    }

Este método se llamará en la función onCreate().

  override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        initRecyclerView()
    }

Aunque ya tenemos el RecyclerView creado todavía no le añadimos datos en ningún momento. Volvemos al método searchByName() que habíamos dejado a medias.

Cuando paramos nos quedaba completar el if(), ya que si todo había ido bien teníamos que actualizar las imágenes y si había algo mal había que mostrar un Toast. Pero si lo piensas bien ambas acciones son visuales, es decir, son de interfaz y eso significa que hay que hacerlo en el hilo principal y estamos dentro de una corrutina. Para poder salir de esa corrutina utilizaremos runOnUiThread{} y todo lo que esté entre esas llaves se hará en el hilo principal aunque esté dentro de una corrutina.

Añadimos el código anterior y metemos el if dentro de este.

 private fun searchByName(query:String){
        CoroutineScope(Dispatchers.IO).launch {
            val call = getRetrofit().create(APIService::class.java).getDogsByBreeds("$query/images")
            val puppies = call.body()
            runOnUiThread {
                if(call.isSuccessful){
                   //show recyclerview
                }else{
                    //show error
                }
            }
        }
    }

Ahora rellenaremos el if, empezando si todo ha ido bien. Para poder actualizar el adapter tendremos que modificar el listado de imágenes que recibe la variable dogImages, así que lo que tenemos que hacer es añadir las nuevas imágenes en esa variable.

    val images = puppies?.images ?: emptyList()
    dogImages.clear()
    dogImages.addAll(images)
    adapter.notifyDataSetChanged()

La primera línea crea una variable nueva llamada images, las imágenes están dentro de la variable puppies y dicha variable es nullable, eso quiere decir que puede ser null y puede provocar un error, por ello tenemos que acceder a las imágenes con una interrogación primero, pues estamos diciendo que puede haber un listado de Strings o puede haber un null. Para solucionarlo utilizaremos el operador elvis ?: que actuará para controlar que sea null y si lo es devolverá una emptyList() así nuestra variable images puede ser un listado de strings o un listado vacío pero nunca será null.

Acto seguido llamamos a nuestra variable dogImages y la limpiamos para que no tenga ninguna imagen y ahora que está vacía utilizamos addAll() para ponerle todas las nuevas imágenes. Para que se recarguen las imágenes en el RecyclerView tendremos que llamar al adapter y su función notifyDataSetChanged().

Ahora nos queda llamar a la función showError() (que todavía no hemos creado) si en vez de entrar por el if entra por el else.

    private fun showError() {
        Toast.makeText(this, "Ha ocurrido un error", Toast.LENGTH_SHORT).show()
    }

Como ves se trata de un simple Toast por si fallase algo.

Nuestro método completo quedaría así.

   private fun searchByName(query:String){
        CoroutineScope(Dispatchers.IO).launch {
            val call = getRetrofit().create(APIService::class.java).getDogsByBreeds("$query/images")
            val puppies = call.body()
            runOnUiThread {
                if(call.isSuccessful){
                    val images = puppies?.images ?: emptyList()
                    dogImages.clear()
                    dogImages.addAll(images)
                    adapter.notifyDataSetChanged()
                }else{
                    showError()
                }
            }
        }
    }

Configurando nuestro buscador

Ya solo nos queda configurar el buscador para poder hacer funcionar toda la maquinaria.

¿Qué hacemos cuando queremos capturar que un botón ha sido pulsado? Le ponemos un listener ¿Verdad? Pues aquí haremos lo mismo de una forma similar.

En la primera línea del MainActivity, a continuación de AppCompatActivity() añadiremos el listener SearchView.OnQueryTextListener.

class MainActivity : AppCompatActivity(), SearchView.OnQueryTextListener{

Con esa línea le estamos diciendo que nuestra clase va a implementar los listeners del SearchView, pero como todavía no los hemos implementados nos saldrá un error en el inicio de la clase. Para solucionarlo tendremos que implementar dos métodos en nuestra clase.

    override fun onQueryTextChange(newText: String?): Boolean {
        return true
    }

Este es el primero de los métodos del buscador, nos avisará de cara carácter que se añada al buscador, pero nosotros no necesitamos eso, solo necesitamos que nos avise cuando el usuario haya terminado de escribir así que lo dejamos como está y no hacemos nada.

El siguiente método será bastante similar pero será llamado cuando el usuario pulse en enter al terminar de buscar y ahí es cuando tendremos el texto que hemos escrito y se lo pasaremos a retrofit para que haga la petición a internet.

   override fun onQueryTextSubmit(query: String?): Boolean {
        if(!query.isNullOrEmpty()){
            searchByName(query.toLowerCase())
        }
        return true
    }

Esta función devuelve un parámetro llamado query que es el texto que ha escrito el usuario y si te fijas también puede ser null, así que comprobamos si query es null o vacía y si no lo es llamaremos a searchByName() pasándole la búsqueda. Atento a que antes de pasarle el query le hago la función toLowerCase(), esta función pasa toda la String a minúscula por si nuestro API no sabe trabajar con mayúsculas.

Terminamos volviendo a la función onCreate() y a nuestro SearchView le implementamos este listener que hemos creado.

    binding.svDogs.setOnQueryTextListener(this)

Extra – Escondiendo el teclado

Si ejecutas la app ya va a funcionar, pero si me das un minuto más te enseñaré una función extra para dejar más bonita la app y es que al buscar en el buscador, una vez haya buscado no va a esconderse el teclado y visualmente queda bastante feo. Para ello simplemente añade esta función.

 private fun hideKeyboard() {
        val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
        imm.hideSoftInputFromWindow(binding.viewRoot.windowToken, 0)
    }

No entraré en detalle porque es más avanzado pero ahora podemos llamar a hideKeyboard() desde searchByName() al terminar el if.

    private fun searchByName(query:String){
        CoroutineScope(Dispatchers.IO).launch {
            val call = getRetrofit().create(APIService::class.java).getDogsByBreeds("$query/images")
            val puppies = call.body()
            runOnUiThread {
                if(call.isSuccessful){
                    val images = puppies?.images ?: emptyList()
                    dogImages.clear()
                    dogImages.addAll(images)
                    adapter.notifyDataSetChanged()
                }else{
                    showError()
                }
                hideKeyboard()
            }
        }
    }

Y ya tenemos nuestra aplicación completa.

Puedes descargar el proyecto entero desde Github.

Te recuerdo que puedes seguirme en mis redes sociales:

Y si tienes dudas con este o cualquier otro artículo del blog únete al Discord de la comunidad y te ayudaremos.