SharedViewModel between fragment and its host activity by Koin - android

I want to achieve the communication between fragment and its host activity by using ViewModel(following: Share data using a ViewModel) to update the UI of activity when shared LiveData changed.
Start with declare ViewModel in the module
MainModule.kt
object MainModule {
val module = module {
viewModel {
MainViewModel()
}
}
}
Then inject it to activity and fragment
MainActivity.kt
private val mainViewModel by viewModel<MainViewModel>()
MainFragment.kt
private val mainViewModel by sharedViewModel<MainViewModel>()
Observe the change of LiveData on activity
MainActivity.kt
mainViewModel.drawerState.observe(this, {
// do something when it changed
})
Update the LiveData when the button(on fragment) clicked
MainFragment.kt
mainButton.setOnClickListener {
mainViewModel.toggleDrawerState()
}
The LiveData declare in ViewModel
MainViewModel.kt
private val _drawerState = MutableLiveData<DrawerState>()
val drawerState: LiveData<DrawerState> = _drawerState
fun toggleDrawerState() {
if (_drawerState.value == DrawerState.OPENED) {
_drawerState.value = DrawerState.CLOSED
} else {
_drawerState.value = DrawerState.OPENED
}
}
DrawerState.kt
enum class DrawerState {
CLOSED, OPENED
}
But It does not work as expected which means nothing happens when the button clicked(can guarantee by debugging with breakpoint). I wondering to know where I've gone wrong or misunderstood. Thank you.

Related

How to prevent data duplication caused by LiveData observation in Fragment?

I'm subscribed to an observable in my Fragment, the observable listens for some user input from three different sources.
The main issue is that once I navigate to another Fragment and return to the one with the subscription, the data is duplicated as the observable is handled twice.
What is the correct way to handle a situation like this?
I've migrated my application to a Single-Activity and before it, the subscription was made in the activity without any problem.
Here is my Fragment code:
#AndroidEntryPoint
class ProductsFragment : Fragment() {
#Inject
lateinit var sharedPreferences: SharedPreferences
private var _binding: FragmentProductsBinding? = null
private val binding get() = _binding!!
private val viewModel: ProductsViewModel by viewModels()
private val scanner: CodeReaderViewModel by activityViewModels()
private fun observeBarcode() {
scanner.barcode.observe(viewLifecycleOwner) { barcode ->
if (barcode.isNotEmpty()) {
if (binding.searchView.isIconified) {
addProduct(barcode) // here if the fragment is resumed from a backstack the data is duplicated.
}
if (!binding.searchView.isIconified) {
binding.searchView.setQuery(barcode, true)
}
}
}
}
private fun addProduct(barcode: String) {
if (barcode.isEmpty()) {
return
}
viewModel.insert(barcode)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.start(args.documentId)
if (args.documentType == "Etichette") {
binding.cvLabels.visibility = View.VISIBLE
}
initUI()
observe()
}
private fun observe() {
observeBarcode()
observeProducts()
observeLoading()
observeLast()
}
}
Unfortunately, LiveData is a terribly bad idea (the way it was designed), Google insisted till they kinda phased it out (but not really since it's still there) that "it's just a value holder"...
Anyway... not to rant too much, the solution you have to use can be:
Use The "SingleLiveEvent" (method is officially "deprecated now" but... you can read more about it here).
Follow the "official guidelines" and use a Flow instead, as described in the official guideline for handling UI Events.
Update: Using StateFlow
The way to collect the flow is, for e.g. in a Fragment:
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { // or RESUMED
viewModel.yourFlow.collectLatest { ... } // or collect { ... }
}
}
For that in your ViewModel you'd expose something like:
Warning: Pseudo-Code
// Imagine your state is represented in this sealed class
sealed class State {
object Idle: State
object Loading: State
data class Success(val name: String): State
data class Failure(val reason: String): State
}
// You need an initial state
private val _yourFlow = MutableStateFlow(State.Idle)
val yourFlow: StateFlow<State> = _yourFlow
Then you can emit using
_yourFlow.emit(State.Loading)
Every time you call
scanner.barcode.observe(viewLifecycleOwner){
}
You are creating a new anonymous observer. So every new call to observe will add another observer that will get onChanged callbacks. You could move this observer out to be a property. With this solution observe won't register new observers.
Try
class property
val observer = Observer<String> { onChanged() }
inside your method
scanner.barcode.observe(viewLifecycleOwner, observer)
Alternatively you could keep your observe code as is but move it to a Fragment's callback that only gets called once fex. onCreate(). onCreate gets called only once per fragment instance whereas onViewCreated gets called every time the fragment's view is created.

