Search code examples
androidkotlinandroid-roomandroid-livedata

Update data from one element in RecyclerView using ViewModel and LiveData


I am trying to write an app using a variety of architectural components/concepts and are now at a point where I am not sure what the best/preferred solution to one of my problems is.

I have a fragment inside the app that has a RecyclerView and displays elements from the Database in a List. Each item has a delete and an edit button. Now I have to implement those functionalities, but I am a bit at a loss on how to do this. I can either update the database and listen for changes there to update the RecyclerView or i can update the RecyclerView first and update the Database when the fragment is destroyed/paused. But I am struggling to implement either of those approaches.

The view needs to be updated when the element has either been changed (display new title etc.) or been removed from the list. If I put the onClickListener inside the ViewHolder, I don't have access to the complete dataset. If I put it inside onCreateViewHolder I don't have access to the item that was clicked. And in OnBindViewHolder I don't have access to the button.

In the onClickListener I either need to change the data the RecycleViewer is displaying (and update the database when the Fragment is destroyed/paused) or directly call the method in the ViewModel that accesses the database. I also don't know how to access the ViewModel and its methods from the Adapter (maybe this shouldn't be done in general) as ViewModelProvider(?)[PlacesViewModel::class.java] needs a ViewModelStoreOwner or a ViewModelStore and I don't really understand either one or how to access them from the Adapter.

What am I missing? What is the best/proper way to do this? It might be something very obvious, but in between all of those new concepts I feel a bit lost. As I said, I am new to Android and only started using most of the concepts here (LiveData, ViewModel etc.). It is possible that I am not using them properly and the whole approach is not correct.

Here is the fragment:

class PlacesFragment : Fragment() {

    private var _binding: FragmentPlacesBinding? = null
    private val binding get(): FragmentPlacesBinding = _binding!!
    private lateinit var mViewModel: PlacesViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        mViewModel = ViewModelProvider(this)[PlacesViewModel::class.java]
    }

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the layout for this fragment
        _binding = FragmentPlacesBinding.inflate(inflater, container, false)

        // set the adapter for the recyclerview to display the list items
        val recyclerView = _binding!!.recyclerViewPlaces
        mViewModel.places.observe(viewLifecycleOwner){ places ->
            recyclerView.adapter = RecyclerViewPlacesAdapter(places)
        }

        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

the ViewModel:

class PlacesViewModel(app: Application): AndroidViewModel(app) {

    private var _places: MutableLiveData<MutableList<Place>> = MutableLiveData()
    val places get() = _places

    init {
        viewModelScope.launch {
            try{
                val db = AppDatabase.getInstance(getApplication())
                _places.value = db.placeDao().getAll().toMutableList()
            } catch(e: Exception){
                Log.e("Error", e.stackTraceToString())
            }
        }
    }

    fun deletePlace(place: Place){
        viewModelScope.launch {
            try{
                val db = AppDatabase.getInstance(getApplication())
                _places.value?.remove(place)
                //db.placeDao().delete(place)
            } catch(e: Exception){
                Log.e("Error", e.stackTraceToString())
            }
        }
    }
}

and the adapter:

class RecyclerViewPlacesAdapter(private val dataSet: List<Place>) :
    RecyclerView.Adapter<RecyclerViewPlacesAdapter.ViewHolder>() {

    class ViewHolder(itemBinding: ListTilePlacesBinding) : RecyclerView.ViewHolder(itemBinding.root) {
        private val placeTitle: TextView = itemBinding.placeTitle
        private val placeAddress: TextView = itemBinding.placeAddress
        var place: Place? = null

        fun setValues(){
            if(place != null){
                placeTitle.text = place!!.title
                placeAddress.text = place!!.address //TODO either address or lat/long
            }
        }

        init {
            // Define click listener for the ViewHolder's View.
            itemBinding.buttonDelete.setOnClickListener { 
                // TODO remove item from dataset and in RecyclerView
            }
            itemBinding.buttonEdit.setOnClickListener {
                // TODO edit item and display changes in RecyclerView
            }
        }
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ): ViewHolder {
        val itemBinding = ListTilePlacesBinding.inflate(LayoutInflater.from(parent.context), parent, false)

        return ViewHolder(itemBinding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.place = dataSet[position]
        holder.setValues()
    }

    override fun getItemCount() = dataSet.size
}

UPDATE: As @cactustictacs suggested, I added a deleteItem() method to the adapter that removed the item from the dataSet and called notifyItemRemoved to update the View. I also passed the ViewModel to the adapter to call its method. I was not sure before if this is a correct approach to just pass it to another Method.


Solution

  • Broadly, the idea is that your ViewModel is what the UI layer (the View) talks to. It exposes a data state (e.g. through a LiveData object) that your UI observes, and that's what controls what the UI displays. The observer gets some new data -> you display it.

    The UI also notifies the VM of user interactions - button presses and the like. So in your case, you have the deletePlace function which is basically telling the VM "hey, the user has just decided to delete this, do what you need to do". Because the VM represents the current state, that's what you need to update whenever anything happens (and it internally handles things like persisting that state)

    Those are two completely separate paths, isolated from each other. Remember, the UI displays what the VM tells it to, so it doesn't need to react to the user hitting a delete button. That just gets forwarded to the VM. If the VM decides the state has changed then it will update its LiveData, the UI will observe the new data, and then it'll update. You're basically treating the VM as the source of truth, and keeping the update logic out of the view layer.


    This simplifies things a lot, because when your UI just needs to observe some state in the VM, every update is the same. Fragment loaded, populating the list? Observe the LiveData, get a result, show it. App restored from background, possibly with a destroyed process? Observe the thing, display whenever an update comes in. User deletes a thing, or updates it? You're observing the data, when a change is stored, it'll update. Etc! Same logic every time. No need to worry about saving state when the fragment is destroyed - all the state is in the VM!

    (There could be some exceptions to this, e.g. if a deletion requires a network call, could take some time, but you want the change to happen immediately in the view. But again, that could just be an implementation detail in the VM - it could update its local data state, and let the remote update happen in the background)


    As far as implementation goes, personally I feel like it's cleaner to have the ViewHolder call a method on the Adapter, like deleteItem(index: Int) or whatever - just because the VH is a UI component, and the adapter is the thing that sits between a data set and the UI that's meant to display it. Feels more like it's the adapter's job to handle "the user clicked delete on this item" etc., if that makes sense. Doesn't hugely matter though.

    And it's typical to make the Adapter an inner class of its parent Fragment - that way if the Fragment has a viewmodel reference, the Adapter can just see it. Otherwise you could just pass/set it on the adapter as a property during setup. A ViewModel is a shared resource so no worries about passing it around