Koin has straightforward APIs to manage custom scopes and Compose Navigation can easily express app flows as nested navigation graphs, but how can we connect these two worlds to automatically connect to a scope when the user enters a flow and close it when that flow is exited? Read along if this sparked your interest.
This article assumes that you are familiar with the following two topics; in case they are new to you, you can quickly get up to speed by visiting the linked resources:
- Koin custom scopes— https://insert-koin.io/docs/reference/koin-core/scopes
- Nested navigation graphs in Compose — https://developer.android.com/guide/navigation/design/nested-graphs#compose
Scope & Navigation Definition
For the solution described in this article we will make use of one convention in order to make a logical bridge between app flows and scopes and that is: the route of a nested nav graph is used as scope identifier.
We start by describing our nested nav graph and here please pay attention to the declared route, we will use it again in the next step when declaring the scope tied to this flow.
const val ROUTE_GRAPH_FLOW: NavGraphRoute = "route_graph_flow"
/**
* Navigation graph of the custom flow.
*/
fun NavGraphBuilder.flowNavGraph(
navController: NavController,
) {
navigation(
startDestination = routeScreen1.getRouteWithPlaceholders(),
route = ROUTE_GRAPH_FLOW,
) {
screen1(
onNextClick = {
navController.navigateToScreen2()
}
)
screen2(
onFinishFlowClick = {
navController.navigateToStart()
}
)
}
}
As you can see below, the definition of the scoped dependencies comes with no surprises. The important aspect to be noticed here is that the name of the scope qualifier is the route we have declared above, to fulfil our convention.
val scopeModule = module {
/**
* Dependencies of [ROUTE_GRAPH_FLOW] scope.
*/
scope(named(ROUTE_GRAPH_FLOW)) {
viewModel {
Screen1ViewModel(
flowRepository = get(),
)
}
// add other viewModels
scoped<FlowRepository> {
FlowRepositoryImpl(
authRepository = get()
)
}
// add other repositories, use cases, DAOs
}
}
⚡ Quick Refresher: A dependency coming from a narrow scope can rely on a dependency coming from a larger one, but vice-versa is not possible. If we want to inject a scoped repository, the viewModel that makes use of it has to be also scoped. That’s why in the definition above the viewModel is declared alongside the repository and is not placed in the root scope.
Injecting Scoped Dependencies
The setup is ready, now we are moving to the injection of our scoped dependencies. We inject the dependency that is highest in our architectural layers and that is the viewModel.
For this purpose, koinViewModel
is the API we are looking for. Its signature reveals two relevant aspects for our solution. Firstly, it takes a scope
as parameter, so injecting a viewModel tied to route_graph_flow
scope is a breeze and looks like this:
val scope = koinInstance.getOrCreateScope(
scopeId = "route_graph_flow",
qualifier = named("route_graph_flow")
)
val viewModel: ScopedViewModel = koinViewModel(
scope = scope
)
And secondly, the default value of the scope
parameter is LocalKoinScope.current
. This is brilliant because it allows us to implicitly pass a scope using the CompositionLocal mechanism.
The solution
We’re moving to the final part of writing the composable that is taking care of the scope management, we will call it AutoConnectKoinScope
. Now let’s look at the details.
navController.addOnDestinationChangedListener()
is the key of the solution, as it gives us the current destination (screen) and implicitly the nested nav graph (flow) that the destination is part of. Adding our convention in the equation enables us to know which scope should be used to resolve the dependencies of a particular screen.
🔓 Key API:
addOnDestinationChangedListener
gives us the current destination (screen) and implicitly the nested nav graph (flow) that the destination is part of.
For clarity I’ve broke the solution into three parts:
- 1st — For each visited screen the appropriate scope is resolved and passed implicitly through CompositionLocal mechanism.
if (currentNavGraphRoute != null) {
val scopeForCurrentNavGraphRoute = koinInstance.getOrCreateScope(
scopeId = currentNavGraphRoute,
qualifier = named(currentNavGraphRoute)
)
scopeToInject = scopeForCurrentNavGraphRoute
} else {
scopeToInject = rootScope
}
.....
CompositionLocalProvider(
LocalKoinScope provides scopeToInject,
content = content
)
💡 Good to Know: We don’t need to link a custom scope to the root scope, by default dependencies coming from the root scope can be resolved in a custom scope.
Job Offers
- 2nd — The previous scope is closed when we detect that the user has moved to a new flow.
val currentNavGraphRoute = destination.parent?.route
val previousNavGraphRoute = lastKnownNavGraphRoute
if (previousNavGraphRoute != null && currentNavGraphRoute != previousNavGraphRoute) {
val lastScope = koinInstance.getOrCreateScope(
scopeId = previousNavGraphRoute,
qualifier = named(previousNavGraphRoute)
)
lastScope.close()
}
- 3rd — The correct scope is restored in case the parent activity is being recreated, for this purpose we store the last known nav graph route in a global variable (in order to be kept around while the process lives) and use it to initialise the mutable state that holds the scope to be injected.
var scopeToInject by remember {
val lastKnownNavGraphRoute = lastKnownNavGraphRoute
mutableStateOf(
value = if (lastKnownNavGraphRoute != null) {
koinInstance.getOrCreateScope(
scopeId = lastKnownNavGraphRoute,
qualifier = named(lastKnownNavGraphRoute)
)
} else {
rootScope
}
)
}
To keep things clean & tidy we make use of DisposableEffect
to unregister the OnDestinationChangedListener
when AutoConnectKoinScope
composable leaves the composition.
🍬 Koin for Compose Goodies: To get a hold of the current Koin instance, Koin provides us with the
getKoin()
composable, and to get the current scope withLocalKoinScope.current
.
To have visibility over the entire navigation and be able to manage the scope for any screen of the app, we need to place the AutoConnectKoinScope
composable at the top of our Compose hierarchy, somewhere above the app’s NavHost
.
And that’s it folks 🎉, with this last piece we’ve placed the scope management on auto-pilot and we can move our focus on other aspects of the app craftsmanship.
This article is previously published on proandroiddev.com