6

Do-It-Yourself Compose Multiplatform Navigation with Decompose

 1 year ago
source link: https://proandroiddev.com/diy-compose-multiplatform-navigation-with-decompose-94ac8126e6b5
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
1*5RZdA2XmHWJiIEv50uNetQ.png

Do-It-Yourself Compose Multiplatform Navigation with Decompose

How to survive configuration changes, survive process death and scope ViewModels without going all-in with Decompose 🫢

Decompose recently went 1.0.0 🙌. I think it’s a good opportunity to share my specific use case with Decompose where I use it to share navigation logic between Android and Desktop, without going all in with Decompose’s Component pattern.

My app is targeting Android, iOS, Desktop and The Web. However, I only want to share navigation between my Android and Desktop clients as they are the only ones that use Compose Multiplatform. (on iOS I use SwiftUI and on the web, it’s just React without react wrappers with just plain-old vanilla typescript)

Here are some of the problems you would run into - in extracting the navigation to the shared code

  1. Abstraction of common navigation.
  2. Retaining and scoping your view models
  3. Restoring the view models state after the process death.

Doesn’t Decompose solve all these problems? Why not go all in?

This was really just a preference. I wanted to do my own architecture for my app and an inheritance-based component approach wasn’t my forte. Luckily Decompose allows you to use parts of it and couple your navigation with business logic (if you want to). Decompose provides you with extensive customisation options to tailor the library to your needs. That's what I ended up doing at the end

The Goal 🥅

This is what we will be working towards.

sealed class Screen: Parcelable {
@Parcelize object List : Screen()
@Parcelize data class Details(val detail: String) : Screen()
}


@Composable
fun ListDetailScreen() {
val router: Router<Screen> = rememberRouter(listOf(List))

RoutedContent(
router = router,
animation = stackAnimation(slide()),
) { screen ->
when (screen) {
List -> ListScreen(onSelect { detail -> router.push(detail) } )
is Details -> DetailsScreen(screen.detail)
}
}
}

@Composable
fun ListScreen(onSelect: (detail: String) -> Unit) {
val viewModel: ListViewModel =
rememberViewModel { savedState -> ListViewModel(savedState) }

val state: ListState by viewModel.states.collectAsState()

LazyColumn {
items(state.items) { item ->
TextButton(onClick = { onSelect(item) } ) {
Text(text = item)
}
}
}
}

@Composable
fun DetailScreen(detail: String) {
val viewModel: ListViewModel =
rememberViewModel(key = detail) { DetailsViewModel(detail) }

val state: DetailsState by viewModel.states.collectAsState()

Toolbar(title = detail)
Text(state.descriptions)
}

There are 3 main parts to work on

  1. A Router (e.g: Router<Screen>) that keeps screen configurations (e.g:Screen) inside of a FILO stack
  2. A @Composable for each configuration and a top-level @Composable to switch between these (ListScreen, DetailsScreen and ListDetailsScreen, respectively)
  3. a ViewModel instance (e.g:ListViewModel, DetailsViewModel) that survives configuration changes, is scoped to the router (gets cleared when the user leaves the screen) and is able to restore its state from process death

Problem 1: Abstracting Shared Navigation

This problem was solved for me by Decompose from the get-go. The author of Decompose,

perfectly summarises everything you need, under 1̶0̶0̶ 30 lines of code. I highly recommend giving this a read first — before continuing to read mine.

There are three crucial pieces we use from Decompose to make things nicer for ourselves, these are

  1. StackNavigator<C>; which lets you push or pop different configurations
  2. ChildStack<C,ComponentContext>& rememberChildStack; which stores all your configurations in a “FILO” stack
  3. @Composable Children(stack: ChildStack, ..); a @Composablefunction that lets you hoist different screens for each configuration (+ some fancy animations for the transitions between screens)

Router API

I’ve extended Arkadii’s implementation, by wrapping 🍬 both the StackNavigator<C>and State<ChildStack<C,ComponentContext>>in a Router<C>. This Router name is inspired by Conductor

import com.arkivanov.essenty.parcelable.Parcelable
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.router.stack.ChildStack
import com.arkivanov.decompose.router.stack.StackNavigation

class Router<C : Parcelable>(
private val navigator: StackNavigation<C>,
val stack: State<ChildStack<C, ComponentContext>>,
) : StackNavigation<C> by navigator

Note that the configuration (template type C) has to be a Parcelable in order to be part of the ChildStack. This is from Essenty (from the same author)

I needed another wrapper function 🍬🍬 to provide the router to the children by wrapping them over @Composable Children(stack: ChildStack, ..) with

import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.arkivanov.decompose.extensions.compose.jetbrains.stack.Children
import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimation

@OptIn(ExperimentalDecomposeApi::class)
@Composable
fun <C : Parcelable> RoutedContent(
router: Router<C>,
modifier: Modifier = Modifier,
animation: StackAnimation<C, ComponentContext>? = null,
content: @Composable (C) -> Unit,
) {
Children(
stack = router.stack.value,
modifier = modifier,
animation = animation,
) { child ->
CompositionLocalProvider(LocalComponentContext provides child.instance) {
content(child.configuration)
}
}
}

Now that we have a Router and a way to provide a router, then I needed a way to hook a new router any Composables. For which I have yet another wrapper 🍬🍬🍬 that wraps around Decompose’s rememberChildStack

@Composable
inline fun <reified C : Parcelable> rememberRouter(
stack: List<C>,
handleBackButton: Boolean = true
): Router<C> {
val navigator: StackNavigation<C> = remember { StackNavigation() }

val childStackState: State<ChildStack<C, ComponentContext>> = rememberChildStack(
source = navigator,
initialStack = { stack },
key = C::class.getFullName(),
handleBackButton = handleBackButton
)

return remember { Router(navigator = navigator, stack = childStackState) }
}

That’s a lot of wrappers 🍬🍬🍬. You might be asking, how do all these wrappings make the use-site simpler?

Using the Router API

Let's take a look at a simple list-detail screen. I’ll use the same example screen configurations from the original article.

sealed class Screen: Parcelable {
@Parcelize object List : Screen()
@Parcelize data class Details(val detail: String) : Screen()
}

A simple list-details screen now looks like this

@Composable
fun ListDetailScreen() {
val router: Router<Screen> = rememberRouter(listOf(List))

RoutedContent(
router = router,
animation = stackAnimation(slide()),
) { screen ->
when (screen) {
is List -> ListScreen(onSelect { detail -> router.push(detail) } )
is Details -> DetailsScreen(screen.detail)
}
}
}

Neat stuff 👍 On to the next problem

Problem 2: Retaining & Scoping Your Instances

Your Android activity goes through a lifecycle (and if you used fragments, even more lifecycles!). Sometimes you want to retain an instance (e.g a view model) over multiple life-cycle stages without losing its hard-earned state. If these instances themselves can't fit into a Bundle, or as a Parcelable — you can't get away with just using rememberSavable, and this is typically where you see most people use aandroidx.lifecycle.ViewModel and let either Activity, Fragment or Application hold on to these instances while the lifecycle does its thing.

ViewModels API

Decompose uses Essenty (from the same author) which creates a multiplatform abstraction to achieve this, and this is called InstanceKeeper. To hand off our instances, all we need to do is to implement the InstanceKeeper.Instance on objects that you want to be retained.

import com.arkivanov.essenty.instancekeeper.InstanceKeeper

open class ViewModel() : InstanceKeeper.Instance {
override fun onDestroy() { // Clean up }
}

Given that we are not using Decompose as intended, we need to wire this up ourselves.

@Composable
inline fun <reified T : ViewModel> rememberViewModel(
key: Any = T::class,
crossinline block: @DisallowComposableCalls () -> T
): T {
val component: ComponentContext = LocalComponentContext.current
val packageName: String = T::class.getFullName()
val viewModelKey = "$packageName.view-model"
return remember(key) {
component.instanceKeeper.getOrCreate(viewModelKey) { block() }
}
}

Decompose’s ComponentContext does all the heavy lifting for us here. It manages these instances by scoping them to each of the child components and clearing these instances when they are no longer on the stack.

Using ViewModels API

Declare your screen’s ViewModel by extending base ViewModel

class ListViewModel() : ViewModel() 

Then use it with rememberViewModel

@Composable
fun ListScreen() {
val viewModel: ListViewModel = rememberViewModel { ListViewModel() }
}

If you need to reissuenew instances when parameters change, you can pass in a key

@Composable
fun DetailScreen(detail: String) {
val viewModel = rememberViewModel(key = detail) { ListViewModel(detail) }
}

Pretty neat stuff 👍 On to the next problem

Problem 3: Surviving Process Death

You may perish but that doesn’t mean your view state should ☠️! Android can kill your process if it feels like it — when nobody is watching. So you have to deal with that from time to time. This is typically where you see most people use androidx.lifecycle.SavedStateHandle to save a state before death and restore the state after the process restarts. We need a similar abstraction on the multiplatform side to pull this off.

SavedState API

First off, we need to create a wrapper to wrap our screen state. I’ll call this SavedState. Note that we can retain state across process-death only if your state is Parcelable.

import com.arkivanov.essenty.parcelable.Parcelable
import com.arkivanov.essenty.parcelable.Parcelize

@Parcelize
data class SavedState(val value: Parcelable): Parcelable

We also need a handle to keep a single instance of this wrapped state to survive config changes. I’ll call this handle SavedStateHandle

@Parcelize
data class SavedState(val value: Parcelable): Parcelable

class SavedStateHandle(default: SavedState?): InstanceKeeper.Instance {
private var savedState: SavedState? = default
val value: Parcelable? get() = savedState
fun <T: Parcelable> get(): T? = savedState?.value as? T?
fun set(value: Parcelable) { this.savedState = SavedState(value) }
override fun onDestroy() { savedState = null }
}

Essenty'sStateKeeper is the multiplatform abstraction of androidx.lifecycle.SavedStateHandle and this is available to us via ComponentContext. Incorporating that into our little API only takes a few more modifications. We need to modify the rememberViewModel hook that we made earlier to pass in this SavedStateHandle

@Composable
inline fun <reified T : ViewModel> rememberViewModel(
key: Any = T::class,
crossinline block: @DisallowComposableCalls (savedState: SavedStateHandle) -> T
): T {
val component: ComponentContext = LocalComponentContext.current
val stateKeeper: StateKeeper = component.stateKeeper
val instanceKeeper: InstanceKeeper = component.instanceKeeper

val packageName: String = T::class.getFullName()
val viewModelKey = "$packageName.viewModel"
val stateKey = "$packageName.savedState"

val (viewModel, savedState) = remember(key) {
val savedState: SavedStateHandle = instanceKeeper
.getOrCreate(stateKey) { SavedStateHandle(stateKeeper.consume(stateKey, SavedState::class)) }
val viewModel: T = instanceKeeper.getOrCreate(viewModelKey) { block(savedState) }
viewModel to savedState
}

LaunchedEffect(Unit) {
if (!stateKeeper.isRegistered(stateKey))
stateKeeper.register(stateKey) { savedState.value }
}

return viewModel
}

Decompose and Essenty StateKeeper does all the heavy lifting for us here. It invokes the StateKeeper to grab the state saved in the SavedStateHandle, and resupply it back to SavedStateHandle (we will grab this from the state handle to restore the default state in our ViewModel next).

Perfection! 👌

Using SavedState API

If you want your state to survive the process death ☠️, just use the StateKeeper (or leave it if you don't want that)

@Composable
fun ListScreen() {
val viewModel: ListViewModel =
rememberViewModel { savedState -> ListViewModel(savedState) }
}

Then we need to use it when we instantiate our view model, which we can do by simply passing it through the constructor as a parameter

class ListViewModel(savedState: SavedStateHandle) : ViewModel() {
private val defaultState: ListState = savedState.get() ?: ListState()

val states: StateFlow<ListState> by lazy {
moleculeFlow() { .. } // or however you want to manage your state
.onEach { state -> savedState.set(state) }
.stateIn(this, Lazily, defaultState)
}
}

Pretty neat stuff 👍

And that’s a wrap! 🌯

This is how I use Decompose in my app 😎. I’m sure there are things that I haven't thought about that could foil my plans — but this does seem to work and solve all the typical problems you would run into when trying to bring navigation logic over to the shared layer in Compose Multiplatform apps.

Check out the sample app here

1*g0vNavhGQrhpbgo26Qo0Jg.gif
1*OZTV3Z4D6FOx02CRMt8N_w.gif
Sample android and desktop apps

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK