Sugerencias De Consultas Recientes En Android

Siguiendo con el apartado de búsquedas, verás que es posible desplegar una lista con sugerencias de consultas recientes en Android, a partir de los últimos términos que ha confirmado el usuario con el SearchView.

Aspecto de sugerencias de consultas recientes en el SearchView

Añadir esta característica permitirá evitarle al usuario escritura de texto, si es que desea realizar de nuevo alguna de sus búsquedas anteriores.


Ejemplo De Consultas Recientes En Android

En este tutorial aprenderás a crear una fuente de datos para guardar y limpiar sugerencias recientes en Android. Luego usarás el servicio de búsqueda del sistema para desplegar los resultados desde el SearchView:

Ejemplo de sugerencias de consultas recentes en Android

Es requisito que leas primero el tutorial sobre SearchView en Android, ya que usaremos la lista de gastos que construimos en él, junto a la implementación de su actividad de búsqueda.

Puedes descargar el proyecto en Android Studio desde el siguiente enlace. El módulo correspondiente se llama Sugerencias_Recientes:


Almacenar Consultas Como Sugerencias

Antes de comenzar con los pasos de implementación, analicemos un poco el flujo de eventos sobre los componentes. El siguiente es un diagrama que muestra de forma simple el recorrido que haremos sobre los componentes de nuestra App de gastos:

Como ves, la secuencia de pasos se resume en:

  1. El usuario expande el SearchView
  2. El sistema consulta las últimas consultas y se muestran en una lista
  3. Flujos:
    1. Uso de sugerencias
      1. El usuario hace click en una sugerencia
      2. El sistema realiza la búsqueda con esa consulta
      3. El sistema muestra los resultados
    2. Nueva búsqueda
      1. El usuario escribe una nueva consulta y la confirma
      2. El sistema almacena esa consulta en la tabla de sugerencias
      3. El sistema muestra los resultados

Analizando este algoritmo, sabemos que la mayoría de los eventos sobre la interfaz de búsqueda están controlados por el sistema.

Lo único que necesitamos añadir es una fuente de persistencia local que almacene a las consultas y las pueda limpiar si el usuario lo desea.

Con las anteriores responsabilidades y colaboraciones definidas, veamos cómo usar el framework para añadir sugerencias recientes a nuestra App de gastos.


1. Crear Persistencia Para Sugerencias

La primera herramienta de infraestructura que podría venirse a tu mente para guardar las consultas recientes podría ser la librería Room. La cual sería una buena opción si estuviésemos creando manualmente la interfaz de búsqueda.

Sin embargo, como estamos aprovechando la asistencia de los servicios de búsqueda de Android, usaremos una clase que nos facilita el trabajo: SearchRecentSuggestionsProvider.

Este Content Provider envuelve toda la lógica para crear una tabla en SQLite con el fin de almacenar registros con los términos de búsqueda recientes del usuario.

Crear Content Provider De Sugerencias

En concreto, debes crear una implementación que extienda de SearchRecentSuggestionsProvider. Para ello puedes hacer click derecho en tu paquete, selecciona New > Other >Content Provider y configura el nuevo provider:

Crear content provider de sugerencias recientes en Android Studio
Usa tu nombre de paquete en conjunto al del provider para URI Authorities

O simplemente crea una clase llamada ExpenseSuggestionsProvider como se ve en el siguiente código:

class ExpenseSuggestionsProvider : SearchRecentSuggestionsProvider() {

    init {
        setupSuggestions(AUTHORITY, MODE)
    }

    companion object {
        const val AUTHORITY = "com.develou.ExpenseSuggestionsProvider"
        const val MODE: Int = DATABASE_MODE_QUERIES
    }
}

Y luego registralo en el AndroidManifest.xml:

<application>
    <provider
        android:name=".ExpenseSuggestionsProvider"
        android:authorities="com.develou.ExpenseSuggestionsProvider"
        android:exported="false" />
    <!--...-->
</application>

Es vital hacer la llamada de setupSuggestions() en el bloque de inicialización para vincular el servicio de búsqueda con el esquema creado por el provider.

Los parámetros que reciben son la autoridad del provider y el modo. Por lo cual mantendremos sus valores en un objeto compañero.

Modo De La Base De Datos De Sugerencias

Dicho modo será DATABASE_MODE_QUERIES para configurar la base de datos para que almacene las consultas recientes.

