Nerd For Tech
Published in

Nerd For Tech

Simplified navigation between Composables of Jetpack Compose using Simple-Stack

Have you ever wanted to navigate between two Composables? Do you wish it were as easy as backstack.goTo(SomeScreen(arg1, arg2)) and you would go there?

Good news, because now that seems to be possible with Simple-Stack’s Compose integration — which I consider BETA for now, but nonetheless, it’s possible.

What it looks like

Initial setup and dependencies:

First, you would add the dependencies:

implementation 'com.github.Zhuinden:simple-stack:2.6.0'
implementation 'com.github.Zhuinden:simple-stack-extensions:2.2.0'
implementation 'com.github.Zhuinden:simple-stack-compose-integration:0.2.0'

And of course, you’d add Jitpack (not JCenter):

// build.gradle
allprojects {
repositories {
// ...
maven { url "https://jitpack.io" }
}
// ...
}

And most importantly, you’d enable Compose:

compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
useIR = true
}
buildFeatures {
compose true
}

Setup

To use Simple-Stack within an Activity, we use the Navigator, as always.

The Compose integration is merely a different implementation of StateChanger, along with a helper that allows exposing the Backstack as a CompositionLocal for children within the composable tree.

class MainActivity : AppCompatActivity() {
private val composeStateChanger = AnimatingComposeStateChanger()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val backstack = Navigator.configure()
.setScopedServices(DefaultServiceProvider())
.setStateChanger(AsyncStateChanger(composeStateChanger))
.install(this, androidContentFrame, History.of(FirstKey()))

setContent {
BackstackProvider(backstack) {
MaterialTheme {
Box(Modifier.fillMaxSize()) {
composeStateChanger.RenderScreen()
}
}
}
}
}

override fun onBackPressed() {
if (!Navigator.onBackPressed(this)) {
super.onBackPressed()
}
}
}

This implementation of StateChanger is able to switch between keys that extend from DefaultComposeKey.

In the samples, I generally used the following key superclass:

abstract class ComposeKey : DefaultComposeKey(), Parcelable, DefaultServiceProvider.HasServices {
override val saveableStateProviderKey: Any = this

override fun getScopeTag(): String = javaClass.name

override fun bindServices(serviceBinder: ServiceBinder) {
}
}

Please note that using the key itself as the saveableStateProviderKey requires the key to be Parcelable, immutable, and must implement equals/hashCode.

And once this “boring” part of the setup is done (which you only need to do once), a screen’s definition looks like this:

@Immutable
@Parcelize
data
class FirstKey(val title: String) : ComposeKey() {
@Composable
override fun ScreenComposable(modifier: Modifier) {
FirstScreen(title, modifier)
}
}

Where FirstScreen is a regular everyday Composable:

@Composable
fun FirstScreen(title: String, modifier: Modifier = Modifier) {
// ...
}

Navigating from Composable to another Composable

To navigate between composables, we just need access to the backstack, and navigate to the other screen. Simple.

@Composable
fun FirstScreen(title: String, modifier: Modifier = Modifier) {
val backstack = LocalBackstack.current
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = {
backstack.goTo(SecondKey())
},
content = {
Text(title)
}
)
}
}

(Theoretically it would be possible to define a top-level val backstack: Backstack get() = LocalBackstack.current but it would be rather intrusive namespace-wise, so that didn’t happen.)

Storing data across configuration changes

To store data across config changes, the approach provided by Simple-Stack is ScopedServices (and isn’t particularly different from the approach outlined here).

For example,

@Immutable
@Parcelize
data class FirstKey(val title: String) : ComposeKey() {
@Composable
override fun ScreenComposable(modifier: Modifier) {
FirstScreen(title, modifier)
}
// from DefaultServiceProvider.HasServices
override fun bindServices(serviceBinder: ServiceBinder) {
with(serviceBinder) {
add(FirstModel())
}
}

}

Where FirstModel is just a regular class:

class FirstModel {
// ...
}

And to simplify the call to remember { backstack.lookup<FirstModel>() }, the function called rememberService<T> is provided.

@Composable
fun FirstScreen(title: String, modifier: Modifier = Modifier) {
val backstack = LocalBackstack.current
val firstModel = rememberService<FirstModel>() // ...

As for all scoped services, the ability to implement ScopedServices.Registered, ScopedServices.Activated, ScopedServices.HandlesBack and Bundleable are possible ways to intercept important navigation-related lifecycle callbacks (and state persistence support).

How does it work?

The code for the animating state changer (which can do basic segue animation or cross-fade animation depending on the direction) can be found here, but it’s tricky enough that it’d require its own article, dedicated to Jetpack Compose on its own 😅 so expect that sometime later.

Conclusion

Overall, we’ve managed to reduce the complexity of defining composables and their navigation to defining a simple “key” class, that is also associated with a Composable of its own.

@Immutable
@Parcelize
data class FirstKey(val title: String) : ComposeKey() {
@Composable
override fun ScreenComposable(modifier: Modifier) {
// ...
}
}

For that, we get the ability to freely navigate between screens, store data across configuration changes, persist state across process death, and most importantly: pass Parcelable classes as arguments, as all routes are simple immutable (parcelable) data classes.

However, it’s 0.2.0 for a reason: it’s all new, and while there is simple transition support via Modifiers, it might be a bit low-level compared to more complex animations, so things might still be subject to change.

Also, it’s currently Android-only, no desktop support as of yet.

Still, I think it’s a good start. The source code is available here.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store