How to do Assisted Injection with Navigation Compose?
Asked Answered
G

1

12

I've a composable called ParentScreen and a ViewModel named ParentViewModel. Inside the ParentViewModel, I am collecting a value from my repo.

class MyRepo @Inject constructor() {
    fun getParentData() = System.currentTimeMillis().toString() // some dummy value
}

@HiltViewModel
class ParentViewModel @Inject constructor(
    myRepo: MyRepo
) : ViewModel() {
    private val _parentData = MutableStateFlow("")
    val parentData = _parentData.asStateFlow()

    init {
        val realData = myRepo.getParentData()
        _parentData.value = realData
    }
}

@Composable
fun ParentScreen(
    parentViewModel: ParentViewModel = hiltViewModel()
) {
    val parentData by parentViewModel.parentData.collectAsState()
    ChildWidget(parentData = parentData)
}

Inside the ParentScreen composable, I have a ChildWidget composable and it has its own ViewModel named ChildViewModel.

@HiltViewModel
class ChildViewModel @AssistedInject constructor(
    @Assisted val parentData: String
) : ViewModel() {

    @AssistedFactory
    interface ChildViewModelFactory {
        fun create(parentData: String): ChildViewModel
    }

    init {
        Timber.d("Child says data is $parentData ")
    }
}

@Composable
fun ChildWidget(
    parentData: String,
    childViewModel: ChildViewModel = hiltViewModel() // How do I supply assisted injection factory here?
) {
    // Code omitted
}

Now, I want to get parentData inside ChildViewModel's constructor.

Questions

  • How do I supply ChildViewModelFactory to Navigation Compose's hiltViewModel method?
  • If that's not possible, what would be the most suitable method to inject an object from the parent composable to the child composable's ViewModel? How about creating a lateinit property and init method like below?
@HiltViewModel
class ChildViewModel @Inject constructor(
) : ViewModel() {
    lateinit var parentData: Long

    fun init(parentData: Long){
        if(this::parentData.isInitialized) return
        this.parentData = parentData
    }
}
Granddad answered 30/11, 2021 at 19:3 Comment(3)
I would really love to learn from the answers to come. Personally, I started with Compose in my recent project, and I found that keeping one ViewModel at the parent level it a lot easier. If there is any data in the child composable and want to past it pack to the parent, I just return the same with a Unit type parameters. I am not sure, if my way is the right one, I would love to see the answers to come. – Malmo
@Malmo in this case you can up-vote this question and Follow for updates, commenting won't subscribe you to answers. – Odellodella
Missed that πŸ™ƒ. – Malmo
G
9

You can do this using EntryPointAccessors (from Hilt) and a ViewModelProvider.Factory from View Model library.

In my sample app, BookFormScreen is using BookFormViewModel and the view model needs to load a book based on a bookId passed by the previous screen. This is what I did:

class BookFormViewModel @AssistedInject constructor(
    ...
    @Assisted private val bookId: String?,
) : ViewModel() {

    ...

    @AssistedFactory
    interface Factory {
        fun create(bookId: String?): BookFormViewModel
    }

    companion object {
        @Suppress("UNCHECKED_CAST")
        fun provideFactory(
            assistedFactory: Factory, // this is the Factory interface 
                                      // declared above
            bookId: String?
        ): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                return assistedFactory.create(bookId) as T
            }
        }
    }
}

Notice that I'm not using @HiltViewModel. The provideFactory will be use to supply a factory to create this view model.

Then, define the ViewModelFactoryProvider for the entry point:

@EntryPoint
@InstallIn(ActivityComponent::class)
interface ViewModelFactoryProvider {

    fun bookDetailsViewModelFactory(): BookDetailsViewModel.Factory

    fun bookFormViewModelFactory(): BookFormViewModel.Factory
}

Now, you need to define a composable function to provide the view model using this factory.

@Composable
fun bookFormViewModel(bookId: String?): BookFormViewModel {
    val factory = EntryPointAccessors.fromActivity(
        LocalContext.current as Activity,
        ViewModelFactoryProvider::class.java
    ).bookFormViewModelFactory()

    return viewModel(factory = BookFormViewModel.provideFactory(factory, bookId))
}

If you're using the navigation library, you can add the ViewModelStoreOwner parameter in this function and use it in viewModel() function call. For this parameter, you can pass the NavBackStackEntry object, with this, the view model will be scoped to that particular back stack entry.

Finally, you can use your view model in your composable.

val bookFormViewModel: BookFormViewModel = bookFormViewModel(bookId)
Germanophobe answered 2/5, 2022 at 14:43 Comment(4)
I'm getting a error: .Factory cannot be provided without an @Provides-annotated method... I can't spot what I'm doing differently to your sample project? (Which is awesome by the way, thanks for sharing!) Any ideas? – Kellie
hi Will. You probably figured it out already, but seems like you are not declaring the EntryPoint which you provide the reference to the YourClass.Factory instance. – Germanophobe
I think I've got that with @EntryPoint @InstallIn(ActivityComponent::class) interface ViewModelFactoryProvider { ... }. Do I need to do anything else with this other than declare it? – Kellie
A hero among men! – Dumfound

© 2022 - 2024 β€” McMap. All rights reserved.