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.
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:
- Crear base de datos Room de gastos
- Crear view model para actividad de gastos
- Actualizar lista de gastos al cambiar consulta de búsqueda
- Añadir sugerencias de consultas recientes
- 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áusulaLIKE
.
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 ExpensesViewMode
l 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.
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()
:
- Obtener referencia de SearchView
- Registrar escucha de expansión y contracción
- Registrar escucha para limpieza de consulta
- 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.
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:
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:
- Se actualiza la consulta del SearchView con el texto de la sugerencia clickeada
- Se procesa el click desde el view model
- 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:
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:
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:
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: