Realizar Búsqueda Con SearchView Y Room

Ahora pasaremos a crear una búsqueda con el SearchView y Room para dar un enfoque más realista a nuestra App de gastos. Tomaremos el control del comportamiento de la búsqueda a diferencia del tutorial anterior sobre sugerencias personalizadas.

Apariencia de ejemplo con SearchView y Room

En este tutorial, modificarás el proyecto de gastos actual para aprender lo siguiente:

  • Personalizar el comportamiento del SearchView
  • Buscar datos desde Room
  • Mostrar sugerencias de consultas recientes y personalizadas sin la asistencia de Android
  • Usar el ViewModel y LiveData en conjunto para soportar las actualizaciones de búsqueda

Nota: En este tutorial asumiré que ya leíste el apartado anterior Búsquedas Con Sugerencias Personalizadas y sus sucesores. Además de mis guías para Room, ViewModel y LiveData.


Ejemplo De Búsqueda Con SearchView Y Room

El aspecto visual de la App de gastos se mantiene igual para la lista principal, pero las sugerencias ahora serán parte del layout de la actividad principal. Esto se debe a que usaremos nuestra propia implementación en lugar del servicio de búsqueda de Android:

Descarga el proyecto final de la App desde el siguiente botón:

Abre el proyecto Android Studio y corre el módulo Busqueda_Room para probar la búsqueda y el despliegue de sugerencias. Esto te permitirá comprender mejor los pasos que presentaré a continuación.


Búsqueda Con Room

Hasta el momento habíamos usado a ExpenseRepository con una estrategia simplificada en memoria. Nuestros gastos vivían en una lista mutable del tipo Expense, que es nuestro modelo para gastos.

Para alcanzar la persistencia, reemplazaremos ese componente por un DAO para gastos y lo integraremos al manejo de estados de vista en un ViewModel y a la reactividad del LiveData.

Para cubrir este plan seguiremos los siguientes pasos:

  1. Crear base de datos Room de gastos
  2. Crear view model para actividad de gastos
  3. Actualizar lista de gastos al cambiar consulta de búsqueda
  4. Añadir sugerencias de consultas recientes
  5. Añadir sugerencias personalizadas

Con las tareas declaradas, pasemos a la acción.


1. Crear Base De Datos Room

Paso 1: Antes que nada añade las dependencias que usaremos en el proyecto al archivo build.gradle del proyecto:

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
}

android {
    //..
}

dependencies {

    // Room
    implementation "androidx.room:room-runtime:2.3.0"
    implementation "androidx.room:room-ktx:2.3.0"
    kapt "androidx.room:room-compiler:2.3.0"

    // ViewModel
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1")

    // LiveData
    implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.3.1")

    // Extensiones KTX
    implementation "androidx.fragment:fragment-ktx:1.3.4"

    // RecyclerView
    implementation "androidx.recyclerview:recyclerview:1.2.1"

    // Default..
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

Paso 2: Luego crea la clase abstracta que extienda de RoomDatabase para que actúe como punto de entrada para SQLite. Su nombre será ExpensesDatabase:

@Database(
    entities = [Expense::class],
    version = 1
)
abstract class ExpensesDatabase : RoomDatabase() {
    abstract fun expenseDao(): ExpenseDao       
}

Paso 3: La entidad Expense de la anotación @Database marcará error en ejecución, ya que esta clase aún no está anotada con @Entity. En consecuencia, aplícale la marca:

@Entity(tableName = "expense")
data class Expense(
    @PrimaryKey(autoGenerate = true)
    val id: Int,
    val description: String,
    val date: String,
    val total: Double
)

Como ves, por simplicidad del ejemplo, mapeamos directamente cada propiedad con la columna que dará soporte a la tabla. Donde id será la clave primaria con valores enteros autoincrementales.

Paso 4: Crea una nueva interfaz llamada ExpenseDao y añade los métodos insert(), query() y queryByDescription().

@Dao
interface ExpenseDao {

    @Insert
    suspend fun insert(expense: Expense)

    @Insert
    fun insert(expense: List<Expense>)

    @Query("SELECT * FROM expense")
    suspend fun query(): List<Expense>

    @Query("SELECT * from expense WHERE description LIKE '%'|| :query ||'%'")
    suspend fun queryByDescription(query: String): List<Expense>
}

Evidentemente, insert() permitirá insertar uno o varios registros Expense, query() obtiene a todos los gastos y queryByDescription() obtiene los gastos que coincidan con el término de búsqueda en su columna expense.description.

Puntos a tener en cuenta:

  • Declaramos a los métodos como funciones suspendibles para ejecutarlos al interior de corrutinas y mantener el hilo de UI limpio.
  • Usamos el operador de concatenación de SQLite || para inyectar la consulta entre los placeholders de coincidencia de la cláusula LIKE.

Paso 5: Culminamos la creación de la base de datos, prepoblando con los diez gastos que tenemos en el repositorio de memoria:

//...
abstract class ExpensesDatabase : RoomDatabase() {

    //...

    companion object {
        @Volatile
        private var INSTANCE: ExpensesDatabase? = null

        fun getInstance(context: Context): ExpensesDatabase =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: buildDatabase(context).also { db ->
                    INSTANCE = db
                }
            }

        private fun buildDatabase(context: Context): ExpensesDatabase {
            return Room.databaseBuilder(
                context,
                ExpensesDatabase::class.java,
                "expenses.db"
            ).fallbackToDestructiveMigration()
                .addCallback(object : Callback() {
                    override fun onCreate(db: SupportSQLiteDatabase) {
                        // Prepoblar la base de datos con 10 gastos en hilo para IO
                        ioThread {
                            getInstance(context).expenseDao().insert(ExpenseRepository.getAll())
                        }
                    }
                })
                .build()
        }
    }
}

