In my Android Kotlin App, I notice that I now have, in my code base, two view models that are HiltViewModel
s, that take care of some search feature concerns. Here's one of them:
package com.mikewarren.speakify.viewsAndViewModels.pages.importantApps
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mikewarren.speakify.data.AppsRepository
import com.mikewarren.speakify.data.UserAppModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AddAppMenuViewModel @Inject constructor(
private val repository: AppsRepository // Replace with your actual repository
) : ViewModel() {
private val _appsToAdd = MutableStateFlow<List<AppListItemViewModel>>(emptyList())
private val _searchText = MutableStateFlow("")
var searchText: StateFlow<String> = _searchText.asStateFlow()
private val _filteredApps = MutableStateFlow<List<AppListItemViewModel>>(_appsToAdd.value)
val filteredApps: StateFlow<List<AppListItemViewModel>> = _filteredApps.asStateFlow()
init {
viewModelScope.launch {
repository.otherApps.collect { userAppModels: List<UserAppModel> ->
_appsToAdd.value = userAppModels.map { model: UserAppModel -> AppListItemViewModel(model) }
applySearchFilter()
}
}
}
fun onSearchTextChange(text: String) {
_searchText.value = text
applySearchFilter()
}
private fun applySearchFilter() {
val text = _searchText.value
val appViewModels = _appsToAdd.value
if (text.isBlank()) {
_filteredApps.value = appViewModels
return;
}
_filteredApps.value = appViewModels.filter { vm: AppListItemViewModel ->
vm.model.appName.contains(
text,
ignoreCase = true
)
}
}
fun onAppSelected(model: UserAppModel, onDone: (UserAppModel) -> Any) {
_appsToAdd.update { appVMs : List<AppListItemViewModel> ->
appVMs.filter({ vm: AppListItemViewModel -> vm.model.packageName != model.packageName })
}
applySearchFilter()
onDone(model)
}
}
Yes, they both have that repository
dependency-injected. But they have so many of the same fields and methods:
MutableStateFlow
searchText
filteredApps
(you can call them filtered child view models)onSearchTextChange()
applySearchFilter()
I want to move all those concerns to a BaseSearchableViewModel
, and have my two view models that take on these concerns extend that base view model ...
But how?
I tried via BaseSearchableViewModel
:
package com.mikewarren.speakify.viewsAndViewModels.pages.importantApps
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mikewarren.speakify.data.AppsRepository
import com.mikewarren.speakify.data.UserAppModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
abstract class BaseSearchableViewModel @Inject constructor(
protected val repository: AppsRepository
) : ViewModel() {
protected abstract fun getMainMutableStateFlow(): MutableStateFlow<List<AppListItemViewModel>>
protected abstract fun getRepositoryStateFlow(): StateFlow<List<UserAppModel>>
private val _searchText = MutableStateFlow("")
var searchText: StateFlow<String> = _searchText.asStateFlow()
private val _filteredApps = MutableStateFlow<List<AppListItemViewModel>>(getMainMutableStateFlow().value)
val filteredApps: StateFlow<List<AppListItemViewModel>> = _filteredApps.asStateFlow()
init {
viewModelScope.launch {
getRepositoryStateFlow().collect { userAppModels: List<UserAppModel> ->
getMainMutableStateFlow().value = userAppModels.map { model: UserAppModel -> AppListItemViewModel(model) }
applySearchFilter()
}
}
}
fun onSearchTextChange(text: String) {
_searchText.value = text
applySearchFilter()
}
private fun applySearchFilter() {
val text = _searchText.value
val appViewModels = getMainMutableStateFlow().value
if (text.isBlank()) {
_filteredApps.value = appViewModels
return;
}
_filteredApps.value = appViewModels.filter { vm: AppListItemViewModel ->
vm.model.appName.contains(
text,
ignoreCase = true
)
}
}
}
but have no idea how to use it.
The obvious answer, provided in comment by @Mike M. was the main thing I had to do, but introduced a pitfall:
init {}
was in the BaseSearchableViewModel
, which caused race condition with the repository
and everything that was using it.
We had to replace that block with an
protected fun onInit() {
viewModelScope.launch {
getRepositoryStateFlow().collect { userAppModels: List<UserAppModel> ->
getMainMutableStateFlow().value = userAppModels.map { model: UserAppModel -> AppListItemViewModel(model) }
_filteredApps.value = getMainMutableStateFlow().value
applySearchFilter()
}
}
}
which gets called by the concrete derived view models.
Here's how the base searchable view model looks:
package com.mikewarren.speakify.viewsAndViewModels.pages
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mikewarren.speakify.data.AppsRepository
import com.mikewarren.speakify.data.UserAppModel
import com.mikewarren.speakify.viewsAndViewModels.pages.importantApps.AppListItemViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
abstract class BaseSearchableViewModel constructor(
protected open var repository: AppsRepository
) : ViewModel() {
protected abstract fun getMainMutableStateFlow(): MutableStateFlow<List<AppListItemViewModel>>
protected abstract fun getRepositoryStateFlow(): StateFlow<List<UserAppModel>>
private val _searchText = MutableStateFlow("")
var searchText: StateFlow<String> = _searchText.asStateFlow()
private val _filteredApps = MutableStateFlow<List<AppListItemViewModel>>(emptyList())
val filteredApps: StateFlow<List<AppListItemViewModel>> = _filteredApps.asStateFlow()
protected fun onInit() {
viewModelScope.launch {
getRepositoryStateFlow().collect { userAppModels: List<UserAppModel> ->
getMainMutableStateFlow().value = userAppModels.map { model: UserAppModel -> AppListItemViewModel(model) }
_filteredApps.value = getMainMutableStateFlow().value
applySearchFilter()
}
}
}
fun onSearchTextChange(text: String) {
_searchText.value = text
applySearchFilter()
}
fun applySearchFilter() {
val text = _searchText.value
val appViewModels = getMainMutableStateFlow().value
if (text.isBlank()) {
_filteredApps.value = appViewModels
return;
}
_filteredApps.value = appViewModels.filter { vm: AppListItemViewModel ->
vm.model.appName.contains(
text,
ignoreCase = true
)
}
}
}
The concrete derived view models then initialize the repository
like this:
@HiltViewModel
class AddAppMenuViewModel @Inject constructor(
override var repository: AppsRepository // Replace with your actual repository
) : BaseSearchableViewModel(repository) {
and the initialize like this:
init {
onInit()
}
I HATE that I have to put the latter code on every last searchable view model, but I'll take the results for now...