Búsqueda Con Sugerencias Personalizadas En Android

En el tutorial anterior viste como añadir sugerencias de consultas recientes y ahora es el turno de añadir a tus búsquedas sugerencias personalizadas en Android.

Apariencia de sugerencias personalizadas en Android

Este tipo de sugerencias las generas tú a partir de los datos en tu infraestructura. De forma que implementas una lógica personalizada para presentar registros que pueden ser de utilidad al usuario.

Normalmente los resultados serán elementos que coinciden alfabéticamente con el texto de consulta que ha escrito el usuario en el SearchView.


Ejemplo De Sugerencias Personalizadas En Android

En este tutorial aprenderás a construir y mostrar sugerencias personalizadas en Android a partir de un ContentProvider y la asistencia del servicio de búsqueda del sistema.

Ejemplo de búsqueda con sugerencias personalizadas en Android

La receta de pasos es similar al que vimos en las sugerencias de consultas recientes. Modificamos nuestro archivo de configuración, creamos el content provider e integramos la lógica de búsqueda en los datos. Claramente es requisito haber leído dicho tutorial para mayor claridad y contexto.

Puedes descargar el proyecto Android Studio de este tutorial desde el siguiente enlace. Dirígete al módulo Sugerencias_Personalizadas para encontrar el código final:


Sugerencias Personalizadas

A continuación describiré los pasos que la documentación nos traza para llegar a mostrar las sugerencias personalizas en nuestra búsqueda:

  1. Modificar el archivo de configuración de búsqueda searchable.xml para indicarle el content provider que entregará las sugerencias
  2. Determinar fuente de datos (SQLite, server, memoria, archivo, etc.) que sirva como origen de las sugerencias y darle un formato para que sus atributos coincidan con los requisitos del servicio de búsqueda del sistema
  3. Crear Content Provider que retorne los datos de las sugerencias
  4. Manejar el click que realiza el usuario sobre una sugerencia personalizada

Por simplicidad, usaremos seguiremos usando nuestro repositorio ExpensesRepository para crear el match entre la consulta del usuario y la propiedad Expense.description.

Además crearemos instancias de la clase MatrixCursor para adaptar los resultados hacia el método query() del repositorio.

De esta forma el servicio de búsqueda de Android presenta las sugerencias a medida que el usuario tipea caracteres en el campo de texto, ejecutando la lógica de coincidencia en cada cambio.

Así que veamos como actualizar nuestro proyecto de gastos con la implementación mencionada.


1. Modificar Configuración De Búsqueda

Como vimos en la configuración de búsqueda de consultas recientes, el archivo xml/seachable.xml era modificado para añadir al atributo android:searchSuggestAuthority, con el objetivo de especificar el proveedor de contenido que sería usado para obtener los resultados de sugerencias.

Para mostrar sugerencias personalizadas esta definición no cambia. Usamos el mismo atributo con el mismo content provider ExpenseSuggestionsProvider.

<?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=" ?" />

Sin embargo, lo que si cambiará será la implementación del content provider de la siguiente manera.


2. Crear Content Provider De Sugerencias Personalizadas

Seguidamente, abre ExpenseSuggestionsProvider.kt y modifica su clase padre SearchRecentSuggestionsProvider por ContentProvider. Luego implementa los métodos abstractos:

class ExpenseSuggestionsProvider : ContentProvider() {
    
    override fun onCreate(): Boolean = true

    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        TODO("Not yet implemented")
    }

    override fun getType(uri: Uri): String? {
        TODO("Not yet implemented")
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        TODO("Not yet implemented")
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        TODO("Not yet implemented")
    }

    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        TODO("Not yet implemented")
    }
}

Ahora bien, vinculemos el content provider con el sistema de búsqueda:

Paso 1: En primer lugar, declara la URI que servirá de identificador para el recurso de las sugerencias.

Para ello crea un objeto UriMatcher con el fin de registrar una nueva URI formada con la autoridad del content provider y el segmento SearchManager.SUGGEST_URI_PATH_QUERY:

class ExpenseSuggestionsProvider : ContentProvider() {

    private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI(BuildConfig.AUTHORITY, SearchManager.SUGGEST_URI_PATH_QUERY, SEARCH_SUGGEST)
    }

    //..

    companion object {
        private const val SEARCH_SUGGEST = 1
    }
}