private val IO_EXECUTOR = Executors.newSingleThreadExecutor()

fun ioThread(f: () -> Unit) {
    IO_EXECUTOR.execute(f)
}

Como ves, getInstance() es un método de provee la instancia de la base de datos. Y buildDatabase() usa el método Room.databaseBuilder() para configurar la creación.

Recuerda que Callback.onCreate() es llamado en el momento en que las tablas son creadas. Por esta razón es un buen lugar para prepoblar la tabla de gastos a través del ExpenseDao.insert() y nuestros datos del repositorio en memoria.


2. Crear ViewModel Para Gastos

Paso 1: Crea un nuevo ViewModel llamado ExpensesViewModel:

class ExpensesViewModel : ViewModel() {
}

Paso 2: Define como propiedades para los estado de vista de la consulta y la lista de gastos:

class ExpensesViewModel : ViewModel() {

    private val _textQuery = MutableLiveData<String>()
    val textQuery:LiveData<String> = _textQuery

    val expenses = MutableLiveData<List<Expense>>()

    init {
        initExpenses()
    }

    private fun initExpenses() {
        // Llamar a DAO
    }
}

Como ves, en el bloque init cargaremos a todos los gastos como estado inicial del live data expenses.

Paso 3: Define como propiedad del constructor primario a ExpenseDao con el objetivo de delegarle la obtención de gastos:

class ExpensesViewModel(
    private val expenseDao: ExpenseDao
) : ViewModel() {

    //..

    private fun initExpenses() {
        viewModelScope.launch {
            expenses.value = expenseDao.query()
        }
    }
}

class ExpensesViewModelFactory(
    private val expenseDao: ExpenseDao
) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        @Suppress("UNCHECKED_CAST")
        if (modelClass.isAssignableFrom(ExpensesViewModel::class.java))
            return ExpensesViewModel(expenseDao) as T

        throw IllegalArgumentException("Clase de ViewModel desconocida")
    }
}

Como el view model recibe parámetros, le creamos a la fábrica ExpensesViewModelFactory, con el fin de orientar al framework al crear el ejemplar de ExpensesViewModel.

Paso 4: Encuentra la referencia de ExpensesViewModel desde MainActivity aplicando delegación de propiedades con la función de extensión viewModels():

class MainActivity : AppCompatActivity() {

    private val viewModel by viewModels<ExpensesViewModel> {
        val db = ExpensesDatabase.getInstance(this)
        ExpensesViewModelFactory(db.expenseDao())
    }
}

Luego registra un observador para expenses con setUpExpenses(). La razón para ello, es actualizar los datos del adaptador de expenseList a través de submitList():

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

    val adapter = ExpenseAdapter()
    expenseList.adapter = adapter

    viewModel.expenses.observe(this){ listedExpenses ->
        adapter.submitList(listedExpenses)
    }
}

3.Usar Toolbar Como View Autónomo

Aunque seguiremos usando a la clase SearchView, esta vez necesitamos añadir una App Bar para conseguir su referencia como un view normal.

App Bar para app de gastos

Modificar Layout De Actividad

El proceso ya lo sabemos. Abrimos activity_main.xml y declaramos el código estándar de una pantalla con app bar y contenido principal:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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=".ui.view.MainActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/Theme.SearchViewEnAndroid.AppBarOverlay">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/Theme.SearchViewEnAndroid.PopupOverlay" />

    </com.google.android.material.appbar.AppBarLayout>

    <include layout="@layout/content_main" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

Configurar Toolbar

Luego desde onCreate(), llamamos un método de utilidad que tome la referencia de la Toolbar, cambie su título, infle el menú de gastos que tenemos y configure el SearchView:

private fun setUpToolbar() {
    toolbar = findViewById(R.id.toolbar)

    toolbar.run {
        setTitle(R.string.app_name)
        inflateMenu(R.menu.expenses_menu)
        setUpSearchView(menu)
    }
}

Configurar SearchView

Al interior del método expresado setUpSearchView() haremos todo lo que hacíamos antes en onCreateOptionsMenu():

  1. Obtener referencia de SearchView
  2. Registrar escucha de expansión y contracción
  3. Registrar escucha para limpieza de consulta
  4. Registrar escucha de cambio del texto de consulta

Es decir:

