-
-
Notifications
You must be signed in to change notification settings - Fork 85
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added Tips and Tricks section to docs
- Loading branch information
Showing
3 changed files
with
330 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,320 @@ | ||
# Calling Composable functions with ViewModels | ||
|
||
This section may be useful when migrating from the official [navigation-compose](https://developer.android.com/jetpack/compose/navigation) library to Decompose. | ||
|
||
By default, Decompose doesn't manage AndroidX [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel). When Decompose is used as root in an Activity or Fragment then by default all `ViewModels` are scoped to that Activity/Fragment. In other words, by default Decompose doesn't create separate `ViewModelStoreOwner` for every child screen. | ||
|
||
If there is a requirement to call a `Composable` function that uses a `ViewModel` (e.g. there is a `Composable` screen that can't be migrated to Decompose), then we need to create a separate `ViewModelStoreOwner` for every child screen manually. | ||
|
||
Consider there is a stack of Main and Details screens. The Main screen is a normal Decompose component. | ||
|
||
```kotlin title="Main component" | ||
import com.arkivanov.decompose.ComponentContext | ||
|
||
interface MainComponent { | ||
fun onShowDetailsClicked() | ||
} | ||
|
||
class DefaultMainComponent( | ||
componentContext: ComponentContext, | ||
private val onShowDetails: () -> Unit | ||
) : MainComponent, ComponentContext by componentContext { | ||
// Omitted code | ||
} | ||
``` | ||
|
||
The Details screen is a `Composable` function with a `ViewModel`. | ||
|
||
```kotlin title="Details screen" | ||
import androidx.compose.runtime.Composable | ||
import androidx.lifecycle.ViewModel | ||
import androidx.lifecycle.viewmodel.compose.viewModel | ||
|
||
@Composable | ||
fun DetailsContent(viewModel: DetailsViewModel = viewModel()) { | ||
// Omitted code | ||
} | ||
|
||
class DetailsViewModel : ViewModel() { | ||
// Omitted code | ||
} | ||
``` | ||
|
||
### Composable functions with ViewModels WITHOUT SavedStateHandle | ||
|
||
The fix is very simple when `SavedStateHandle` is not required. | ||
|
||
First, we need an extension function to attach `ViewModelStore` to `InstanceKeeper` of a child component. | ||
|
||
```kotlin title="Attaching ViewModelStore to InstanceKeeper" | ||
import androidx.lifecycle.ViewModelStore | ||
import androidx.lifecycle.ViewModelStoreOwner | ||
import com.arkivanov.essenty.instancekeeper.InstanceKeeper | ||
import com.arkivanov.essenty.instancekeeper.InstanceKeeperOwner | ||
import com.arkivanov.essenty.instancekeeper.getOrCreate | ||
|
||
internal fun InstanceKeeperOwner.viewModelStoreOwner(): ViewModelStoreOwner = | ||
instanceKeeper.getOrCreate(::ViewModelStoreOwnerInstance) | ||
|
||
private class ViewModelStoreOwnerInstance : ViewModelStoreOwner, InstanceKeeper.Instance { | ||
override val viewModelStore: ViewModelStore = ViewModelStore() | ||
|
||
override fun onDestroy() { | ||
viewModelStore.clear() | ||
} | ||
} | ||
``` | ||
|
||
Now we need to expose a `ViewModelStoreOwner` instead of the child component. | ||
|
||
```kotlin title="Root component" | ||
import androidx.lifecycle.ViewModelStoreOwner | ||
import com.arkivanov.decompose.ComponentContext | ||
import com.arkivanov.decompose.router.stack.ChildStack | ||
import com.arkivanov.decompose.router.stack.StackNavigation | ||
import com.arkivanov.decompose.router.stack.childStack | ||
import com.arkivanov.decompose.router.stack.pushNew | ||
import com.arkivanov.decompose.value.Value | ||
import com.arkivanov.sample.app.RootComponent.Child | ||
import com.arkivanov.sample.app.RootComponent.Child.ListChild | ||
import com.arkivanov.sample.app.RootComponent.Child.MainChild | ||
import kotlinx.serialization.Serializable | ||
|
||
interface RootComponent { | ||
|
||
val childStack: Value<ChildStack<*, Child>> | ||
|
||
sealed class Child { | ||
class ListChild(val component: MainComponent) : Child() | ||
class MainChild(val component: ViewModelStoreOwner) : Child() // ViewModelStoreOwner instead of component | ||
} | ||
} | ||
|
||
class DefaultRootComponent( | ||
componentContext: ComponentContext, | ||
) : RootComponent, ComponentContext by componentContext { | ||
|
||
private val nav = StackNavigation<Config>() | ||
|
||
override val childStack: Value<ChildStack<*, Child>> = | ||
childStack( | ||
source = nav, | ||
serializer = Config.serializer(), | ||
initialConfiguration = Config.Main, | ||
) { config, ctx -> | ||
when (config) { | ||
is Config.Main -> | ||
ListChild( | ||
component = DefaultMainComponent( | ||
componentContext = ctx, | ||
onShowDetails = { nav.pushNew(Config.Details) }, | ||
), | ||
) | ||
|
||
is Config.Details -> | ||
MainChild( | ||
component = ctx.viewModelStoreOwner(), // Attach a ViewModelStore to the child InstanceKeeper | ||
) | ||
} | ||
} | ||
|
||
@Serializable | ||
private sealed interface Config { | ||
@Serializable | ||
data object Main : Config | ||
|
||
@Serializable | ||
data object Details : Config | ||
} | ||
} | ||
``` | ||
|
||
Not we just have to provide the child `ViewModelStoreOwner` via `LocalViewModelStoreOwner` provider. | ||
|
||
```kotlin | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.runtime.CompositionLocalProvider | ||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner | ||
import com.arkivanov.decompose.extensions.compose.stack.Children | ||
import com.arkivanov.sample.app.RootComponent.Child.MainChild | ||
import com.arkivanov.sample.app.RootComponent.Child.ListChild | ||
|
||
@Composable | ||
fun RootContent(component: RootComponent) { | ||
Children(stack = component.childStack) { (_, child) -> | ||
when (child) { | ||
is ListChild -> MainContent(child.component) | ||
|
||
is MainChild -> | ||
CompositionLocalProvider(LocalViewModelStoreOwner provides child.component) { | ||
DetailsContent() // The Details screen now has its own ViewModelStore | ||
} | ||
} | ||
} | ||
} | ||
``` | ||
|
||
### Composable functions with ViewModels WITH SavedStateHandle | ||
|
||
Things are a bit more complicated when injecting `SavedStateHandle` is required. | ||
|
||
Since on Android the state saving works via `Bundle`, we'll need to create a serializer for it. | ||
|
||
```kotlin title="BundleSerializer" | ||
import android.os.Bundle | ||
import android.os.Parcel | ||
import kotlinx.serialization.KSerializer | ||
import kotlinx.serialization.builtins.ByteArraySerializer | ||
import kotlinx.serialization.descriptors.SerialDescriptor | ||
import kotlinx.serialization.encoding.Decoder | ||
import kotlinx.serialization.encoding.Encoder | ||
|
||
internal object BundleSerializer : KSerializer<Bundle> { | ||
private val byteArraySerializer = ByteArraySerializer() | ||
override val descriptor: SerialDescriptor = byteArraySerializer.descriptor | ||
|
||
override fun serialize(encoder: Encoder, value: Bundle) { | ||
encoder.encodeSerializableValue(byteArraySerializer, value.toByteArray()) | ||
} | ||
|
||
private fun Bundle.toByteArray(): ByteArray { | ||
val parcel = Parcel.obtain() | ||
try { | ||
parcel.writeBundle(this) | ||
return parcel.marshall() | ||
} finally { | ||
parcel.recycle() | ||
} | ||
} | ||
|
||
override fun deserialize(decoder: Decoder): Bundle = | ||
decoder.decodeSerializableValue(byteArraySerializer).toBundle() | ||
|
||
private fun ByteArray.toBundle(): Bundle { | ||
val parcel = Parcel.obtain() | ||
try { | ||
parcel.unmarshall(this, 0, size) | ||
parcel.setDataPosition(0) | ||
return requireNotNull(parcel.readBundle()) | ||
} finally { | ||
parcel.recycle() | ||
} | ||
} | ||
} | ||
``` | ||
|
||
Now we need a special reusable `ViewModelStoreComponent` that will implement `ViewModelStoreOwner` interface, and also manage argument passing and state saving for `ViewModels`. | ||
|
||
```kotlin title="ViewModelStoreComponent" | ||
import android.os.Bundle | ||
import androidx.lifecycle.HasDefaultViewModelProviderFactory | ||
import androidx.lifecycle.Lifecycle | ||
import androidx.lifecycle.LifecycleRegistry | ||
import androidx.lifecycle.SavedStateViewModelFactory | ||
import androidx.lifecycle.ViewModelProvider | ||
import androidx.lifecycle.ViewModelStoreOwner | ||
import androidx.savedstate.SavedStateRegistry | ||
import androidx.savedstate.SavedStateRegistryController | ||
import androidx.savedstate.SavedStateRegistryOwner | ||
import com.arkivanov.decompose.ComponentContext | ||
|
||
class ViewModelStoreComponent( | ||
ctx: ComponentContext, | ||
args: Bundle = Bundle(), | ||
) : ViewModelStoreOwner by ctx.viewModelStoreOwner(), | ||
SavedStateRegistryOwner, | ||
HasDefaultViewModelProviderFactory { | ||
|
||
private val savedStateRegistryController = SavedStateRegistryController.create(this) | ||
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry | ||
override val lifecycle: Lifecycle = LifecycleRegistry(provider = this) | ||
|
||
override val defaultViewModelProviderFactory: ViewModelProvider.Factory = | ||
SavedStateViewModelFactory(application = null, owner = this, defaultArgs = args) | ||
|
||
init { | ||
savedStateRegistryController.performRestore(ctx.stateKeeper.consume(KEY_SAVED_STATE, BundleSerializer)) | ||
|
||
ctx.stateKeeper.register(KEY_SAVED_STATE, BundleSerializer) { | ||
Bundle().also(savedStateRegistryController::performSave) | ||
} | ||
} | ||
|
||
private companion object { | ||
private const val KEY_SAVED_STATE = "saved_state" | ||
} | ||
} | ||
``` | ||
|
||
Finally, we can use `ViewModelStoreComponent` in the Root component as follows. | ||
|
||
```kotlin title="Using ViewModelStoreComponent" | ||
import androidx.core.os.bundleOf | ||
import com.arkivanov.decompose.ComponentContext | ||
import com.arkivanov.decompose.router.stack.ChildStack | ||
import com.arkivanov.decompose.router.stack.StackNavigation | ||
import com.arkivanov.decompose.router.stack.childStack | ||
import com.arkivanov.decompose.router.stack.pushNew | ||
import com.arkivanov.decompose.value.Value | ||
import com.arkivanov.sample.app.RootComponent.Child | ||
import com.arkivanov.sample.app.RootComponent.Child.ListChild | ||
import com.arkivanov.sample.app.RootComponent.Child.MainChild | ||
import kotlinx.serialization.Serializable | ||
|
||
class DefaultRootComponent( | ||
componentContext: ComponentContext, | ||
) : RootComponent, ComponentContext by componentContext { | ||
|
||
private val nav = StackNavigation<Config>() | ||
|
||
override val childStack: Value<ChildStack<*, Child>> = | ||
childStack( | ||
source = nav, | ||
serializer = Config.serializer(), | ||
initialConfiguration = Config.Main, | ||
) { config, ctx -> | ||
when (config) { | ||
is Config.Main -> | ||
ListChild( | ||
component = DefaultMainComponent( | ||
componentContext = ctx, | ||
onShowDetails = { text -> | ||
nav.pushNew(Config.Details(text = text)) // Pass text to the Details screen | ||
}, | ||
), | ||
) | ||
|
||
is Config.Details -> | ||
MainChild( | ||
component = ViewModelStoreComponent( // Pass ViewModelStoreComponent as ViewModelStoreOwner | ||
ctx = ctx, | ||
args = bundleOf( | ||
"text" to config.text, // Pass arguments to DetailsViewModel | ||
) | ||
), | ||
) | ||
} | ||
} | ||
|
||
@Serializable | ||
private sealed interface Config { | ||
@Serializable | ||
data object Main : Config | ||
|
||
@Serializable | ||
data class Details(val text: String) : Config | ||
} | ||
} | ||
``` | ||
|
||
Now we are able to inject `SavedStateHandle` into `DetailsViewModel`. | ||
|
||
```kotlin title="Using SavedStateHandle" | ||
import androidx.lifecycle.SavedStateHandle | ||
import androidx.lifecycle.ViewModel | ||
import androidx.lifecycle.viewmodel.compose.viewModel | ||
|
||
class ItemDetailsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { | ||
val text: String = checkNotNull(savedStateHandle["text"]) | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
# Tips and Tricks | ||
|
||
This section contains various hints, tips and tricks that can be useful when using Decompose. | ||
|
||
- [Calling Composable functions with ViewModels](composable-viewmodel.md) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters