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.