private fun setUpSearchView(menu: Menu) {
    val searchItem = menu.findItem(R.id.search)

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

        override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
            return true
        }
    })

    searchView = searchItem.actionView as SearchView

    searchView.run {
        queryHint = getString(R.string.search_hint)

        findViewById<View>(R.id.search_close_btn).setOnClickListener {
            setQuery("", true)
            showKeyboard(this)
        }

        setOnQueryTextListener(object : SearchView.OnQueryTextListener {
            override fun onQueryTextSubmit(query: String): Boolean {
                dismissKeyboard(searchView)
                return true
            }

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

Ya que esta vez el sistema no nos ayudará con los comportamientos para mostrar/ocultar el teclado, usaremos los siguientes métodos de utilidad desde la limpieza y el envío de consulta:

private fun showKeyboard(view: View) {
    ViewCompat.getWindowInsetsController(view)?.show(WindowInsetsCompat.Type.ime())
}

private fun dismissKeyboard(view: View) {
    ViewCompat.getWindowInsetsController(view)?.hide(WindowInsetsCompat.Type.ime())
}

4. Manejar Envío De Búsqueda

En este momento el SearchView se expande y recibe resultados de tus consultas. No obstante, no tendrás ningún comportamiento por obvias razones.

Primero tenemos que actualizar la consulta que tenemos en el view model desde onQueryTextChange():

override fun onQueryTextChange(newText: String): Boolean {
    viewModel.onSearchQueryChanged(newText)
    return true
}

El método onSeaerchQueryChanged() responderá asignando el nuevo valor:

fun onSearchQueryChanged(query: String) {
    _textQuery.value = query

}

Luego, ejecuta la búsqueda al confirmar la consulta, ve al controlador onQueryTextSubmit() y haz el llamado del View Model:

override fun onQueryTextSubmit(query: String): Boolean {
    viewModel.onSearchQuerySubmitted()
    dismissKeyboard(searchView)
    return true
}

El método onSearchQuerySubmitted() transmite el comando de cambio de consulta hacia los estados de vista, de forma que busquemos desde la base de datos los gastos asociados.

fun onSearchQuerySubmitted() {
    val query = textQuery.value
    if (query.isNullOrBlank())
        return

    viewModelScope.launch {
        expenses.value = expenseDao.queryByDescription(query)
    }
}

5. Manejar Colapso Del SearchView

En el momento que el SearchView se contrae, debemos retornar a la lista original de todos los gastos. Para desembocar este estímulo, llamamos a su contraparte del view model:

override fun onMenuItemActionCollapse(item: MenuItem?): Boolean {
    viewModel.onSearchClosed()
    return true
}

Con el método onSearchClosed() limpiaremos los resultados de búsqueda:

fun onSearchClosed() {
    initExpenses()
}

Con el código anterior, ya se disparará la actualización de la lista de gastos desde Room, cada que el usuario presiona el botón de enviar búsqueda:

Sin embargo, en el momento en que se expande el SearchView deseamos presentar sugerencias, es decir, reemplazar la lista de gastos por una lista de sugerencias mientras la búsqueda esté activa.

Veamos cómo hacerlo.


6. Añadir Sugerencias Recientes Desde Room

Si recuerdas, en el tutorial sobre sugerencias recientes, vimos que existía una tabla adicional para operarlas y consultarlas.

Búsqueda con sugerencias recientes y Room

Pues en esta solución haremos exactamente lo mismo, solo que sin el ContentProvider y aplicando Room. Observa.

Crear Tabla De Consultas Recientes

Iniciemos añadiendo una nueva entidad llamada RecentQuery que contenga como columnas un identificador, el término de búsqueda y la fecha de inserción.

@Entity(tableName = "recent_query", indices = [Index(value = ["text"], unique = true)])
data class RecentQuery(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val text: String,
    val createdDate: Long = System.currentTimeMillis()
)

Importantisimo agregar esta nueva entidad a la base de datos:

@Database(
    entities = [Expense::class, RecentQuery::class],
    version = 1
)

Crear DAO De Consultas Recientes

Seguido, añade su respectivo objeto de acceso con el nombre de RecentQueryDao. Provee métodos para inserción, consulta y borrado:

@Dao
interface RecentQueryDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(query: RecentQuery)

    @Query("SELECT * FROM recent_query")
    fun query(): LiveData<List<RecentQuery>>

    @Query("SELECT * FROM recent_query WHERE text LIKE '%'|| :query ||'%'")
    fun queryByText(query: String): LiveData<List<RecentQuery>>

    @Query("DELETE FROM recent_query")
    suspend fun delete()
}

Y claro está, añade su acceso desde la base de datos:

abstract class ExpensesDatabase : RoomDatabase() {

    //...
    abstract fun recentQueryDao(): RecentQueryDao
}

Terminando este fragmento de infraestructura, ahora avancemos a la UI.

Añadir LiveData Para Sugerencias Recientes

Para nuestro ejemplo de gastos añadiremos un segundo RecyclerView, con el objetivo de jugar con las visibilidades de los gastos y las sugerencias recientes.

Esto quiere decir que añadiremos un nuevo estado de vista para las sugerencias en ExpensesViewModel:

val recentSuggestions: LiveData<List<RecentQuery>> = textQuery.switchMap { query ->
    if (query.isBlank())
        recentQueryDao.query()
    else
        recentQueryDao.queryByText(query)
}.distinctUntilChanged()

La lista de sugerencias reacciona en cadena con la consulta de texto, por lo que usamos la función switchMap() para realizar la vinculación hacia las consultas de sugerencias del DAO.

Si la consulta está en blanco, tomamos todas las sugerencias (o el número que desees), de lo contrario las filtramos por el texto.

Nota: Recuerda que distinctUntilChanged() evita que el LiveData emita valores hasta que sean diferentes.

Crear Adaptador De Sugerencias Recientes

El siguiente paso es crear el adaptador que mostrará y procesará los clicks sobre cada sugerencia.

Por lo que crea la clase RecentSuggestionsAdapter y configura el siguiente esquema:

class RecentSuggestionAdapter(private val listener: (RecentQuery) -> Unit) :
    ListAdapter<RecentQuery, RecentSuggestionAdapter.SuggestionViewHolder>(
        SuggestionDiffCallback()
    ) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionViewHolder {
        val view = LayoutInflater
            .from(parent.context)
            .inflate(R.layout.recent_suggestion_item, parent, false)
        return SuggestionViewHolder(view, listener)
    }

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

    class SuggestionViewHolder(view: View, val listener: (RecentQuery) -> Unit) :
        RecyclerView.ViewHolder(view) {
        private lateinit var currentItem: RecentQuery
        private val suggestionText = view.findViewById<TextView>(R.id.suggestion)

        init {
            itemView.setOnClickListener { listener(currentItem) }
        }

        fun bind(suggestion: RecentQuery) {
            currentItem = suggestion
            suggestionText.text = suggestion.text
        }
    }
}

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

    override fun areContentsTheSame(oldItem: RecentQuery, newItem: RecentQuery): Boolean {
        return oldItem == newItem
    }
}

El layout usado para el ítem es prácticamente la copia del que usamos en el tutorial de sugerencias recientes:

Layout de sugerencia reciente para búsqueda con Room

Diseñamos un espacio con un icono de aspecto temporal y el texto de la consulta:

<?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:foreground="?attr/selectableItemBackground"
    android:paddingHorizontal="16dp">

    <TextView
        android:id="@+id/suggestion"
        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" />

    <ImageView
        android:id="@+id/suggestion_icon"
        android:layout_width="24dp"
        android:layout_height="24dp"
        android:src="@drawable/ic_recent_suggestion"
        android:contentDescription="@string/recent_suggestion_icon"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Observar Lista De Sugerencias Recientes

Al igual que con los gastos, toma la referencia del RecyclerView asociado a las sugerencias, asigna su adaptador y termina usando observe() sobre recentSuggestions para actualizar sus datos internos en cada cambio:

private fun setUpSuggestions() {
    suggestionsList = findViewById(R.id.suggestions_list)

    val recentAdapter = RecentSuggestionAdapter { suggestion ->
        searchView.setQuery(suggestion.text, false)
        viewModel.onRecentQueryClicked(suggestion)
        dismissKeyboard(searchView)
    }
    suggestionsList.adapter = recentAdapter

    viewModel.recentSuggestions.observe(this) { listedSuggestions ->
        recentAdapter.submitList(listedSuggestions)
    }
}

En el código anterior destacan las acciones que ejecutamos en el click de las sugerencias recientes:

  1. Se actualiza la consulta del SearchView con el texto de la sugerencia clickeada
  2. Se procesa el click desde el view model
  3. Se oculta el teclado

El método onRecentQueryClicked() se compone del guardado de la consulta y la confirmación de una búsqueda:

fun onRecentQueryClicked(recentQuery: RecentQuery) {
    _textQuery.value = recentQuery.text
    onSearchQuerySubmitted()
}

Insertar Consultas Recientes

Lo que falta ahora es insertar en la tabla las consultas recientes del usuario, lo cual es sencillo. Desde onSearchQuerySubmitted() invoca a saveRecentQuery() para insertar el registro:

fun onSearchQuerySubmitted() {
    val query = textQuery.value
    if (query.isNullOrBlank())
        return

    saveRecentQuery(query)

    viewModelScope.launch {
        expenses.value = expenseDao.queryByDescription(query)
    }
}

private fun saveRecentQuery(query: String) {
    viewModelScope.launch {
        val recentQuery = RecentQuery(text = query)
        recentQueryDao.insert(recentQuery)
    }
}

De esta forma, cada que presiones el icono de envío, tendrás una nueva consulta reciente en la tabla. Puedes usar al Database Inspector para comprobar las operaciones:

Database Inspector con Room y sugerencias

7. Añadir Sugerencias Personalizadas Desde Room

Y por último, combinaremos las sugerencias recientes y las personalizadas en una misma lista proyectada en la pantalla de gastos:

Búsqueda con sugerencias personalizadas y Room

Procesar el estado de vista para estas sugerencias es casi identico al caso anterior, por lo que no ahondaremos mucho en explicaciones sobre la creación de los componentes.

Origen De Las Sugerencias Personalizadas

La fuente de datos desde donde se crean las sugerencias personalizadas es la misma tabla expense. No obstante, solo necesitaremos el id y la descripción, por lo que crearemos la siguiente vista en la base de datos:

@DatabaseView(
    viewName = "expense_suggestion_view",
    value = "SELECT id as expenseId, description as text FROM expense"
)
data class ExpenseSuggestionView(
    val expenseId: Int,
    val text: String
)

Al igual que las entidades, debes añadir la vista a la base de datos:

@Database(
    entities = [Expense::class, RecentQuery::class],
    views = [ExpenseSuggestionView::class],
    version = 1
)

Y completamos con un método de consulta desde ExpensesDao:

@Query("SELECT * FROM expense_suggestion_view WHERE text LIKE '%'|| :query ||'%'")
fun suggestions(query: String): LiveData<List<ExpenseSuggestionView>>

Añadir LiveData De Sugerencias Personalizadas

El estado de vista es exactamente que el de las sugerencias recientes. Responde al cambio del live data asociado a la query de sugerencias:

val customSuggestions: LiveData<List<ExpenseSuggestionView>> =
    textQuery.switchMap { query ->
        if (query.length > 1)
            expenseDao.suggestions(query)
        else
            MutableLiveData(emptyList())
    }.distinctUntilChanged()

La diferencia radica en que solo comenzamos a buscar si el tamaño del texto de consulta es mayor a 1. De lo contrario retornamos un live data vacío.

Crear Adaptador Para Sugerencias Personalizadas

De igual forma creamos un adaptador para inflar el layout para sugerencias personalizadas y bindee a los objetos ExpenseSuggestionView:

class CustomSuggestionAdapter(private val listener: (ExpenseSuggestionView) -> Unit) :
    ListAdapter<ExpenseSuggestionView, CustomSuggestionAdapter.SuggestionViewHolder>(
        CustomDiffCallback()
    ) {

    class SuggestionViewHolder(view: View, val listener: (ExpenseSuggestionView) -> Unit) :
        RecyclerView.ViewHolder(view) {

        private lateinit var currentItem: ExpenseSuggestionView
        private val suggestionText = view.findViewById<TextView>(R.id.suggestion)

        init {
            itemView.setOnClickListener { listener(currentItem) }
        }

        fun bind(suggestion: ExpenseSuggestionView) {
            currentItem = suggestion
            suggestionText.text = suggestion.text
        }
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): SuggestionViewHolder {
        val view = LayoutInflater
            .from(parent.context)
            .inflate(R.layout.custom_suggestion_item, parent, false)
        return SuggestionViewHolder(view, listener)
    }

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

class CustomDiffCallback : DiffUtil.ItemCallback<ExpenseSuggestionView>() {
    override fun areItemsTheSame(
        oldItem: ExpenseSuggestionView,
        newItem: ExpenseSuggestionView
    ): Boolean {
        return oldItem.expenseId == newItem.expenseId
    }

    override fun areContentsTheSame(
        oldItem: ExpenseSuggestionView,
        newItem: ExpenseSuggestionView
    ): Boolean {
        return oldItem == newItem
    }
}

El layout es la réplica de las recientes, solo que el icono cambia por una lupa:

Layout de sugerencia personalizada

Mantendremos esta separación de layouts para abrir los diseños a futuras personalizaciones y eventos:

<?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:foreground="?attr/selectableItemBackground"
    android:paddingHorizontal="16dp">

    <TextView
        android:id="@+id/suggestion"
        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" />

    <ImageView
        android:id="@+id/suggestion_icon"
        android:layout_width="24dp"
        android:layout_height="24dp"
        android:contentDescription="@string/custom_suggestion_icon"
        android:src="@drawable/ic_search_black_24dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Fusionar Ambos Tipos De Sugerencias Con ConcatAdapter

La librería del RecyclerView nos provee la clase ConcatAdapter para integrar dos o más adaptadores sobre una lista.

Y esto nos viene perfecto, ya que deseamos solucionar la combinación de los resultados desde la UI para mantener los eventos de cada ítem separados:

private fun setUpSuggestions() {
    suggestionsList = findViewById(R.id.suggestions_list)

    val recentAdapter = RecentSuggestionAdapter { suggestion ->
        searchView.setQuery(suggestion.text, false)
        viewModel.onRecentQueryClicked(suggestion)
        dismissKeyboard(searchView)
    }
    val customAdapter = CustomSuggestionAdapter { suggestion ->
        goToExpenseDetail(suggestion.expenseId)
    }
    suggestionsList.adapter = ConcatAdapter(recentAdapter, customAdapter)

    viewModel.recentSuggestions.observe(this) { listedSuggestions ->
        recentAdapter.submitList(listedSuggestions)
    }

    viewModel.customSuggestions.observe(this) {
        customAdapter.submitList(it)
    }
}

En el momento en que cambien las listas de ambas sugerencias, los observadores sobre los LiveDatas invocarán la actualización sobre la instancia correspondiente. Aportándonos así, una lista con dos diferentes tipos de sugerencias:

Sugerencias recientes y personalizadas en búsqueda con Room

Más Contenidos 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!