companion object {
    const val AUTHORITY = "com.develou.ExpenseSuggestionsProvider"
    const val MODE: Int = DATABASE_MODE_QUERIES or DATABASE_MODE_2LINES
}

Adicionalmente puedes realizar una operación de bits para incluir el modo DATABASE_MODE_2LINES por si deseas añadir una segunda columna a la tabla interna de sugerencias, con el objetivo de sostener un texto de soporte para cada sugerencia.


2. Actualizar Archivo De Configuración De Búsqueda

El siguiente paso es decirle al servicio de búsqueda del sistema que nuestra búsqueda tendrá la característica sugerencias.

Esto se traduce en añadir nuevos atributos que soporten dicha configuración en tu archivo searchable.xml:

<?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"
    android:searchSuggestAuthority="com.develou.ExpenseSuggestionsProvider"
    android:searchSuggestSelection=" ?" />

En particular usaremos dos atributos que describen esta funcionalidad: android:searchSuggestAuthority y android:searchSuggestSelection.

El primero le indica al sistema que la actividad de búsqueda mostrará sugerencias asociadas con el content provider al que le pertenece la autoridad asignada.

Y el segundo representa la sentencia en la cláusula WHERE que use usa internamente al consultar sugerencias. Que como ves, usamos un espacio y el placeholder ? para indicar que usaremos como parámetro la entrada del usuario.


3. Insertar Sugerencias

Aunque podríamos insertar consultas a través del ContentResolver y el método insert(), el framework provee una clase auxiliar para cumplir esta tarea llamada SearchRecentSuggestions.

Esta clase de utilidad envuelve toda la lógica del comando de inserción con su método saveRecentQuery().

Pero… ¿Cuál es el lugar adecuado para invocarlo?

Lo haces justo en el momento en que el servicio de búsqueda confirmó el String enviado por el usuario, es decir, en el manejo del intent desde onCreate() o onNewIntent() en la actividad:

class MainActivity : AppCompatActivity() {
    //...

    private val suggestions = SearchRecentSuggestions(
        this,
        ExpenseSuggestionsProvider.AUTHORITY,
        ExpenseSuggestionsProvider.MODE
    )

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

        //...
    }

    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 ->
                suggestions.saveRecentQuery(query, null)
                searchExpenses(query)
            }
        }
    }

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

    //...
}

El constructor de SearchRecentSuggestions recibe al contexto y a la autoridad y modo del content provider. Argumentos que ya tenemos a la mano.

Si ejecutas el aplicativo hasta el momento, ya podrás ver como se insertan las consultas confirmadas y como se muestra el dropdown con sugerencias infladas desde el provider:

Guardar consultas recientes en ContentProvider

4. Personalización De Ítem De Sugerencias

Debido a que estamos delegando la gran mayoría de eventos y colaboraciones al sistema de búsqueda de Android, el ítem de las sugerencias por defecto que nos provee no tiene el aspecto visual que requerimos:

Ítem de sugerencia por defecto

Para que obtenga el aspecto que has vistos en las otras capturas, es necesario crear un layout que sea asignado desde al atributo searchViewStyle en el tema asociado a la App Bar. Por ejemplo:

<style name="Theme.SearchViewEnAndroid.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar">
    <item name="searchViewStyle">@style/SearchViewStyle</item>
</style>

Donde SearchViewStyle hereda de Widget.AppCompat.SearchView.ActionBar y usa como referencia al layout personalizado en el atributo suggestionRowLayout:

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="SearchViewStyle" parent="Widget.AppCompat.SearchView.ActionBar">
        <item name="suggestionRowLayout">@layout/suggestion_item</item>
    </style>

</resources>

¿Cómo construir el layout del ítem sin interferir con el código interno del adaptador de sugerencias?

Simplemente creas dos TextViews para el texto primario y el secundario que usen los recursos de ID @android:id/text1 y @android:id/text2. Además de un elemento ImageView de legado con el id @android:id/query_image.

Layout personalizado para ítems de sugerencias en el SearchView

Aunque puedes agregar más elementos de decoración. Sin embargo, procesar los eventos de nuevos elementos ya requeriría que realices la búsqueda manualmente, desvinculando al SearchView del servicio de búsqueda del sistema.

En particular, el layout anterior tendría la siguiente definición en el archivo res/layout/suggestion_item.xml:

<?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="56dp"
    android:background="@color/white"
    android:paddingHorizontal="16dp">

    <ImageView
        android:id="@+id/edit_query"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:visibility="gone" />

    <TextView
        android:id="@android:id/text1"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:maxLines="1"
        android:paddingHorizontal="32dp"
        android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
        android:textColor="@color/black"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/suggestion_icon"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="Sugerencia" />

    <TextView
        android:id="@android:id/text2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:maxLines="1"
        android:paddingHorizontal="32dp"
        android:textColor="@color/black"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_bias="0.975"
        app:layout_constraintStart_toEndOf="@+id/suggestion_icon"
        app:layout_constraintTop_toBottomOf="@android:id/text1" />

    <ImageView
        android:id="@+id/suggestion_icon"
        android:layout_width="@dimen/icon_size"
        android:layout_height="@dimen/icon_size"
        android:contentDescription="@string/suggestion_icon"
        android:src="@drawable/ic_suggestion"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

5. Limpiar Sugerencias

El altamente probable que el usuario desee eliminar el historial de sugerencias recientes que se le ofrecen para mantener su privacidad. Por lo cual debes proveer acceso a la acción de limpieza de las mismas:

Limpiar sugerencias con clearHistory()

La limpieza es similar al guardado, la clase SearchRecentSuggestions provee un método de utilidad llamado clearHistory(), por lo que tu trabajo es tan solo decidir donde invocarlo.

Como ilustración, lo haremos en nuestra App de gastos desde el menú de opciones en la app bar:

override fun onOptionsItemSelected(item: MenuItem): Boolean {
    if (item.itemId == R.id.clear_suggestions) {
        suggestions.clearHistory()
        return true
    }
    return super.onOptionsItemSelected(item)
}

Y como ya sabemos solo es manejar el evento de click desde el controlador onOptionsItemSelected() del ítem que fue inflado desde el recurso de menú en onCreateOptiosnMenu():

<?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="collapseActionView|always" />
    <item
        android:id="@+id/clear_suggestions"
        android:title="@string/clean_suggestions" />
</menu>

6. Manejar Eventos De Sugerencias

Probablemente surjan casos de uso donde requieras ejecutar tareas asociadas al click o selección de una sugerencia.

Por ejemplo, en ocasiones el framework ya no asigna la sugerencia seleccionada al campo de texto del SearchView, por lo que nos toca asignarlo en tiempo de ejecución.

Un buen lugar para ello, es asignando una escucha OnSuggestionsListener para intervenir el momento en que se hace click en una sugerencia:

searchView.setOnSuggestionListener(object : SearchView.OnSuggestionListener {
    override fun onSuggestionSelect(position: Int): Boolean {
        return false
    }

    override fun onSuggestionClick(position: Int): Boolean {
        val suggestion = getClickedSuggestion(searchView, position)
        searchView.setQuery(suggestion, false)
        return false
    }

})

Ambos controladores reciben el argumento de la posición en el adaptador de sugerencias que posee el AutoCompleteTextView del SearchView.

En el momento en onSuggestionClick() percibe que se hizo click en una sugerencia, aprovechamos para invocar a setQuery() para reemplazar el texto en el SearchView.

Pasamos false como segundo parámetro para no enviar la confirmación del cambio, ya que esta acción solo es cosmética.

Por otro lado, la obtención de la sugerencia a partir del adaptador lo logramos así:

private fun getClickedSuggestion(
    searchView: SearchView,
    position: Int
): String? {
    val suggestionCursor = searchView.suggestionsAdapter.getItem(position) as Cursor
    val suggestionIndex = suggestionCursor.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_1)
    return suggestionCursor.getString(suggestionIndex)
}

Si revisas la implementación de la clase SuggestionsAdapter asociado al SearchView, verás que es del tipo CursorAdapter. Por esta razón accedemos con la interfaz de Cursor para obtener el valor asociado a la columna SearchManager.SUGGEST_COLUMN_TEXT_1.

Nota: Usa la propiedad SearchView.suggestionAdapter para asignar tu propio adaptador para alimentar las sugerencias de búsqueda. Claramente tendrás que personalizar todo el ciclo para el procesamiento de la búsqueda, pero lograrás expandir sus comportamientos y estilos a lo que realmente necesitas.

Ú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!