Filter Observed ViewModel data for Spinner based on another Spinners selection (LiveData) - kotlin

I have two Spinners, each populated by ArrayList, which is observed in the ViewModel from the Fragment as below:
InventoryAddEdit Fragment
// Observe ProductGroups and populate Spinner
businessViewModel.allAppDataProductGroups.observe(viewLifecycleOwner, { productGroupArrayList -> // ArrayList<ProductGroupObject>
if (!productGroupArrayList.isNullOrEmpty()){
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, productGroupArrayList)
binding.inventoryAddEditProductGroupSpinner.adapter = adapter
}
})
// Observe ProductTypes and populate Spinner
businessViewModel.allAppDataProductTypes.observe(viewLifecycleOwner, { productTypeArrayList -> // ArrayList<ProductTypeObject>
if (!productTypeArrayList.isNullOrEmpty()){
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, productTypeArrayList)
binding.inventoryAddEditProductTypeSpinner.adapter = adapter
binding.inventoryAddEditProductTypeSpinner.setSelection(17) // Sets default value
}
})
I am trying to avoid keeping data in the Fragment (MVVM), so I am wondering how best to filter the second ArrayList based on the selection of the first.
I thought I could use the onItemSelected method on the first spinner to cancel the observer and reattach, but then filter the newly observed ArrayList by a selection of the first spinner. However, this seems a bit clunky. Another idea was to create another filtered list in the ViewModel, but that will mean more data in the ViewModel.
Is there another option I am missing, please?
For info, the ProductGroupObject and ProductTypeObject look like this:
ProductGroupObject
#IgnoreExtraProperties
data class ProductGroupObject (
//ProductGroup fields (1 fields)
var productGroup: String = "",
#ServerTimestamp
var dateEditedTimestamp: Date? = null,
#Exclude #set:Exclude #get:Exclude
var productGroupID: String = ""
) : Serializable {
override fun toString(): String {
return productGroup
}
}
ProductTypeObject
#IgnoreExtraProperties
data class ProductTypeObject (
//ProductType fields (2 fields)
var productType: String = "",
var productGroup: String = "",
#ServerTimestamp
var dateEditedTimestamp: Date? = null,
#Exclude #set:Exclude #get:Exclude
var productTypeID: String = ""
) : Serializable {
override fun toString(): String {
return productType
}
fun detailsText(): String {
val detailsString = StringBuilder()
if(productTypeID.isNotEmpty()) detailsString.append("$productTypeID\n")
if(productType.isNotEmpty()) detailsString.append("$productType\n")
if(productGroup.isNotEmpty()) detailsString.append("$productGroup\n")
return detailsString.toString()
}
}

