How to create separate ViewModels per list item when using Compose UI?
Asked Answered
J

3

2

I'm working on a trading app. I need to list the user stocks and their value (profit or loss) among with the total value of the portfolio.

For the holdings list, in an MVP architecture I would create a presenter for each list item but for this app I decided to use MVVM (Compose, ViewModels and Hilt ). My first idea was to create a different ViewModel for each list item. I'm using hiltViewModel() in the composable method signature to create instances of my ViewModel, however this gives me always the same instance and this is not what I want. When using MVVM architecture, is what I'm trying to do the correct way or I should use a single ViewModel? Are you aware about any project I could have a look at? The image below is a super simplification of my actual screen, each cell is complex and that's why I wanted to use a different ViewModel for each cell. Any suggestion is very welcome.

enter image description here

Jaxartes answered 28/10, 2021 at 12:42 Comment(0)
P
5

Hilt doesn't support keyed view models. There's a feature request for keyed view models in Compose, but we had to wait until Hilt supports it.

Here's a hacky solution on how to bypass it for now.

You can create a plain view model, which can be used with keys, and pass injections to this view model through Hilt view model:

class SomeInjection @Inject constructor() {
    val someValue = 0
}

@HiltViewModel
class InjectionsProvider @Inject constructor(
    val someInjection: SomeInjection
): ViewModel() {

}

class SomeViewModel(private val injectionsProvider: InjectionsProvider) : ViewModel() {
    val injectedValue get() = injectionsProvider.someInjection.someValue
    var storedValue by mutableStateOf("")
        private set

    fun updateStoredValue(value: String) {
        storedValue = value
    }
}

@Composable
fun keyedViewModel(key: String) : SomeViewModel {
    val injectionsProvider = hiltViewModel<InjectionsProvider>()
    return viewModel(
        key = key,
        factory = object: ViewModelProvider.Factory {
            override fun <T : ViewModel?> create(modelClass: Class<T>): T {
                @Suppress("UNCHECKED_CAST")
                return SomeViewModel(injectionsProvider) as T
            }

        }
    )
}

@Composable
fun TestScreen(
) {
    LazyColumn {
        items(100) { i ->
            val viewModel = keyedViewModel("$i")
            Text(viewModel.injectedValue.toString())
            TextField(value = viewModel.storedValue, onValueChange = viewModel::updateStoredValue)
        }
    }
}

UPDATE after Hilt 2.43 release

You can create function like:

import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.HiltViewModelFactory
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry

@Composable
inline fun <reified VM : ViewModel> hiltViewModel(key: String): VM {
    val viewModelStoreOwner =
        if (checkNotNull(LocalViewModelStoreOwner.current) is NavBackStackEntry) {
            checkNotNull(LocalViewModelStoreOwner.current) { "ViewModelStoreOwner is null" }
        } else null

    return viewModel(
        key = key,
        factory = if (viewModelStoreOwner is NavBackStackEntry) {
            HiltViewModelFactory(
                LocalContext.current,
                viewModelStoreOwner
            )
        } else null
    )
}

and then use it like:

YourComposableWithViewModelArg(
            viewModel = hiltViewModel(key = "MyUniqueViewModelKey"),
            // ... rest of the arguments
            )
Planchet answered 28/10, 2021 at 15:51 Comment(4)
thank you for the hack. Correct me if I'm wrong but since I'm using a single activity, using viewModel(key, factory) would keep the ViewModels alive while the activity is alive (even if the I've told the NavHost to navigate to a different screen). Is that correct?. If so, the memory consumption is important. I'm thinking about using multiple fragments and use compose for the UI. Another reason for this is because NavHost does not support transition animations and I cannot use Experimental apis.Jaxartes
@DiegoPalomar If you're using Compose Navigation, then the view model will be released once the route is removed from the navigation stack. An other option is creating your own ViewModelStoreOwner, providing it as shown in this answer, and cleaning when you exit the screen: you can detect it with DisposableEffectPlanchet
@PhilipDukhov so if I understand this right, if instead of items(100) we'd have items(100_000_000) it would try to create that many ViewModels until memory runs out right?Kidwell
@Kidwell Sure. How do you expect it to work? If you don't need to save data for invisible cells, then you don't need a view model. I'd say that generally in such situation you only need a one view model which should handle all the cells.Planchet
J
1

Unfortunately, HiltViewModelFactory is not a KeyedFactory. So as of now it does not support same viewModel with multiple instances.

Tracking: https://github.com/google/dagger/issues/2328

Jala answered 28/10, 2021 at 15:38 Comment(0)
S
1

You have to use Dagger version 2.43 (or newer), it includes the feature/fix to support keys in Hilt ViewModels

https://github.com/google/dagger/releases/tag/dagger-2.43

From the release description:

Fixes #2328 and #3232 where getting multiple instances of @HiltViewModel with different keys would cause a crash.

Savino answered 21/7, 2022 at 21:11 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.