Búsqueda Asistida Con SearchView En Android

El SearchView en Android es un widget que presenta una interfaz de usuario para realizar búsquedas en nuestros aplicativos.

Este permite al usuario introducir la consulta de búsqueda en forma de texto y confirmar su petición para obtener resultados.

Normalmente lo integrarás en la App Bar como un ítem de acción que se expande para mostrar el campo de texto de búsqueda.


Ejemplo De SearchView En Android

En este tutorial aprenderás a:

  • Incluir un SearchView en la app bar
  • Declarar una actividad que te asista en la búsqueda
  • Manejar eventos de búsqueda
  • Mostrar los resultados en un RecyclerView

Todo lo anterior será aplicado sobre el siguiente aplicativo de ilustración:

Este ejemplo se basa en un dominio sobre el manejo de gastos personales del usuario. Por lo que tendremos una lista de los mismos para aplicarles la búsqueda por la descripción. Puedes descargar el proyecto Android Studio desde aquí:

Por simplicidad mantendremos el origen de los datos en memoria con el fin de enfocarnos en la receta de la implementación de la búsqueda.

A continuación te mostraré los pasos necesarios. Parto del hecho de que ya tienes un proyecto Android Studio creado, al cual deseas añadirle búsqueda (en mi caso creé uno con la plantilla Empty Activity).


1. Crear Lista Con RecyclerView

En primer lugar crearemos la lista de los gastos para presentar los resultados de la búsqueda que deseamos implementar.

Lista de gastos en Android

Paso 1. Nuestro ejemplo usa una actividad llamada MainActivity la cual tiene el layout activity_main.xml asociado. Por lo que añadiremos un RecyclerView con LinearLayoutManager en su definición:

<?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"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:clipToPadding="false"
        android:paddingVertical="8dp"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        tools:listitem="@layout/expense_item"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Paso 2. Ahora crea el layout del ítem para la lista de gastos. El diseño lleva dos líneas verticales para el nombre del gasto y la fecha en la que ocurrió. Adicional, posee el dato del total en el costado derecho.

Diseño de layout para ítem de gasto

Si usas un ConstraintLayout, obtendrás un diseño XML similar al siguiente:

<?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="64dp">

    <TextView
        android:id="@+id/description"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:maxLines="1"
        android:paddingHorizontal="16dp"
        android:paddingTop="16dp"
        android:textAppearance="?attr/textAppearanceSubtitle1"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/total"
        app:layout_constraintHorizontal_bias="1.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.0"
        tools:text="@tools:sample/lorem/random" />

    <TextView
        android:id="@+id/date"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:maxLines="1"
        android:paddingHorizontal="16dp"
        android:textAppearance="?attr/textAppearanceBody2"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/total"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/description"
        app:layout_constraintVertical_bias="0.0"
        tools:text="@tools:sample/lorem/random" />

    <TextView
        android:id="@+id/total"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:paddingHorizontal="16dp"
        android:paddingTop="18dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.5"
        app:layout_constraintStart_toEndOf="@+id/description"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.0"
        tools:text="$20" />
</androidx.constraintlayout.widget.ConstraintLayout>

Paso 3. Lo siguiente es crear nuestro modelo a presentar en la lista. Es decir, la clase para gastos llamada Expense:

data class Expense(
    val id: Int,
    val description: String,
    val date: String,
    val total: Double
)

Paso 4. Ahora crea un adaptador de lista llamado ExpenseAdapter del tipo ListAdapter, crea el correspondiente ExpenseViewHolder e implementa el inflado y binding:

class ExpenseAdapter :
    ListAdapter<Expense, ExpenseAdapter.ExpenseViewHolder>(ExpenseDiffCallback()) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ExpenseViewHolder {
        val view = LayoutInflater
            .from(parent.context)
            .inflate(R.layout.expense_item, parent, false)
        return ExpenseViewHolder(view)
    }

    override fun onBindViewHolder(holder: ExpenseViewHolder, position: Int) {
        val expense = getItem(position)
        holder.bind(expense)
    }

    class ExpenseViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        private val description: TextView = view.findViewById(R.id.description)
        private val date: TextView = view.findViewById(R.id.date)
        private val total: TextView = view.findViewById(R.id.total)
        fun bind(expense: Expense) {
            description.text = expense.description
            date.text = expense.date
            total.text = NumberFormat.getCurrencyInstance().format(expense.total)
        }
    }
}

class ExpenseDiffCallback : DiffUtil.ItemCallback<Expense>() {
    override fun areItemsTheSame(oldItem: Expense, newItem: Expense): Boolean {
        return oldItem.id == newItem.id
    }
    override fun areContentsTheSame(oldItem: Expense, newItem: Expense): Boolean {
        return oldItem == newItem
    }
}

Paso 5. Aislaremos la lectura de los gastos en un patrón repository como declaración de objeto. Esto con el fin de desacoplar esta responsabilidad.

Como había mencionado, usaremos una lista en memoria para los datos, por lo que podemos inicializar un ejemplar mutable en el mismo repositorio:

object ExpenseRepository {

    private val expenses: MutableList<Expense>

    init {
        expenses = mutableListOf(
            Expense(1, "Salida a cine", "09/06/2021", 15.0),
            Expense(2, "Compra de viveres", "08/06/2021", 100.0),
            Expense(3, "Pago de arriendo", "07/06/2021", 150.0),
            Expense(4, "Pago de servicios públicos", "06/06/2021", 55.0),
            Expense(5, "Un mecatico", "06/06/2021", 2.0),
            Expense(6, "Netflix Junio", "06/06/2021", 10.0),
            Expense(7, "Compra de steam", "01/06/2021", 15.0),
            Expense(8, "Compra de viveres en la 14", "01/06/2021", 40.0),
            Expense(9, "Pago mensual de correo", "25/05/2021", 5.0),
            Expense(10, "Salida con amigos", "20/05/2021", 70.0)
        )
    }

    fun getAll(): List<Expense> = expenses
}

Paso 6. Inicializa la lista junto al adaptador en onCreate() de la actividad. Luego usa submitList() para mostrar el estado inicial de los gastos en la pantalla:

class MainActivity : AppCompatActivity() {

    private lateinit var expenseList: RecyclerView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        setUpList()
    }

    private fun setUpList() {
        expenseList = findViewById(R.id.list)

        val adapter = ExpenseAdapter()
        expenseList.adapter = adapter

        adapter.submitList(ExpenseRepository.getAll())
    }    
}

Hasta aquí, si compilas el aplicativo, podrás ver la lista con los datos de ejemplo.

Ahora procedamos a aplicar los pasos para incluir la búsqueda sobre esta.


2. Añadir Ítem De Búsqueda

Ahora bien, añade un nuevo recurso de menú al proyecto con el fin de añadir el ítem de búsqueda a la app bar.

Menú con SearchView en Android Studio

Recuerda que solo das click derecho en tu proyecto y seleccionas New>Android Resource File. Luego seleccionas nombra al menú como main_menu.xml y confirmas.

Crear recurso de menú para búsqueda de gastos

Aunque puedes añadir la etiqueta <item> manualmente, en la pestaña de diseño de Android Studio podrás ver al Search Item ya configurado. Puedes presionar Enter sobre el widget o arrastrarlo al archivo de menú:

Search Item en Android Studio

La definición XML sería la siguiente:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <item
        android:id="@+id/search"
        android:icon="@drawable/ic_search_black_24dp"
        android:title="@string/search"
        app:actionViewClass="androidx.appcompat.widget.SearchView"
        app:showAsAction="ifRoom|collapseActionView" />
</menu>

Como vimos en el tutorial de recursos de menú, el atributo android:actionViewClass referencia la clase del widget que actuará como opción en la app bar. Y android:showAsAction le permite ampliar su contenido si es posible con collapseActionView.


3. Crear Configuración De Búsqueda

El archivo de configuración de búsqueda es un recurso XML donde declaras el comportamiento y características activas para tu SearchView. El sistema usará este archivo para aplicar sus propiedades sobre la búsqueda que asistirá.

Propiedades como búsqueda por voz, sugerencias, tipo de entrada de texto, etiquetas, etc.

Creación de archivo searchable.xml en Android Studio

Teniendo en cuenta lo anterior, crea un nuevo archivo en los recursos res/xml con el nombre de searchable.xml y añade la siguiente definición base:

<?xml version="1.0" encoding="utf-8"?>
<searchable xmlns:android="http://schemas.android.com/apk/res/android"
    android:hint="@string/search_hint"
    android:label="@string/app_name" />

El atributo android:label se refiere al nombre de tu app y android:hint es el texto que se muestra en la barra de búsqueda cuando el usuario no ha tipeado texto.


4. Definir Una Actividad De Búsqueda

Tu actividad de búsqueda será el componente que se iniciará cuando el usuario solicite buscar información.

HomeActivity inicia a SearchActivity pasando a «texto» en el Intent

En el momento en que el usuario confirma la petición de búsqueda, el sistema crea un Intent con la acción ACTION_SEARCH y un extra con clave QUERY, el cual contiene el String con el texto de la consulta.

Con este en mente, determina o crea la actividad de tu proyecto que servirá a la búsqueda y añade el siguiente meta dato en el AndroidManifest.xml:

<activity android:name=".ResultsActivity" android:parentActivityName=".MainActivity">
    <meta-data
        android:name="android.app.searchable"
        android:resource="@xml/searchable" />
    <intent-filter>
        <action android:name="android.intent.action.SEARCH" />
    </intent-filter>
</activity>

Como ves, hemos creado un intent filter para permitir que ResultsActivity perciba intents con la acción android.intent.action.SEARCH.

Además pasamos a la configuración de búsqueda que creamos como datos descriptivos en la comunicación.


5. Habilitar Búsqueda Asistida En SearchView

El siguiente paso es vincular al SearchView al servicio de búsqueda del sistema a través del archivo que creamos para configurar la búsqueda:

override fun onCreateOptionsMenu(menu: Menu): Boolean {
    menuInflater.inflate(R.menu.expenses_menu, menu)

    val sm = getSystemService(Context.SEARCH_SERVICE) as SearchManager

    val searchView = menu.findItem(R.id.search).actionView as SearchView

    searchView.setSearchableInfo(
        sm.getSearchableInfo(
            ComponentName(this, ResultsActivity::class.java)
        )
    )
    return true
}

Como ves, esta tarea consiste en obtener el ítem de búsqueda a partir del método Menu.findItem() y luego pasarle la configuración de búsqueda de searchable.xml.

Obtener esta configuración se realiza con la clase SearchManager, la cual representa las funciones de búsqueda de Android.

Donde el resultado de su método getSearchableInfo() debe ser asignado en la propiedad searchableInfo del SearchView para completar el empalme. La instancia ComponentName permitirá obtener el contenido de R.xml.searchable.


6. Manejar Búsqueda

Una vez es iniciada la actividad de búsqueda obtenemos el extra que viene a través del Intent desde onCreate():

class ResultsActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        if (Intent.ACTION_SEARCH == intent.action) {
            intent.getStringExtra(SearchManager.QUERY)?.also { query ->
                // Buscar datos
                searchExpenses(query)
            }
        }
    }

    private fun searchExpenses(query: String) {
        // Filtrar en repositorio y plasmar resultados en lista
    }
}

Ya sabemos que la obtención de extras se basa en usar los métodos get*Extra() junto a la clave que lo identifica. En este caso es QUERY.

Una vez obtenido el valor de la consulta, entonces buscamos en la fuente de datos y luego actualizamos la lista con searchExpenses().


7. Buscar Datos

Este paso y el siguiente ya te compete a ti. Android solo nos asiste en el proceso de configuración, manejo y envío de la petición. Buscar los datos es responsabilidad de nuestro diseño.

Por lo cual, le asignaremos esta responsabilidad al repositorio de gastos, ya que es el encargado de consultar y operar nuestro esquema de datos en memoria.

Concretamente, añadiremos le añadiremos un nuevo método llamado search():

fun search(query: String): List<Expense> {
    if (query.isBlank())
        return emptyList()

    return expenses.filter { expense ->
        val regex = query.toRegex(RegexOption.IGNORE_CASE)
        regex.containsMatchIn(expense.description)
    }
}

Su objetivo es seleccionar y retornar a los gastos cuyo nombre coincida con la expresión regular construida a partir de la consulta de búsqueda.

Si el argumento query está en blanco, entonces retornamos una lista vacía.


8. Mostrar Resultados De Búsqueda

Mostrar los resultados que ha producido la búsqueda consiste en actualizar los datos que consume el adaptador de gastos para presentar el estado actual de gastos al usuario.

Resultados de búsqueda

Y como ya sabes, actualizar la lista de gastos es simplemente invocar a submitList() desde el adaptador con el resultado de la búsqueda desde el repositorio:

private fun searchExpenses(query: String) {
    val searchResults = ExpenseRepository.search(query)
    (expenseList.adapter as ExpenseAdapter).submitList(searchResults)
}

9. Buscar Y Mostrar Resultados En La Misma Actividad

Para terminar, veamos como dejar que el usuario busque datos y vea los resultados en la misma actividad:

Búsqueda en Android con singletTop

Buscar sin iniciar otra actividad es de utilidad cuando tu interfaz presenta un conjunto de datos homogéneos, los cuales no serán afectados si se presentan como resultado un subconjunto con la misma uniformidad.

Claramente este es el caso de nuestra lista de gastos. No nos veremos afectados ya que buscar no interfiere con algún estado que deseemos ver persistentemente.

Así que…

¿Cómo evitas la recreación de la actividad de búsqueda?

Usa la bandera singleTop que vimos en el tutorial de la pila de tareas con actividades. Si modificas el modo de lanzamiento android:launchMode con este valor, tan solo una instancia será creada en la back stack.

Para ello, ve al AndroidManifest.xml y añade el atributo:

<activity android:name=".MainActivity" android:launchMode="singleTop">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />

        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>

    <meta-data
        android:name="android.app.searchable"
        android:resource="@xml/searchable" />
    <intent-filter>
        <action android:name="android.intent.action.SEARCH" />
    </intent-filter>
</activity>

Recuerda que el controlador onNewIntent() se ejecuta cada que la actividad con singleTop se intenta iniciar.

En consecuencia, sobrescribimos este método para manejar la búsqueda así como lo hacemos en onCreate(). Con esto, nuestra actividad actualizará la lista de gastos cada que el usuario envíe una solicitud de búsqueda:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    setUpList()

    handleSearchIntent(intent)
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    handleSearchIntent(intent)
}

private fun handleSearchIntent(intent: Intent) {
    if (Intent.ACTION_SEARCH == intent.action) {
        intent.getStringExtra(SearchManager.QUERY)?.also { query ->
            // Buscar datos
            searchExpenses(query)
        }
    }
}

Claramente, para reusar el manejo del intent en ambos métodos, creamos el método handleSearchIntent().


10. Mostrar Resultados Al Cambiar La Búsqueda

En el caso de que desees actualizar la lista de gastos cada que el usuario cambie la consulta, necesitaras usar la interfaz SearchView.OnQueryTextListener.

Esta escucha posee dos controladores: onQueryTextChange() que es llamado cuando el usuario cambia el texto. Y onQueryTextSubmit() que se invoca cuando el usuario confirma la petición.

Ya que deseamos los resultados de búsqueda que coincidan, sobrescribiremos a onQueryTextChange() para llamar al repositorio y actualizar al adaptador:

searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
    override fun onQueryTextChange(newText: String?): Boolean {
        newText?.let {
            searchExpenses(it)
        }
        return true
    }

    override fun onQueryTextSubmit(query: String?): Boolean {
        return false
    }
})

11. Manejar Eventos De Inicio Y Cierre

Debido a que el view de acción asociado al SearchView es expandible, es posible responder a estos sucesos. Por ejemplo, si deseamos mostrar cero resultados al expandir o mostrarlos todos al contraer:

OnActionExpandListener en SearchView

Por supuesto existe una escucha llamada onActionExpandListener que te proveerá dos controladores para la expansión y la contracción:

menuItem.setOnActionExpandListener(object :
    MenuItem.OnActionExpandListener {
    override fun onMenuItemActionExpand(item: MenuItem?): Boolean {
        searchExpenses("")
        return true
    }

    override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
        val all = ExpenseRepository.getAll()
        (expenseList.adapter as ExpenseAdapter).submitList(all)
        return true
    }
})

//...
private fun setExpensesInitialState() {
    val all = ExpenseRepository.getAll()
    (expenseList.adapter as ExpenseAdapter).submitList(all)
}

En onMenuItemActionExpand() pasaremos un carácter vacío con el fin de no obtener ningún resultado al expandirse el elemento.

Luego retornamos al estado inicial la lista con onMenuItemActionCollapse(), lo que significa asignar el resultado de getAll() al adaptador.

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!