So the best solution I came up with was to create a 'full list' and 'filtered list' for each Spinner data set in the ViewModel + 'current selection' object for each Spinner (also kept in the ViewModel).
The 'full list' is populated by the Cloud Database on startup, the 'filtered lists' are filtered depending on the Spinner selection by way of the following code:
binding.inventoryAddEditProductGroupSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener{
override fun onItemSelected(parent:AdapterView<*>?, view: View?, position: Int, id: Long){
val productGroupObject = parent?.selectedItem as ProductGroupObject
if (productGroupObject.productGroupID.isNotEmpty()){
businessViewModel.updateCurrentProductGroupVMLiveData(productGroupObject.productGroupID)
}
}
This updates the 'filtered lists' (filtering the 'full list') which hold data to any of the linked Spinners as below:
fun updateCurrentProductGroupVMLiveData (currentProductGroupId: String) {
val newProductGroup = allAppDataProductGroups.value?.find { productGroup -> productGroup.productGroupID == currentProductGroupId }
_currentProductGroup.value = newProductGroup
if(newProductGroup?.productGroup != null) {
val filteredProductsList = allAppDataProductTypes.value?.filter { productTypeObject -> productTypeObject.productGroup == newProductGroup.productGroup} as ArrayList<ProductTypeObject>
_filteredAppDataProductTypes.value = filteredProductsList
}
// UPDATE OTHER SPINNERS HERE
Log.d(TAG, "updateCurrentProductGroupVMLiveData(): '_currentProductGroupId.value' updated ($currentProductGroupId)")
}
One issue I faced was that the View was nullable because of how LiveData works, which was solved in the following post: Spinner Listener LiveData Issue

Related

Rerun StateFlow When Filter Needs to Change?

I have a StateFlow containing a simple list of strings. I want to be able to filter that list of Strings. Whenever the filter gets updated, I want to push out a new update to the StateFlow.
class ResultsViewModel(
private val api: API
) : ViewModel() {
var filter: String = ""
val results: StateFlow<List<String>> = api.resultFlow()
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
}
It's easy enough to stick a map onto api.resultFlow():
val results: StateFlow<List<String>> = api.resultFlow()
.map { result ->
val filtered = mutableListOf<String>()
for (r in result) {
if (r.contains(filter)) {
filtered.add(r)
}
}
filtered
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
But how do I get the flow to actually emit an update when filter changes? Right now, this only works with whatever the initial value of filter is set to.
You could have the filter update another StateFlow that is combined with the other one. By the way, there's filter function that is easier to use than manually creating another list and iterating to get your results.
class ResultsViewModel(
private val api: API
) : ViewModel() {
private val filterFlow = MutableStateFlow("")
var filter: String
get() = filterFlow.value
set(value) {
filterFlow.value = value
}
val results: StateFlow<List<String>> =
api.resultFlow()
.combine(filterFlow) { list, filter ->
list.filter { it.contains(filter) }
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
}

Kotlin on Android: How to use LiveData from a database in a fragment?

I use MVVM and have a list of data elements in a database that is mapped through a DAO and repository to ViewModel functions.
Now, my problem is rather banal; I just want to use the data in fragment variables, but I get a type mismatch.
The MVVM introduces a bit of code, and for completeness of context I'll run through it, but I'll strip it to the essentials:
The data elements are of a data class, "Objects":
#Entity(tableName = "objects")
data class Objects(
#ColumnInfo(name = "object_name")
var objectName: String
) {
#PrimaryKey(autoGenerate = true)
var id: Int? = null
}
In ObjectsDao.kt:
#Dao
interface ObjectsDao {
#Query("SELECT * FROM objects")
fun getObjects(): LiveData<List<Objects>>
}
My database:
#Database(
entities = [Objects::class],
version = 1
)
abstract class ObjectsDatabase: RoomDatabase() {
abstract fun getObjectsDao(): ObjectsDao
companion object {
// create database
}
}
In ObjectsRepository.kt:
class ObjectsRepository (private val db: ObjectsDatabase) {
fun getObjects() = db.getObjectsDao().getObjects()
}
In ObjectsViewModel.kt:
class ObjectsViewModel(private val repository: ObjectsRepository): ViewModel() {
fun getObjects() = repository.getObjects()
}
In ObjectsFragment.kt:
class ObjectsFragment : Fragment(), KodeinAware {
private lateinit var viewModel: ObjectsViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(this, factory).get(ObjectsViewModel::class.java)
// I use the objects in a recyclerview; rvObjectList
rvObjectList.layoutManager = GridLayoutManager(context, gridColumns)
val adapter = ObjectsAdapter(listOf(), viewModel)
// And I use an observer to keep the recyclerview updated
viewModel.getObjects.observe(viewLifecycleOwner, {
adapter.objects = it
adapter.notifyDataSetChanged()
})
}
}
The adapter:
class ObjectsAdapter(var objects: List<Objects>,
private val viewModel: ObjectsViewModel):
RecyclerView.Adapter<ObjectsAdapter.ObjectsViewHolder>() {
// Just a recyclerview adapter
}
Now, all the above works fine - but my problem is that I don't want to use the observer to populate the recyclerview; in the database I store some objects, but there are more objects that I don't want to store.
So, I try to do this instead (in the ObjectsFragment):
var otherObjects: List<Objects>
// ...
if (condition) {
adapter.objects = viewModel.getObjects()
} else {
adapter.objects = otherObjects
}
adapter.notifyDataSetChanged()
And, finally, my problem; I get type mismatch for the true condition assignment:
Type mismatch: inferred type is LiveData<List> but List was expected
I am unable to get my head around this. Isn't this pretty much what is happening in the observer? I know about backing properties, such as explained here, but I don't know how to do that when my data is not defined in the ViewModel.
We need something to switch data source. We pass switching data source event to viewModel.
mySwitch.setOnCheckedChangeListener { _, isChecked ->
viewModel.switchDataSource(isChecked)
}
In viewModel we handle switching data source
(To use switchMap include implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0")
class ObjectsViewModel(private val repository: ObjectsRepository) : ViewModel() {
// Best practice is to keep your data in viewModel. And it is useful for us in this case too.
private val otherObjects = listOf<Objects>()
private val _loadDataFromDataBase = MutableLiveData<Boolean>()
// In case your repository returns liveData of favorite list
// from dataBase replace MutableLiveData(otherObjects) with repository.getFavorite()
fun getObjects() = _loadDataFromDataBase.switchMap {
if (it) repository.getObjects() else MutableLiveData(otherObjects)
}
fun switchDataSource(fromDataBase: Boolean) {
_loadDataFromDataBase.value = fromDataBase
}
}
In activity/fragment observe getObjects()
viewModel.getObjects.observe(viewLifecycleOwner, {
adapter.objects = it
adapter.notifyDataSetChanged()
})
You can do something like this:
var displayDataFromDatabase = true // Choose whatever default fits your use-case
var databaseList = emptyList<Objects>() // List we get from database
val otherList = // The other list that you want to show
toggleSwitch.setOnCheckedChangeListener { _, isChecked ->
displayDataFromDatabase = isChecked // Or the negation of this
// Update adapter to use databaseList or otherList depending upon "isChecked"
}
viewModel.getObjects.observe(viewLifecycleOwner) { list ->
databaseList = list
if(displayDataFromDatabase)
// Update adapter to use this databaseList
}

Two Spinners Populated By Same ArrayList

So I am trying to populate two Spinners in the same Fragment, both using the same list, but to display different items.
I have the following data class:
data class ProductTypeObject (
//ProductType fields (2 fields)
var productType: String = "",
var productGroup: String = "",
#ServerTimestamp
var dateEditedTimestamp: Date? = null,
#Exclude #set:Exclude #get:Exclude
var productTypeID: String = ""
) : Serializable {
override fun toString(): String {
return productType
}
}
The Spinner is populated in the Fragment when the list is observed from the ViewModel as below:
// Observe ProductTypes and populate Spinner
businessViewModel.allAppDataProductTypes.observe(viewLifecycleOwner, Observer { productTypeArrayList ->
if (!productTypeArrayList.isNullOrEmpty()){
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, productTypeArrayList)
binding.inventoryAddEditProductGroupSpinner.adapter = adapter
}
})
This shows a list of product types as I have specified this in the toString()of the object, but is there a way to direct a second Spinner to show a list ofproduct group?
If you don't need to retrieve the values from the spinners, it's easiest to map the values to a new list:
businessViewModel.allAppDataProductTypes.observe(viewLifecycleOwner, Observer { productTypeArrayList ->
if (!productTypeArrayList.isNullOrEmpty()){
//...
val adapter2 = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item,
productTypeArrayList.map(ProductTypeObject::productGroup)
//...
}
})
If you need both Spinners to be able to retrieve the original item type, then you can't use the ArrayAdapter class as is, since it relies purely on the toString() of your class. You can subclass it like this for a more flexible version that lets you pass property or lambda that is used instead of toString(). I didn't test it, but I think it will do what you want. If you use this class, you don't need to override toString() in your original data class.
class CustomArrayAdapter<T : Any>(
context: Context,
items: List<T>,
val itemToCharSequence: T.() -> CharSequence = Any::toString
) : ArrayAdapter<T>(context, 0, items) {
private val inflater = LayoutInflater.from(context)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
return (convertView ?: inflater.inflate(android.R.layout.simple_spinner_item, parent, false))
.apply {
val item = getItem(position)!! // will never be null inside getView()
(this as TextView).text = itemToCharSequence(item)
}
}
}
Usage:
val typeAdapter = CustomArrayAdapter(requireContext(), productTypeArrayList, ProductTypeObject::productType)
val groupAdapter = CustomArrayAdapter(requireContext(), productTypeArrayList, ProductTypeObject::productGroup)