How to pass data between fragments using ViewModel(s)?

I use Koin and Fragment + ViewModel per screen.
In my HomeFragment I have list with post.
When user selects post I navigate user to PosDetailsFragment and I want to display info about post.
class HomeFragment : Fragment() {
private val homeViewModel by viewModel<HomeViewModel>()
//when user select post I set that value to LeadViewModel (I want to make that viewModel as common for some fragment)
leadViewModel.state.selectedPost.value = action.post
}
class PostDetailsFragment : Fragment() {
private val leadViewModel by sharedViewModel<LeadViewModel>()
//always null
val post = leadViewModel.state.selectedPost.value
}
My Koin module:
viewModel { LeadViewModel() }
viewModel { HomeViewModel(get(bottomNavigationCommander), get()) }
viewModel { AddPostViewModel() }
What is wrong? It looks like instance of LeadViewModel in PostDetailsFragment is completely different than in HomeFragment?
Try making the val as backing field
val post
get() = leadViewModel.state.selectedPost.value

Pass arguments from fragment to viewmodel function

Can you tell me if my approach is right? It works but I don't know if it's correct architecture. I read somewhere that we should avoid calling viewmodel function on function responsible for creating fragments/activities mainly because of screen orientation change which recall network request but I really need to pass arguments from one viewmodel to another one. Important thing is I'm using Dagger Hilt dependency injection so creating factory for each viewmodel isn't reasonable?
Assume I have RecyclerView of items and on click I want to launch new fragment with details - common thing. Because logic of these screens is complicated I decided to separate single viewmodel to two - one for list fragment, one for details fragment.
ItemsFragment has listener and launches details fragment using following code:
fun onItemSelected(item: Item) {
val args = Bundle().apply {
putInt(KEY_ITEM_ID, item.id)
}
findNavController().navigate(R.id.action_listFragment_to_detailsFragment, args)
}
Then in ItemDetailsFragment class in onViewCreated function I receive passed argument, saves it in ItemDetailsViewModel itemId variable and then launch requestItemDetails() function to make api call which result is saved to LiveData which is observed by ItemDetailsFragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
//...
val itemId = arguments?.getInt(KEY_ITEM_ID, -1) ?: -1
viewModel.itemId = itemId
viewModel.requestItemDetails()
//...
}
ItemDetailsViewModel
class ItemDetailsViewModel #ViewModelInject constructor(val repository: Repository) : ViewModel() {
var itemId: Int = -1
private val _item = MutableLiveData<Item>()
val item: LiveData<Item> = _item
fun requestItemDetails() {
if (itemId == -1) {
// return error state
return
}
viewModelScope.launch {
val response = repository.getItemDetails(itemId)
//...
_item.postValue(response.data)
}
}
}
Good news is that this is what SavedStateHandle is for, which automatically receives the arguments as its initial map.
#HiltViewModel
class ItemDetailsViewModel #Inject constructor(
private val repository: Repository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val itemId = savedStateHandle.getLiveData(KEY_ITEM_ID)
val item: LiveData<Item> = itemId.switchMap { itemId ->
liveData(viewModelScope.coroutineContext) {
emit(repository.getItemDetails(itemId).data)
}
}
we should avoid calling viewmodel function on function responsible for creating fragments/activities mainly because of screen orientation change which recall network request
Yes, in your example a request will be executed whenever ItemDetailsFragment's view is created.
Take a look at this GitHub issue about assisted injection support in Hilt. The point of assisted injection is to pass additional dependencies at object's creation time.
This will allow you to pass itemId through the constructor, which then will allow you to access it in ViewModel's init block.
class ItemDetailsViewModel #HiltViewModel constructor(
private val repository: Repository,
#Assisted private val itemId: Int
) : ViewModel() {
init {
requestItemDetails()
}
private fun requestItemDetails() {
// Do stuff with itemId.
}
}
This way the network request will be executed just once when ItemDetailsViewModel is created.
By the time the feature is available you can either try workarounds suggested in the GitHub issue or simulate the init block with a flag:
class ItemDetailsViewModel #ViewModelInject constructor(
private val repository: Repository
) : ViewModel() {
private var isInitialized = false
fun initialize(itemId: Int) {
if (isInitialized) return
isInitialized = true
requestItemDetails(itemId)
}
private fun requestItemDetails(itemId: Int) {
// Do stuff with itemId.
}
}

Testing Navigation component: "does not have a NavController"

