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.
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.
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.
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.
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ú:
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.
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.
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.
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:
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:
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.