Las URIs con este segmento son filtradas por el sistema para redireccionar los resultados de sugerencias a nuestro SearchView. Adicionalmente las identificaremos con la constante SEARCH_SUGGEST al interior de un objeto compañero.

Paso 2: Luego sobrescribe el método query() para retornar el Cursor asociado a la consulta del usuario.

El sistema invoca a query() pasando el String de consulta en el argumento selectionArgs. Sabiendo esto, podemos acceder al elemento con una operación de acceso get(0) o la función de extensión first():

override fun query(
    uri: Uri,
    projection: Array<out String>?,
    selection: String?,
    selectionArgs: Array<out String>?,
    sortOrder: String?
): Cursor? {
    when (uriMatcher.match(uri)) {
        SEARCH_SUGGEST -> {
            val query = selectionArgs?.first() ?: ""
            return prepareSuggestions(query)
        }
        else -> throw IllegalArgumentException("La uri $uri no se reconoce")
    }
}

Recuerda que UriMatcher.match() compara la URI entrante versus los elementos registrados con addUri() en su configuración. El resultado será el entero que identifica a la URI por lo que la expresión when nos viene genial para procesar las opciones.

Paso 3: Ahora bien, ¿cómo crear un cursor si no tenemos una base de datos Room?

La respuesta es: la clase MatrixCursor. Esta es la representación de un cursor mutable, al cual puedes añadirle filas a partir de su método addRow().

private fun prepareSuggestions(query: String): Cursor {
    val suggestions = ExpenseRepository.search(query)
    val columnNames = arrayOf(
        BaseColumns._ID,
        SearchManager.SUGGEST_COLUMN_TEXT_1
    )
    val cursor = MatrixCursor(columnNames, 2)

    suggestions.forEach { expense ->
        cursor.addRow(listOf(expense.id, expense.description))
    }

    return cursor
}

El método prepareSuggestions() procesa los resultados de search() para añadir cada coincidencia al cursor y así retornar las sugerencias hacia el sistema de búsqueda.

Es necesario declarar un array con los nombres de las columnas que tendrán las sugerencias. Tanto _ID como SUGGEST_COLUMN_TEXT_1 son elementos obligatorios. No obstante hay muchas más columnas opcionales que podrías usar para brindar más datos.

Paso 4: Termina la implementación de ExpenseSuggestionsProvider sobrescribiendo el método getType(). Recuerda que aquí retornamos el tipo MIME dependiendo del match:

override fun getType(uri: Uri): String? {
    return when (uriMatcher.match(uri)) {
        SEARCH_SUGGEST -> SearchManager.SUGGEST_MIME_TYPE
        else -> throw IllegalArgumentException("La uri $uri no se reconoce")
    }
}

Aunque no debes crear el string, ya que SearchManager también tiene la constante SUGGEST_MIME_TYPE para este caso.


3. Manejar Click En Sugerencias

Cuando el usuario hace click en alguna sugerencia, el sistema libera un Intent asociado a este evento. La acción y extras que este contenga están a tu disposición con el fin de interpretarlo en la actividad de búsqueda.

Manejar click en sugerencias personalizadas de búsqueda

A continuación verás cómo hacerlo:

Paso 1. Para definir al Intent abre de nuevo el archivo searchable.xml y añádele el atributo android:searchSuggestIntentAction con la acción deseada.

El valor depende de la naturaleza de la acción al clickear. En nuestro caso, cuando se selecciona una sugerencia, navegamos para ver el detalle del gasto, por lo que VIEW nos viene bien:

<?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="@string/authority"
    android:searchSuggestIntentAction="android.intent.action.VIEW"
    android:searchSuggestSelection=" ?"
    android:searchSuggestThreshold="2" />

Otro atributo que puedes añadir es android:searchSuggestThreshold para definir la cantidad de caracteres que se requieren para comenzar a mostrar sugerencias. El valor por defecto es 0, pero en nuestro caso lo pusimos en 2.

Paso 2: Lo siguiente es pasarle extras al intent asociado.

La primer forma es usar el atributo android:searchSuggestIntentData en el archivo searchable.xml, con el fin de pasarle la URI de contenido base de los gastos.

