Implementing a nested screen history with Jetpack Compose

Zsolt Kocsi
ProAndroidDev

Declarative routing, back stacks, and back navigation

Notice

This article was written in December 2019, in the very early days of Jetpack Compose.

I’m keeping it here for its value in the thought process.

You can check what happened since:

Motivation and disclaimer

Pressing the back button in your Compose app at the time of writing this article will result in your app closing. Compose is currently at 0.1.0-dev03, and my understanding is that back press handling is further down the roadmap (please do correct this if you know otherwise).

But as I was playing around with it, it occurred to me that I already fought this battle once when implementing a Fragments-style back stack functionality in RIBs, and it’s not that much magic: back stack manipulation is in essence list operations. The magical part is when the framework figures out how the changes in a list should be mapped to attach / detach operations. In the case of Compose this comes organically and without any extra effort on client code’s part.

If you have declarative routing (will explain below), and store the current routing, then building a list of these routing tokens shouldn’t be a problem either. The only parts that puzzled me were how to connect it with Android’s onBackPress() event, and how to have a top-to-bottom propagation of the back press event in a world where otherwise you do the opposite: properties down, events up.

I started experimenting and managed to make something working, which was all worth for the fun part.

I’m a big proponent of a single-Activity approach to writing apps (this is despite my initial fears caused by years of conditioning to map screens to Activities), and I’ve been working with one (RIBs) for the past one and half, so naturally I was inclined to experiment in this direction. After all, Compose seems to be great working with tree structures in general, not just UI, and I like the idea of pushing its boundaries to see what’s possible. I understand if it’s not for everyone though, but hopefully there are some fun to be had exploring the process.

Peek at the end result

We’ll be arriving at a solution that is as easy to use as:

We can then use this on any level of our Composable tree to achieve nested screen history.

GitHub project and sample apps

You can find the project at https://github.com/zsoltk/compose-router

It has the core functionality as well as two sample apps.

Both are single-Activity, Compose-only implementations that have nested screen history. You can go back in this history simply by pressing the back button.

See these gifs of demo usage below.

One is a “lifelike” app based on the examples used throughout the article:

(If you have trouble viewing, here’s a direct link: https://i.imgur.com/4h22NyZ.gif)

The other is a nested container example with generated levels. The code might be more difficult to understand, but the app itself demonstrates the whole screen history very nicely:

(If you have trouble viewing, here’s a direct link: https://i.imgur.com/w3Lr2IE.gif)

In this article, we will discuss the thought process of arriving to the solutions that makes the above demo apps work.

Let’s see how!

Prerequisites

This article is not an intro to Compose — there are many, many already, along with brilliant talks — and assumes at least a basic understanding of how you would use it to create an app. At this point, you should be familiar with the base building blocks and the +state expression.

Please see basics at the official site: https://developer.android.com/jetpack/compose

Having looked at the Jetnews app is also an advantage, but not a requirement.

Declarative, explicit Routing

Before we can talk about backtracking in the history of screen states, first we need to see how we even describe the current one.

Imagine that you are on some level in your app hierarchy where you are branching what to show. If you are in a gallery screen, you could be branching which exact photo you are showing on full screen. If you are in the main content area of your app, you could be branching which main screen to show currently.

This branching in an imperative world is usually done implicitly, without much fuss or central pieces, and can differ in implementation greatly even inside the same app codebase. In a declarative world however, we can have first class support for it.

An interesting thing is that though this branching usually describes the UI you are seeing, but the whole thing can also be thought of as something on the logical level. For example, if you are at the root of your application, you would be branching to either a logged out or a logged in state. This is not a UI-first approach in this case, but the UI will certainly follow this branching.

This is routing.

Maybe you were already using this pattern, just didn’t put a name on it. The Jetnews app for example uses this to describe top-level screen being shown:

And then the actual branching:

But we don’t need to stop here and apply this pattern for picking only the top-level screen of our app! Routing lends itself on every level of a tree structure. For example, using the previous examples I described above, we could do:

This is very similar to what RIBs is doing (wink-wink: R = Router) if you check this image from Uber. (The issue there is that they do it imperatively. One of the improvements in Badoo RIBs is that it has declarative routing, which looks a lot closer to what we’re dealing with now in Compose).

Routing state

Having defined the sealed classes of possible branches, we can store the current routing, and use it to compose other @Composables. Using the gallery as an example:

At this point you can also change the state of routing by making a new assignment, which will trigger a recompose. For example:

What’s missing so far, is that there’s no history of previous routing states, so there’s nothing to go back to. Even in Jetnews whenever you hit back, the app closes. The only provided way is to use the top app bar’s back icon, but it’s hardwired to navigate to the home screen, so it’s not exactly a back operation.

Let’s fix that and add a support structure for history!

A very simple back stack

Let’s make a very simple implementation working on generic <T> items:

Use the back stack

Instead of holding a single instance of Routing as an effect, let’s change that to an instance of our BackStack<T>:

We can keep branching on routing with very little change, just check last element in the back stack:

But now instead of changing routing directly, we manipulate backStack:

As BackStack<T> is a mutable structure annotated with @Model, its operations that modify the elements list will trigger a recompose wherever it’s used.

Cool!

So we now have a way to manipulate screen state history programmatically.

But we’re still missing a way to trigger popping this history automatically from back presses.

Poor man’s back press handling

Looking at our Activity, setting the content and back press handling are distant from each other:

There’s no way to just forward the onBackPressed() to our Composable, as there’s nothing to invoke a method on.

And this is right, remember, we’re doing declarative UI. Properties down, events up.

If until now it was smooth and nice, now here comes an admittedly ugly part. Bear with me, it will be temporary. What if we represent back press as a property?

What, how can an event be a property?

Well, it’s not the event itself, but two distinct states that represent it: first, that now we have a back press event to deal with, and second, when it’s been handled.

Let’s create a Kotlin object to store this state and mark it with @Model to trigger a recompose upon changes:

Let’s change our Activity so that this object is passed to our Composable, and the value changed in onBackPressed():

So now in Root.Content we have:

And we will forward this object down the Composable tree by passing it as a first parameter to all @Composable functions.

Reacting to onBackPressed()

So now that we have both the tool to manipulate a back stack (our BackStack<T> class) and the information when to do it (the BackPress.triggered property), we can finally connect the two:

We need to set triggered back to false so that when it becomes true again on the next back press, it will actually trigger another recompose. The downside is that setting it to false also causes a recompose, mainly for nothing ¯\_(ツ)_/¯

Multiple levels of back stack handling

Imagine you have an app composed of multiple levels, each with their own back stack:

Generally speaking you probably want the back press to cause the popping of the back stack on the deepest level possible.

For example, in the above case this deepest level is C, because D has only its default element, and if we popped that, it didn’t have any information to base its routing choice upon.

In the case of C, we can do 3 pops until we reach the default element and can’t go further. At that point, we’ll want B to pop once on the next back press, resulting in this tree:

After reaching this state, the next back press should trigger the Android default action (probably finishing the Activity) unless some action pushes more elements to any of the back stacks before that.

So not only we have to somehow get the back press event from the outside world (Activity) right to the deepest level of our Compose tree, but then we’ll have to bubble it up as a fallback if it cannot be handled on the current levels.

How can we implement such a mechanism?

Our pop() operation returns false whenever the back stack contains only a single element, so we can check that and call our fall back cantPopBackStack() method:

But this in itself won’t be enough: if every block handles the back press on its own, it will never reach the composed children!

We want the children to handle it first if they can, and only trigger our local logic as a fallback.

So rather, let’s pass modify the fall back mechanism of the children to invoke the above block:

This is a step forward in that we finally process it in a bottom-to-top order. BackPress info is passed down the tree, and the cantPopBackStack event bubbles up afterwards if needed.

However, further problems arise at this point.

Problem #1: at some point we do need to branch on the BackPress.triggered value. The above snippet only passes it down the tree, but we can’t do that forever.

So what we need to do is to add some logic on levels that do not have a back stack and also don’t compose any other blocks that do (leaf composables). We can do this by:

And the problem is that it’s brittle: if at any point we forget to do this, the whole back press handling process will halt at that point, as nothing will propagate the cantPopBackStack() upwards.

Problem #2: delegating to multiple child Composables would look like this:

But this means that SomeChild1 will not just have precedence in the handling, but also that the cantPopBackStack fallback will not even consider SomeChild2. If SomeChild1 couldn’t handle the back press, we right away skip other children and fall back to the parent.

To fix this, we need to keep count how many of the composed children failed so far. Only when all of them did should we proceed with popping the back stack on our current level with parent propagation:

The problem — beyond ugliness — is again that it’s brittle. We need to remember to maintain the correct count of children for it to work. If the value in nbChildren is any less than the actual number we will definitely skip the rest. If it’s greater, we we’ll never fall back to current level back stack popping and just get stuck.

Or you know, you could just use an Ambient

I was just about to call it a day at this point and put aside this experiment reaching uncomfortable barriers. I was thinking all the above is maybe something the compiler could help with some day, both to generate the necessary blocks and to make them hidden, relieving us from the burden of passing the BackPress object and the cantHandleBackPress lambdas all around. But not something you would probably want to do manually.

And it was around this time that I showed this project to my colleague, Andrei Shikov, who had a brilliant idea: why not try to do the whole thing with Ambients? And his awesome input flipped the whole thing around.

Ok, what is an Ambient, and how is it relevant?

This is where the fun begins! In quick terms, an Ambient lets you pass dependencies through the tree without having to put them explicitly in the method signature. It’s just there in the background (hence ambient), and you can grab it whenever you need it, skipping those levels where you don’t. (For more information, check official documentation)

So what if we provided back stack handling as an Ambient, and only access it where it’s needed? We could get rid of those pesky backPress and cantPopBackStack methods passed through every Composable.

Another crucial feature is that you can override an Ambient at any point:

We could leverage this fact, and use the overriding functionality to store the fall back mechanism of the current level, so that:

Let’s add a simple class to implement handling on a single level:

What this does is it has a list of lambdas that return a Boolean (this will be the results of back handling in children), and upon the handleBackClick() call it will delegate to asking them.

We will use this to keep track of children, and delegate the back press to them until one of them reports success.

We’ll define our Ambient as a top level val:

And this will be our Composable leveraging it:

Notice that the cantPopBackStack() method is gone! We’re not calling the passed down method on fallback. Instead, we register ourselves with the parent, who will call down to us (and other children) in this line, and automatically fall back to its own popping if needed:

And since the whole expression is wrapped in another lambda, it’s providing a true/false value when evaluated by the parent.

The whole thing is outside of the usual approach of properties down, events up. We game the system a bit by providing a two-way communication between distant levels, allowing children to manipulate a parent directly (registering in the parent’s handler list), and allowing these parents to trigger children also directly via downstream.handle().

First test ride!

Let’s see how easy client code has now become! On any level where we need automatic back press driven back stack functionality, all we need to do is to surround it with our new BackHandler Composable, and both the back stack and the back press handling is automatically provided:

This is pretty amazing! We can skip the whole ritual throughout all the layers where the functionality is not needed, and just add another BackHandler whenever we need it again.

Root integration

Since the BackHandler Composable above works with a back stack, we need a simplified version of it that we can use at our integration point in the Activity. This is almost the same as before, minus the upstream part (we’re on top level) and the back stack:

And this is how our integration point looks now:

Notice how we could finally get rid of the BackPress object too, and instead of manually calling finish(), we can call super.onBackPressed(), since we’re inside that invocation. Pretty neat!

Keeping track of nested back stacks

Before I let you all go on happy with your lives, there’s one last issue we need to address, and it’s an important one. Imagine this scenario, where we have nested levels all with their own back stacks:

If at this point we push another routing to level A’s back stack, all the nested levels of B, C, and D will be gone if something else is rendered for this new routing:

But if we now press back, and pop A’s back stack so that it again contains the previous routing, all the back stacks previously contained in the levels of B, C, and D are not restored. Instead, a new tree is created with back stacks containing only initial elements:

So now the history in the deeper levels is all gone. Can we do something about that?

The mental model that we need to put into code is that for every element in a back stack, we have some children further down the tree, all with their own back stacks, and they should be stored associated to our current element. Recursively.

Here’s a not particularly beautiful implementation, but enough to do the job.

Instead of storing <T> elements directly in the back stack, let’s modify it so that it stores a list of Entry<T>:

This offers a way to store child back stacks next to the actual T element.

Let’s store an optional BackStack instance associated with the current level in the ScopedBackPressHandler that we store in the Ambient:

Now we can update our BackHandler Composable to use it, and we’re ready:

And that’s it! We have a fully functional screen history of nested back stacks, automatically triggered from back presses. Win!

At this point, we reached the functionality of the demo apps shown at the beginning of the article.

Side note: more back stack operations

The nice thing about a declarative approach to back stack manipulation is that it’s really easy to add more operations if needed. For example replace and new root are just lightweight list operations:

If you want to add a push operation that doesn’t keep history of nested levels, it’s also not too difficult:

If you feel like playing around, you can try and modify the Root level in the app-lifelike example to see how all these operations work out in practice when changing logged out / logged in routing:

  • With push(), you can back track from logged out state to registration flow (also from logged out to profile screen if you logged out there)
  • With pushAndDropNested(), you can back track from logged out state, but the registration flow is gone, and you’ll restart from the splash screen. Same as if you logout from Profile screen, you can go back, but lose the back stack of the logged in flow.
  • With newRoot(), once you logged in or logged out, previous flow is gone, and back press just finishes the Activity
  • With replace(), it’s now the same as newRoot(), but only because the back stack has only 1 element.

Final thoughts

I’m learning Compose as I go, so any or all of the things discussed might or might not make any sense at all. If you can give advice on how to improve it, feedback is very welcome.

You can also follow me on Twitter.

Further reading

  • If you missed it, I definitely recommend reading Compose from first principles by Leland Richardson which discusses the ideas that make Compose work under the hood.
  • If you’re interested in declarative routing in a non-Compose world, I’d advise to check out Badoo RIBs.

Published in ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Responses (2)

What are your thoughts?

Seems like Compose can do a lot more than just showing things at a different way… Really cool article.

--

Does this article need an update now in 2021? How relevant is it?

--