From 0063055f6c9a1573af8ccf1f309b28ae9fdadb8e Mon Sep 17 00:00:00 2001 From: Arkadii Ivanov Date: Mon, 22 Jan 2024 11:24:45 +0100 Subject: [PATCH] Added Tips and Tricks section to docs --- docs/tips-tricks/composable-viewmodel.md | 320 +++++++++++++++++++++++ docs/tips-tricks/overview.md | 5 + mkdocs.yml | 5 + 3 files changed, 330 insertions(+) create mode 100644 docs/tips-tricks/composable-viewmodel.md create mode 100644 docs/tips-tricks/overview.md diff --git a/docs/tips-tricks/composable-viewmodel.md b/docs/tips-tricks/composable-viewmodel.md new file mode 100644 index 000000000..4ba66e616 --- /dev/null +++ b/docs/tips-tricks/composable-viewmodel.md @@ -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> + + 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() + + override val childStack: Value> = + 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 { + 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() + + override val childStack: Value> = + 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"]) +} +``` diff --git a/docs/tips-tricks/overview.md b/docs/tips-tricks/overview.md new file mode 100644 index 000000000..0be46ea92 --- /dev/null +++ b/docs/tips-tricks/overview.md @@ -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) diff --git a/mkdocs.yml b/mkdocs.yml index 3804b24b9..ba9787c48 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,6 +55,11 @@ nav: - Extensions for Android views: extensions/android.md - Samples: samples.md + + - Tips and Tricks: + - Overview: tips-tricks/overview.md + - Calling Composable functions with ViewModels: tips-tricks/composable-viewmodel.md + - Community: community.md # Configuration