How to update data in nested child RecyclerView without losing animations/initialising a new adapter?

I have a ParentData class structured as follows:
class ParentData(
//parent data fields
var childList: List<ChildData>
I am populating a parent RecylerView with a list of ParentData and then populating each nested child RecyclerView with the embedded List<ChildData> in onBindViewHolder of the parent RecylerView Adapter (ListAdapter) like so:
val mAdapter = ChildAdapter()
binding.childRecyclerView.apply {
layoutManager = LinearLayoutManager(this.context)
adapter = mAdapter
}
mAdapter.submitList(item.childList)
//item from getItem(position)
I observe a LiveData<List<ParentData>>, so every time the embedded ChildData changes, I submit the new List<ParentData> to my parent recycler, which in turn calls OnBindViewHolder and submits the new `childList' and updates the inner child RecyclerView.
The issue is val mAdapter = ChildAdapter() is called every time the data is updated, resetting the entire child RecyclerView. This results in no item add animations being seen and scroll position being reset.
Is there any way to avoid initialising a new Adapter every time or some other way I can avoid resetting the child RecyclerViews?
I know this is an old question but I have worked on a project that is exactly like that and this answer might help others.
Basically we have a single RecyclerView approach where the main interface has a Parent RecyclerView that receives a list of Pair<ViewType, BaseViewBinder>.
class ParentAdapter: RecyclerView.Adapter<BaseViewHolder>() {
private var mList: List<Pair<Int, BaseViewBinder>> = listOf() //Int is the ViewType
fun updateData(mList: List<Pair<Int, BaseViewBinder>>) {
if (this.mList!= mList) {
this.mList = mList
this.notifySetDataChanged()
...
We observe a MediatorLiveData that feeds a new list to the Parent RecyclerView every time there is new data.
The problem is that this.notifySetDataChanged() will update everything in the parent recyclerview. So the solution for the issue where child RecyclerViews "reset" and scroll back to beggining when a new List is received was solved by sending one extra variable to the ParentAdapter "updateData" function informing which view type in the list have changed, so we can "notifyItemChanged" only that specific index of the list, therefore not refreshing the entire recyclerview ui.
fun updateData(mList: List<Pair<Int, BaseViewBinder>>, sectionUpdated: Int) {
if (this.mList!= mList) {
this.mList = mList
when(sectionUpdated){
SECTION_A -> this.notifyItemChanged(mList.indexOfFirst { it.first == VIEW_TYPE_A })
SECTION_B -> this.notifyItemChanged(mList.indexOfFirst { it.first == VIEW_TYPE_B })
SECTION_C -> this.notifyItemChanged(mList.indexOfFirst { it.first == VIEW_TYPE_C })
}
So basically you need to edit whatever function is generating your LiveData, check for differences in the new data, and return the list and an Int specifying that that specific child changed.
Ex:
private var mListA: List<X>
fun returnSomeLiveData(): LiveData<Pair<Int, List<X>>> {
var result = MutableLiveData<Pair<Int, List<X>>>()
if (mListA != someLiveData.value) { //could be DiffUtils
mListA = someLiveData.value
result.postValue(Pair(0, mListA))
}
return result
}
companion object {
val SECTION_A = 0
val SECTION_B = 1
val SECTION_C = 2
}

Recycler View recycle issue

I have a recyclerView. When I do the pull to refresh, if the new data is just one list item, then the recycler view loads the item perfectly. But if the updated data contains 2 or more, then I think the view is not recycled properly. In the actionContainer, there should only one item to be added for each of the updated list item. But during pull to refresh, ONLY WHEN there are 2 or more list items to be updated, the actionContainer shows 2 data where it should be only one. Can someone help me to fix this?
override fun onBindViewHolder(holder: HistoryListAdapter.ViewHolder?, position: Int) {
info("onBindViewHolder =>"+listAssets.size)
info("onBindViewHolder itemCount =>"+itemCount)
info("onBindViewHolder position =>"+position)
val notesButton = holder?.notesButton
val notesView = holder?.notesTextView
val dateTime = listAssets[position].date
val location = listAssets[position].location
val sessionId = listAssets[position].id
holder?.sessionID = sessionId
holder?.portraitImageView?.setImageDrawable(listAssets[position].image)
holder?.titleTextView?.text = DateTimeFormatter.getFormattedDate(context, dateTime)
val timeString = DateTimeFormatter.getFormattedTime(context, dateTime)
if (location.length != 0) {
holder?.subtitleTextView?.text = "$timeString # $location"
} else {
holder?.subtitleTextView?.text = "$timeString"
}
val data = listAssets[position].data
for (actionData in data) {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val parent = inflater.inflate(R.layout.history_card_action, null)
val icon = parent?.findViewById(R.id.historyActionIcon) as ImageView
val title = parent?.findViewById(R.id.historyActionTitle) as TextView
val subtitle = parent?.findViewById(R.id.historyActionSubtitle) as TextView
var iconDrawable: Drawable? = null
when(actionData.type) {
ActionType.HEART -> {
iconDrawable = ContextCompat.getDrawable(context, R.drawable.heart)
}
ActionType.LUNGS -> {
iconDrawable = ContextCompat.getDrawable(context, R.drawable.lungs)
}
ActionType.TEMPERATURE -> {
iconDrawable = ContextCompat.getDrawable(context, R.drawable.temperature)
}
}
icon.setImageDrawable(iconDrawable)
val titleString = actionData.title
titleString?.let {
title.text = titleString
}
val subtitleString = actionData.subtitle
subtitleString?.let {
subtitle.text = subtitleString
}
holder?.actionContainer?.addView(parent)
}
val notes = listAssets[position].notes
notesView?.text = notes
if (notes.length == 0) {
notesButton?.layoutParams?.width = 0
} else {
notesButton?.layoutParams?.width = toggleButtonWidth
}
if (expandedNotes.contains(sessionId)) {
notesView?.expandWithoutAnimation()
} else {
notesView?.collapseWithoutAnimation()
}
notesButton?.onClick {
notesView?.toggleExpansion()
}
}
data class ListAssets(val id: String,
val date: Date,
val location: String,
val notes: String,
val image: Drawable,
val data: ArrayList<ListData>)
data class ListData(val type: ActionType,
val title: String?,
val subtitle: String?)
override fun onViewRecycled(holder: HistoryListAdapter.ViewHolder?) {
super.onViewRecycled(holder)
if (holder != null) {
holder.actionContainer.removeAllViewsInLayout()
holder.actionContainer.removeAllViews()
val notesTextView = holder.notesTextView
if (notesTextView != null) {
if (notesTextView.expandedState) {
val sessionID = holder.sessionID
sessionID?.let {
val sessionSearch = expandedNotes.firstOrNull {
it.contentEquals(sessionID)
}
if (sessionSearch == null) {
expandedNotes.add(sessionID)
}
}
} else {
val sessionID = holder.sessionID
sessionID?.let {
val sessionSearch = expandedNotes.firstOrNull {
it.contentEquals(sessionID)
}
if (sessionSearch != null) {
expandedNotes.remove(sessionSearch)
}
}
}
}
}
}
First, you should probably not override onViewRecycled() unless you have to perform some very particular resources cleanup.
The place where you want to setup your views before display is onBindViewHolder().
Second, you don't need not add or remove views dynamically in a RecyclerView item, it's simpler and more efficient to only switch the visibility of the view between VISIBLE and GONE. In cases where this is not enough because views are too different, you should declare different view types, which will create separate ViewHolders for each view type.
You should not remove or add any view while overriding onBindViewHoder() method of RecyclerView Adapter because next time when a recycled layout is used, the removed views will not be found. Instead of this you can use show/hide on a view.
If you add any view to the layout dynamically, later on when this layout is recycled, it also contains the extra view which you have added before.
Similarly, if you remove any view from the layout dynamically, later on when this layout is recycled, it does not contain the view which you have removed earlier.
I have implemented a RecyclerView and Retrofit,it has the SwipeView layout (Pull to Refresh).Here is the link to the repisitory.
https://github.com/frankodoom/Retrofit-RecyclerVew