I'm implementing Espresso tests. I'm using a Fragment with a NavGraph scoped ViewModel. The problem is when I try to test the Fragment I got an IllegalStateException because the Fragment does not have a NavController set. How can I fix this problem?
class MyFragment : Fragment(), Injectable {
private val viewModel by navGraphViewModels<MyViewModel>(R.id.scoped_graph){
viewModelFactory
}
#Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
//Other stuff
}
Test class:
class FragmentTest {
class TestMyFragment: MyFragment(){
val navMock = mock<NavController>()
override fun getNavController(): NavController {
return navMock
}
}
#Mock
private lateinit var viewModel: MyViewModel
private lateinit var scenario: FragmentScenario<TestMyFragment>
#Before
fun prepareTest(){
MockitoAnnotations.initMocks(this)
scenario = launchFragmentInContainer<TestMyFragment>(themeResId = R.style.Theme_AppCompat){
TestMyFragment().apply {
viewModelFactory = ViewModelUtil.createFor(viewModel)
}
}
// My test
}
Exception I got:
java.lang.IllegalStateException: View android.widget.ScrollView does not have a NavController setjava.lang.IllegalStateException
As can be seen in docs, here's the suggested approach:
// Create a mock NavController
val mockNavController = mock(NavController::class.java)
scenario = launchFragmentInContainer<TestMyFragment>(themeResId = R.style.Theme_AppCompat) {
TestMyFragment().also { fragment ->
// In addition to returning a new instance of our Fragment,
// get a callback whenever the fragment’s view is created
// or destroyed so that we can set the mock NavController
fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
if (viewLifecycleOwner != null) {
// The fragment’s view has just been created
Navigation.setViewNavController(fragment.requireView(), mockNavController)
}
}
}
}
Thereafter you can perform verification on mocked mockNavController as such:
verify(mockNavController).navigate(SearchFragmentDirections.showRepo("foo", "bar"))
See architecture components sample for reference.
There exists another approach which is mentioned in docs as well:
// Create a graphical FragmentScenario for the TitleScreen
val titleScenario = launchFragmentInContainer<TitleScreen>()
// Set the NavController property on the fragment
titleScenario.onFragment { fragment ->
Navigation.setViewNavController(fragment.requireView(), mockNavController)
}
This approach won't work in case there happens an interaction with NavController up until onViewCreated() (included). Using this approach onFragment() would set mock NavController too late in the lifecycle, causing the findNavController() call to fail. As a unified approach which will work for all cases I'd suggest using first approach.
You are missing setting the NavController:
testFragmentScenario.onFragment {
Navigation.setViewNavController(it.requireView(), mockNavController)
}

Let every Observer only receive *new* LiveData upon subscribing/observing

Whenever you call .observe() on LiveData, the Observer receives the last value of that LiveData. This may be useful in some cases, but not in mine.
Whenever I call .observe(), I want the Observer to receive only future LiveData changes, but not the value it holds when .observe() is called.
I may have more than one Observer for a LiveData instance. I want them all to receive LiveData updates when they happen.
I want each LiveData update to be consumed only once by each Observer. I think is just a re-phrasing of the first requirement, but my head is spinning already and I'm not sure about it.
While googling this problem, I came upon two common approaches:
Wrap the data in an LiveData<SingleEvent<Data>> and check in this SingleEvent class if it was already consumed.
Extend MediatorLiveData and use a look-up-map if the Observer already got the Event
Examples for these approaches can be found here:
https://gist.github.com/JoseAlcerreca/5b661f1800e1e654f07cc54fe87441af#gistcomment-2783677
https://gist.github.com/hadilq/f095120348a6a14251a02aca329f1845#file-liveevent-kt
https://gist.github.com/JoseAlcerreca/5b661f1800e1e654f07cc54fe87441af#file-event-kt
Unfortunately none of these examples solves all my requirements. Most of the time, the problem is that any new Observer still receives the last LiveData value upon subscribing. That means that a Snackbar which was already shown is displayed again and again whenever the user navigates between screens.
To give you some insights what I am talking about / what I am coding about:
I am following the LiveData MVVM design of the Android Architecture Componentns:
2 ListFragment are showing a list of entries.
They are using 2 instances of the same ViewModel class to observe UI-related LiveData.
The user can delete an entry in such a ListFragment. The deletion is done by the ViewModel calling Repository.delete()
The ViewModel observes the Repository for RepositoryEvents.
So when the deletion is done, the Repository informs the ViewModel about it and the ViewModel inform the ListFragment about it.
Now, when the user switches to the second ListFragment the following happens:
The second Fragment gets created and calls .observe() on its ViewModel
The ViewModel gets created and calls .observe() on the Repository
The Repository sends its current RepositoryEvent to the ViewModel
The ViewModel send the according UI Event to the Fragment
The Fragment shows a confirmation Snackbar for a deletion that happened somewhere else.
Heres some simplified code:
Fragment:
viewModel.dataEvents.observe(viewLifecycleOwner, Observer { showSnackbar() })
viewModel.deleteEntry()
ViewModel:
val dataEvents: LiveData<EntryListEvent> = Transformations.switchMap(repository.events, ::handleRepoEvent)
fun deleteEntry() = repository.deleteEntry()
private fun handleRepoEvent(event: RepositoryEvent): LiveData<EntryListEvent> {
// convert the repository event to an UI event
}
Repository:
private val _events = MutableLiveData<RepositoryEvent>()
val events: LiveData<RepositoryEvent>
get() = _events
fun deleteEntry() {
// delete it from database
_events.postValue(RepositoryEvent.OnDeleteSuccess)
}
UPDATE 2021:
Using the coroutines library and Flow it is now very easy to achieve this by implementing Channels:
MainActivity
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import com.plcoding.kotlinchannels.databinding.ActivityMainBinding
import kotlinx.coroutines.flow.collect
class MainActivity : AppCompatActivity() {
private lateinit var viewModel: MainViewModel
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
viewModel = ViewModelProvider(this).get(MainViewModel::class.java)
binding.btnShowSnackbar.setOnClickListener {
viewModel.triggerEvent()
}
lifecycleScope.launchWhenStarted {
viewModel.eventFlow.collect { event ->
when(event) {
is MainViewModel.MyEvent.ErrorEvent -> {
Snackbar.make(binding.root, event.message, Snackbar.LENGTH_LONG).show()
}
}
}
}
}
}
MainViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class MainViewModel : ViewModel() {
sealed class MyEvent {
data class ErrorEvent(val message: String): MyEvent()
}
private val eventChannel = Channel<MyEvent>()
val eventFlow = eventChannel.receiveAsFlow()
fun triggerEvent() = viewModelScope.launch {
eventChannel.send(MyEvent.ErrorEvent("This is an error"))
}
}
For me problem was solved with this:
Event wrapper class to keep event related data(Copy from google samples)
public class Event<T> {
private T mContent;
private boolean hasBeenHandled = false;
public Event( T content) {
if (content == null) {
throw new IllegalArgumentException("null values in Event are not allowed.");
}
mContent = content;
}
#Nullable
public T getContentIfNotHandled() {
if (hasBeenHandled) {
return null;
} else {
hasBeenHandled = true;
return mContent;
}
}
public boolean hasBeenHandled() {
return hasBeenHandled;
}
}
Next, i create event observer class, that handles data checks(null, etc):
public class EventObserver<T> implements Observer<Event<T>> {
#Override
public void onChanged(Event<T> tEvent) {
if (tEvent != null && !tEvent.hasBeenHandled())
onEvent(tEvent.getContentIfNotHandled());
}
protected void onEvent(#NonNull T content) {}
}
And, event handler class, to simplify access from viewmodel:
public class EventHandler<T> {
private MutableLiveData<Event<T>> liveEvent = new MutableLiveData<>();
public void observe(#NonNull LifecycleOwner owner, #NonNull EventObserver<T> observer){
liveEvent.observe(owner, observer);
}
public void create(T content) {
liveEvent.setValue(new Event<>(content));
}
}
Example:
In ViewModel.class:
private EventHandler<Boolean> swipeEventHandler = new EventHandler<>();
public EventHandler<Boolean> getSwipeEventHandler() {
return swipeEventHandler;
}
In Activity/Fragment:
Start observing:
viewModel
.getSwipeEventHandler()
.observe(
getViewLifecycleOwner(),
new EventObserver<Boolean>() {
#Override
protected void onEvent(#NonNull Boolean content) {
if(content)confirmDelete(modifier);
}
});
Create event:
viewModel.getSwipeEventHandler().create(true);
Created a basic sealed class flag in the need of:
sealed class Event(private var handled: Boolean = false) {
val coldData: Event?
get() {
return if (handled) null else {
handled = true
this
}
}
class ShowLoader() : Event()
class HideLoader() : Event()
class ShowErrorAlert(#StringRes val message: Int) : Event()
}
Then it can be observed at different fragments
viewModel.eventFlow.observe(this) { event ->
val data = event.coldData
when (data) {
is Event.ShowLoader -> {
progressBar.visible = true
}
is Event.HideLoader -> {
progressBar.visible = false
}
is Event.ShowErrorAlert -> {
showAlert(data.message)
}
else -> {
// do nothing
}
}
}
Or use a subclass of MutableLiveDatawith the same purpose to process them individually.

Categories

Resources