<?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="@string/authority"
    android:searchSuggestIntentAction="android.intent.action.VIEW"
    android:searchSuggestSelection=" ?"
    android:searchSuggestIntentData="content://com.develou.ExpenseSuggestionsProvider/expenses"
    android:searchSuggestThreshold="2" />

Y luego complementarlo con el valor de la columna especial SUGGEST_COLUMN_INTENT_DATA_ID declarado en el esquema de la sugerencia. Esto creará URIs del estilo content://autoridad/recurso/#, donde el marcador # será reemplazado automáticamente por el ID de la sugerencia. Luego podrías obtener esta URI con Intent.getData().

Sin embargo, ya que no usamos consulta por ID en nuestro provider, estas uris de contenido no nos serán de utilidad.

Por esta razón usaremos la columna SUGGEST_COLUMN_INTENT_DATA desde la creación de las filas del MatrixCursor:

private fun prepareSuggestions(query: String): Cursor {
    val suggestions = ExpenseRepository.search(query)
    val columnNames = arrayOf(
        BaseColumns._ID,
        SearchManager.SUGGEST_COLUMN_TEXT_1,
        SearchManager.SUGGEST_COLUMN_INTENT_DATA
    )
    val cursor = MatrixCursor(columnNames, 3)

    suggestions.forEach { expense ->
        cursor.addRow(listOf(expense.id, expense.description, expense.id))
    }

    return cursor
}

El identificador del gasto será suficiente para consultarlo desde la actividad de detalle a través del método fromId():

object ExpenseRepository {

    //..

    fun fromId(id: Int) = expenses.find { expense -> expense.id == id }
}

Paso 3: Finalmente, vamos a MainActivity y añadimos un when en handleSearchIntent() para procesar a ACTION_VIEW.

Ahí mismo obtenemos el dato que la selección de sugerencia trajo consigo en Intent.dataString:

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

private fun handleSearchIntent(intent: Intent) {
    when (intent.action) {
        Intent.ACTION_SEARCH -> {
            intent.getStringExtra(SearchManager.QUERY)?.also { query ->
                searchExpenses(query)
            }
        }
        Intent.ACTION_VIEW -> {
            intent.dataString?.let {
                goToExpenseDetail(it.toInt())
            }
        }
    }
}

private fun goToExpenseDetail(expenseId: Int) {
    val intent = Intent(this, DetailActivity::class.java)
    intent.putExtra(EXPENSE_ID_EXTRA, expenseId)
    startActivity(intent)
}

Gracias al método goToExpenseDetail() generamos la navegación hacia DetailActivity a través de un intent que carrea el ID del gasto clickeado desde las sugerencias.


4. Obtener Datos De Sugerencia

Concluyendo, conseguiremos el ID del gasto en la actividad de detalle y poblar una interfaz muy sencilla:

class DetailActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_detail)
        supportActionBar?.setDisplayHomeAsUpEnabled(true)

        loadExpense(intent.getIntExtra(EXPENSE_ID_EXTRA, -1))
    }

    private fun loadExpense(expenseId: Int) {
        ExpenseRepository.fromId(expenseId)?.let {
            title = "Gasto $expenseId"
            findViewById<TextView>(R.id.expense_description).text = it.description
            findViewById<TextView>(R.id.expense_date).text = it.date
            findViewById<TextView>(R.id.expense_total).text = "$${it.total}"
        }
    }

    companion object {
        const val EXPENSE_ID_EXTRA: String = "expenses.extras.id"
    }
}

Nuestra última tarea solo consistió de obtener el extra con getIntExtra() y pasarlo a loadExpense().

En esta función cargamos el gasto a partir de su identificador junto a la función de alcance let para mapear el contenido en caso de que no sea null.

Cambiamos el título de la actividad (title) y las propiedades text de los tres TextViews para descripción, fecha y total.


Búsquedas Con Room

En este tutorial aprendiste a cómo usar el servicio del sistema de Android para proveer sugerencias personalizadas en Android.

Ahora puedes avanzar hacia el tutorial para realizar búsquedas con la librería Room (todo), donde crearemos la base de datos SQLite para este ejemplo de gastos.


Más Tutoriales Android

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