Pass arguments from fragment to viewmodel function
Asked Answered
B

2

6

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.

items structure

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)
        }
    }
}
Brunabrunch answered 17/2, 2021 at 9:13 Comment(0)
S
17

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)
        }
    }
Stancil answered 17/2, 2021 at 14:49 Comment(7)
How do you pass arguments from fragment?Timoshenko
the savedStateHandle is Empty !Manmade
@HussienFahmy it isn't empty if you configure things correctlyStancil
The problem was am using 'by activityViewModels()'Manmade
well in that case you only see whatever is passed to the activity via intentStancil
This is what I am looking for. Why is it not mentioned in the documentation that SavedStateHandle also handle fragment.arguments?Tips
@VolodymyrZakharov The key value bundle which you pass during the navigation call can be retrieved in savedStateHandle. Eg: findNavController().navigate(R.id.destination, Bundle().apply { putString("key", "value") })Flagellum
H
0

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.
    }
}
Hutchings answered 17/2, 2021 at 12:48 Comment(2)
Thanks for you suggestion. I just thought about using somekind flag. Meanwhile I realised I wanted to achieve impossible, because I wanted to pass argument to function without passing it... As I understand the part about passing argument in onCreateView is correct? I just need to take care of avoid multiple unnecessary api calls in viewmodel class?Brunabrunch
Generally, that is correct. Although I guess there are some specific use cases where you'd want to reload on configuration change, e.g. downloading a more detailed image for landscape mode. You can treat my suggestion as a rule of thumb.Hutchings

© 2022 - 2024 — McMap. All rights reserved.