Short lived View Models provided by Koin with the right scope in Android Compose.
Note: This library (
io.github.sebaslogen.resaca:resacakoin
) is only required if you want to use ViewModels with a SavedStateHandle construtor parameter. If this is not your case, you can simply use the base resaca library (io.github.sebaslogen.resaca:resaca
) withviewModelScoped
function in combination with Koin getters, see example.
Compose allows the creation of fine-grained UI components that can be easily reused like Lego blocks 🧱. Well architected Android apps isolate functionality in small business logic components (like use cases, interactors, repositories, etc.) that are also reusable like Lego blocks 🧱.
Screens are built using Compose components together with business logic components, and the standard tool to connect these two types of components is a Jetpack ViewModel. Unfortunately, ViewModels can only be scoped to a whole screen (or larger scope), but not to smaller Compose components on the screen.
In practice, this means that we are gluing UI Lego blocks with business logic Lego blocks using a big glue class for the whole screen, the ViewModel 🗜.
Until now...
Just include the library:
Kotlin (KTS)
// In module's build.gradle.kts
dependencies {
// The latest version of the lib is available in the badget at the top from Maven Central, replace X.X.X with that version
implementation("io.github.sebaslogen:resacakoin:X.X.X")
}
Groovy
dependencies {
// The latest version of the lib is available in the badget at the top from Maven Central, replace X.X.X with that version
implementation 'io.github.sebaslogen:resacakoin:X.X.X'
}
Inside your @Composable
function create and retrieve a ViewModel using koinViewModelScoped
to remember any ViewModel that you have defined in your Koin module
. This is all that's needed 🪄✨
Examples:
Scope a ViewModel injected by Koin with SavedStateHandle to a Composable
@Composable
fun DemoInjectedViewModelScoped() {
val myInjectedViewModel: MyViewModel = koinViewModelScoped()
DemoComposable(viewModel = myInjectedViewModel)
}
class MyViewModel(private val stateSaver: SavedStateHandle) : ViewModel()
Scope a ViewModel injected by Koin with SavedStateHandle and a key to a Composable
@Composable
fun DemoInjectedViewModelWithKey(keyOne: String = "myFirstKey", keyTwo: String = "mySecondKey") {
val scopedVMWithFirstKey: MyViewModel = koinViewModelScoped(keyOne)
val scopedVMWithSecondKey: MyViewModel = koinViewModelScoped(keyTwo)
// We now have 2 instances on memory of the same ViewModel type, both inside the same Composable scope
// When one key updates only the ViewModel with that key will be recreated
DemoComposable(inputObject = scopedVMWithFirstKey)
DemoComposable(inputObject = scopedVMWithSecondKey)
}
class MyViewModel(private val stateSaver: SavedStateHandle) : ViewModel()
Scope a ViewModel injected by Koin with SavedStateHandle and an argument/parameter/id (assisted injection) to a Composable
@Composable
fun DemoInjectedViewModelWithId(idOne: String = "myFirstId", idTwo: String = "mySecondId") {
val scopedVMWithFirstId: MyIdViewModel = koinViewModelScoped(key = idOne, parameters = { parametersOf(idOne) })
val scopedVMWithSecondId: MyIdViewModel = koinViewModelScoped(key = idTwo, parameters = { parametersOf(idTwo) })
// We now have 2 instances on memory of the same ViewModel type, both inside the same Composable scope
// When one Id updates only the ViewModel with that Id will be recreated
// Each ViewModel instance has its own Id
DemoComposable(inputObject = scopedVMWithFirstId)
DemoComposable(inputObject = scopedVMWithSecondId)
}
class MyIdViewModel(private val stateSaver: SavedStateHandle, private val id: String) : ViewModel()
Use a different ViewModel injected by Koin for each item in a LazyColumn and scope them to the Composable that contains the LazyColumn
@Composable
fun DemoManyInjectedViewModelsScopedOutsideTheLazyColumn(listItems: List<Int> = (1..1000).toList()) {
val keys = rememberKeysInScope(inputListOfKeys = listItems)
LazyColumn() {
items(items = listItems, key = { it }) { item ->
val myScopedVM: MyViewModel = koinViewModelScoped(key = item, keyInScopeResolver = keys)
DemoComposable(inputObject = myScopedVM)
}
}
}
class MyViewModel(private val stateSaver: SavedStateHandle) : ViewModel()
Once you use the koinViewModelScoped
function, the same object will be restored as long as the Composable is part of the composition, even if it temporarily
leaves composition on configuration change (e.g. screen rotation, change to dark mode, etc.) or while being in the backstack.
⚠️ Note that ViewModels provided withkoinViewModelScoped
should not be created using any of the KoinkoinViewModel()
or ComposegetViewModel()
norViewModelProviders
factories, otherwise they will be retained in the scope of the screen regardless ofkoinViewModelScoped
.
To use the koinViewModelScoped
function you need to follow these 2 Koin configuration steps:
- Add a configuration module variable with the Koin factories for your ViewModels. See example here
- Initialize Koin (with the module you just created in step 1) inside the
onCreate
of your application. See example here
For a complete guide to Koin check the official documentation. Here are the setup and the Koin ViewModel docs.
Here are some sample use cases reported by the users of this library:
- ❤️ Isolated and stateful UI components like a favorite button that are widely used across the screens. This
FavoriteViewModel
can be very small, focused and only require an id to work without affecting the rest of the screen's UI and state. - 🗪 Dialog pop-ups can have their own business-logic with state that is better to isolate in a separate ViewModel but the lifespan of these dialogs might be short, so it's important to clean-up the ViewModel associated to a Dialog after it has been closed.
- 📃 A LazyColumn with a ViewModel per list item. Each item can have its own complex logic in an isolated ViewModel that will be lazily loaded when the item is visible for the first time. The ViewModel will cleared and destroyed when the item is not part of the list in the source data or the whole LazyColumn is removed.
- 📄📄 Multiple instances of the same type of ViewModel in a screen with a view-pager. This screen will have multiple sub-pages that use the same ViewModel
class with different ids. For example, a screen of holiday destinations with multiple pages and each page with its own
HolidayDestinationViewModel
.
This is handy for the typical case where you have a lazy list of items and you want to have a separate ViewModel for each item in the list, using the viewModelScoped
function.
How to use `rememberKeysInScope` to control the lifecycle of a scoped object in a Lazy* list
When using the Lazy* family of Composables it is recommended that -just above the call to the Lazy* Composable- you use rememberKeysInScope
with a list of
keys corresponding to the items used in the Lazy* Composable to obtain a KeyInScopeResolver
(it's already highly recommended in Compose that items in a Lazy* list have unique keys).
Then in the Lazy* Composable, once you are creating an item and you need a ViewModel for that item,
all you have to do is include in the call to koinViewModelScoped
the key for the current item and the KeyInScopeResolver
you previously got from rememberKeysInScope
.
With this setup, when an item of the Lazy* list becomes visible for the first time, its associated koinViewModelScoped
ViewModel will be created and even if the item is scrolled away, the scoped ViewModel will still be alive. Only once the associated key is not present anymore in the list provided to rememberKeysInScope
and the item is either not part of the Lazy* list or scrolled away, then the associated ViewModel will be cleared and destroyed.
🏷️ Example of a different ViewModel for each item in a LazyColumn and scope them to the Composable that contains the LazyColumn
@Composable
fun DemoManyInjectedViewModelsScopedOutsideTheLazyColumn(listItems: List<Int> = (1..1000).toList()) {
val keys = rememberKeysInScope(inputListOfKeys = listItems)
LazyColumn() {
items(items = listItems, key = { it }) { item ->
val myScopedVM: MyViewModel = koinViewModelScoped(key = item, keyInScopeResolver = keys)
DemoComposable(inputObject = myScopedVM)
}
}
}
class MyViewModel(private val stateSaver: SavedStateHandle) : ViewModel()
Assisted injection is a dependency injection (DI) pattern that is used to construct an object where some parameters may be provided by the DI framework and
others must be passed in at creation time (a.k.a “assisted”) by the user, in our case when the koinViewModelScoped
is requested.
Assisted injection is supported by Koin out of the box with the parametersOf()
syntax.
When you declare the ViewModel factory in your Koin Module using the viewModel {}
or the factory {}
syntax, then
you can declare arguments in the factory.
Finally, those arguments can be passed as parameters at call time from your Composable when calling koinViewModelScoped
.