Dependency injection
Enro does not require a dependency injection framework. Any DI framework can construct a ViewModel for an Enro destination, under one condition.
Constructing the ViewModel inside createEnroViewModel
A ViewModel that uses by navigationHandle<Key>() must be constructed inside createEnroViewModel { }. createEnroViewModel provides the destination’s NavigationHandle to the ViewModel while the ViewModel’s constructor runs.
A DI framework’s own ViewModel helper constructs the ViewModel outside createEnroViewModel { }. by navigationHandle<Key>() then throws “Could not get a NavigationHandle for ViewModel”. Request the instance from the framework inside createEnroViewModel { }.
The framework provides the ViewModel’s dependencies. The ViewModel reads its arguments from navigation.key, so the ViewModel does not need assisted injection.
Koin
Declare the ViewModel in a Koin module with factory. The AndroidX viewModel() call stores the ViewModel, so Koin must create a new instance on each request.
class KoinUserDetailViewModel(
repository: UserRepository,
) : ViewModel() {
// navigation.key is the key that opened the destination. The key
// contains the arguments of the ViewModel.
private val navigation by navigationHandle<KoinUserDetail>()
val title: String = repository.nameFor(navigation.key.userId)
fun onBackClicked() = navigation.close()
}
val koinRecipeModule = module {
single { UserRepository() }
// `factory`, not Koin's `viewModel`: the AndroidX viewModel() call stores the ViewModel.
factory { KoinUserDetailViewModel(repository = get()) }
}
koinEnroViewModel requests the ViewModel from Koin inside createEnroViewModel { }:
@Composable
inline fun <reified T : ViewModel> koinEnroViewModel(): T {
val koin = getKoin()
return viewModel<T> { createEnroViewModel { koin.get<T>() } }
}
Call koinEnroViewModel() in a destination in place of koinViewModel():
@Composable
@NavigationDestination(KoinUserDetail::class)
fun KoinUserDetailDestination() {
val viewModel = koinEnroViewModel<KoinUserDetailViewModel>()
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(viewModel.title, style = MaterialTheme.typography.titleLarge)
Button(onClick = { viewModel.onBackClicked() }) { Text("Back") }
}
}
Features that each provide a Koin module
The app loads one Koin module per feature:
val appModule = module {
// `single`: every feature receives the same KoinSession instance.
single { KoinSession() }
}
// The Koin application loads the app module and the module of each feature.
private val recipeKoin = koinApplication {
modules(appModule, ordersModule, accountModule)
}
A feature’s Koin module provides the feature’s ViewModels. The enro-processor registers the feature’s destinations; Koin does not.
class OrdersViewModel(
private val session: KoinSession,
repository: OrderRepository,
) : ViewModel() {
private val navigation by navigationHandle<KoinOrders>()
val orders: List<String> = repository.orders()
val userName: String get() = session.userName
// The account feature declares the destination for KoinAccount. The
// orders feature imports the key only.
fun onAccountClicked() = navigation.open(KoinAccount)
}
// The Koin module of the orders feature. The app's module provides KoinSession.
val ordersModule = module {
single { OrderRepository() }
factory { OrdersViewModel(session = get(), repository = get()) }
}
@Composable
@NavigationDestination(KoinOrders::class)
fun KoinOrdersDestination() {
val viewModel = koinEnroViewModel<OrdersViewModel>()
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Orders for ${viewModel.userName}", style = MaterialTheme.typography.titleLarge)
viewModel.orders.forEach { Text(it) }
Button(onClick = { viewModel.onAccountClicked() }) { Text("Account") }
}
}
Metro
Declare the ViewModel on the graph as an accessor with no scope. Each read of an unscoped accessor constructs a new ViewModel.
@Inject
class MetroUserDetailViewModel(
repository: MetroUserRepository,
) : ViewModel() {
// The key this destination was opened with: the ViewModel's arguments.
private val navigation by navigationHandle<MetroUserDetail>()
val title: String = repository.nameFor(navigation.key.userId)
fun onBackClicked() = navigation.close()
}
@DependencyGraph
interface MetroRecipeGraph {
val userRepository: MetroUserRepository
// Not scoped: each read constructs a new ViewModel.
val userDetailViewModel: MetroUserDetailViewModel
}
Read the accessor inside createEnroViewModel { }:
@Composable
@NavigationDestination(MetroUserDetail::class)
fun MetroUserDetailDestination() {
val viewModel = viewModel<MetroUserDetailViewModel> {
createEnroViewModel { recipeGraph.userDetailViewModel }
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(viewModel.title, style = MaterialTheme.typography.titleLarge)
Button(onClick = { viewModel.onBackClicked() }) { Text("Back") }
}
}
Features that contribute to one graph
Declare the app’s graph with a scope. Each feature reads the graph from a CompositionLocal.
// Implements every interface contributed to AppScope.
@DependencyGraph(AppScope::class)
interface MetroModularGraph
val LocalMetroGraph = staticCompositionLocalOf<Any> {
error("No Metro graph was provided")
}
/**
* Creates [T] from the feature's graph interface [G], inside
* `createEnroViewModel` so that `by navigationHandle()` works in the ViewModel.
*/
@Composable
inline fun <reified G : Any, reified T : ViewModel> metroEnroViewModel(
crossinline create: (G) -> T,
): T {
val graph = LocalMetroGraph.current as G
return viewModel<T> { createEnroViewModel { create(graph) } }
}
Each feature contributes an interface that declares the feature’s accessors, and casts the graph to that interface:
@Inject
class OrdersViewModel(
private val session: MetroSession,
repository: OrderRepository,
) : ViewModel() {
private val navigation by navigationHandle<MetroOrders>()
val orders: List<String> = repository.orders()
val userName: String get() = session.userName
// The account screen is in another feature and is opened by key.
fun onAccountClicked() = navigation.open(MetroAccount)
}
// What this feature needs from the app's graph.
@ContributesTo(AppScope::class)
interface OrdersGraph {
val ordersViewModel: OrdersViewModel
}
@Composable
@NavigationDestination(MetroOrders::class)
fun MetroOrdersDestination() {
val viewModel = metroEnroViewModel<OrdersGraph, OrdersViewModel> { it.ordersViewModel }
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Orders for ${viewModel.userName}", style = MaterialTheme.typography.titleLarge)
viewModel.orders.forEach { Text(it) }
Button(onClick = { viewModel.onAccountClicked() }) { Text("Account") }
}
}
Other frameworks
For any other framework, request the ViewModel instance from the framework inside createEnroViewModel { }:
val viewModel = viewModel<MyViewModel> {
// Request the instance from the framework here.
createEnroViewModel { myFramework.myViewModel() }
}
Destinations across modules
The enro-processor registers the destinations of every module. A feature does not register destinations through DI. See Multi-Module Projects.
See also
- View Models —
createEnroViewModelandby navigationHandle(). - Koin ViewModel recipe.
- Koin Modular recipe.
- Metro ViewModel recipe.
- Metro Modular recipe.