Shared Action Bar in Jetpack Compose
--
Introduction
In today’s story we will find out how to implement an Action Bar in Jetpack Compose that can be shared across different screens, while updating to show different content based on which screen is currently active.
This GIF shows what we want to end up with:
For the actions menu we will use the solution described in this story, so I will not go over in detail on how to implement the actions bar menu here.
Defining the structure
The first thing we need to do is define the structure for the data that will represent the action bar for each screen, these are the items that we will need:
- the navigation icon, optional
- a click handler for the navigation icon, optional as well
- a content description for the navigation icon, also optional
- the title for the screen
- a list of menu actions, optional
- whether the action bar is visible or not
The action bar will be configured for each screen, so it makes sense to create a data structure that contains the screen route as well, used for navigation, so we will add that attribute to our structure.
The interface representing our action bar is shown below
Note that we define this as a sealed interface
as we will define one implementation of this interface for each screen in our app.
Creating the apps’ skeleton
We will start by creating a barebones app with a single screen, the Home screen. For this story we will use Material3 components and the Jetpack Compose Navigation library to navigate from screen to screen.
Let’s create our starting point:
Here we have
- We get the
NavController
that we will use to navigate to our screens. - We use a
Scaffold
as the root element for our app, this will host the Action Bar and the content. - For the top bar, we will have our own implementation, which we are naming
PlaygroundTopAppBar
. - For the content, we use the
NavHost
to define our screens. - For now we have just 1 screen, the
Home
screen. - This is our starting point for the custom Action Bar, it’s based on the Material3
TopAppBar
and, for now, is mostly empty. - And this is the main content for the home screen, presently only has 2 buttons that we will use to navigate to other sections of the app.
If we run this, we get this result:
Creating the Home Screen object
Now that we have a basic Home screen composable, we can go ahead and create a concrete implementation of the Screen
interface for the home screen. For this implementation, we will only need a title and 1 menu action item, to navigate to Settings, so we can create our screen object as shown below:
- We create a concrete implementation of the
Screen
interface for the Home screen. - We override the base properties — we only need a title and an action menu item, so we set the others as
null
.
Note that we have left the click listener for the action menu empty, we’ll be populating that a bit later.
Fleshing out the Action Bar composable
Next we will flesh out the Action Bar component that we will share across all screens of the app.
As we have done in other stories, we will create a state holder class that represents the state of the composable, the Action Bar in this case, and a remember
factory method to create an instance of this state.
The state of the Action Bar basically mimics the Screen structure we showed earlier, with the difference being that we need to determine which concrete implementation of the sealed interface Screen
we have to use to update the state with.
As we are using the Jetpack Compose Navigation library, we will use the NavController
APIs to observe the current screen, and then get the data we need to update the Action Bar. The NavController
has an API to retrieve the current route, which we can use to identify the screen that is currently on top of the stack, so we need a means to map a route
to a Screen
— for this, we can create a simple method that will return our Screen
from the route, as shown below
This method iterates over the implementations of our sealed interface
and returns a screen that matches the route
we pass in.
Now that we have a means to get the current screen from the route, we can define our state for the Action Bar; let’s do that:
- We define our state, which accepts the
NavController
as constructor argument. - We use the
NavController
's APIs to get the route for the screen that is currently at the top of the stack. - Based on the current route, we get the corresponding screen.
- Here we simply expose the properties of the screen as observables for the Action Bar to consume.
- Finally we create a
remember
method to create an instance of the Action Bar state class.
It’s worth to note that the NavController
's currentBackStackEntryAsState
is a composable method, so all our properties that depend on it must be too.
Now that we have our state, we can go back to the top app bar and populate it based on the data from the state. Let’s see our updated top app bar composable:
- We modify our Action Bar composable to accept the state as an input parameter.
- We keep a local flag to persist the state of the overflow menu.
- We get a reference to the navigation icon and the navigation click handler from our state.
- If the icon is present, then we add an
IconButton
to the Action Bar. - We do the same with the title, creating a
Text
composable to display our Action Bar title. - Finally we check if we have any actions and add them to the
ActionsMenu
composable.
Now that we have this, all we need to do is update our root composable to pass the state to the Action Bar:
- We get the navigation controller.
- We get an instance of the Action Bar state using our
remember
factory method, and we pass the navigation controller as constructor argument. - If the action bar is visible, determined by its state, we add the Action Bar composable to the composition tree, passing as argument its state.
With this we have the basic pieces in place, and if we run this we get this result:
We are displaying the action bar title and the Settings action menu item. However, tapping that action menu does nothing, as we left the click handler in the HomeScreen
empty. Let’s fix that.
Hooking up the click handler on the Screen class
Our Screen
classes define the click handlers for the different action menu items, and these need to be connected to our composables, where the actual handling needs to take place (actually, this should be forwarded to a viewmodel, but in our example we will just handle the clicks in the composable itself for simplicity’s sake).
So we need a means for a click handler in the Screen
class to be propagated to the composable for that screen. A way to do this would be to define a Flow
in the Screen
implementation, and then have the composables observe emissions from that flow. When the click handler in the Screen object triggers, we push an event to the flow
, where we identify the action menu item that the user clicked on. Let’s make these changes as it will all probably make more sense when we see the code, let’s start by updating the HomeScreen
implementation by fleshing out the click handler:
- We define an
enum
to represent the different action menu items for this screen. - We also define a private
MutaableSharedFlow
and a public immutable variant to expose to the home composable. - On our click handler we push an event to the flow, to identify which button was tapped.
Now that we have this, it’s just a matter of observing this flow
in our home screen composable and trigger the navigation action to go to Settings:
- We need a
coroutine scope
to observe the emissions of theflow
, so we use aLaunchedEffect
for that. - Within the
LaunchedEffect
, we observe the emissions of thebuttons
flow and apply awhen
to figure out which action was clicked. In this case, there is only 1, the Settings button. - When the Settings button is tapped, we navigate to the Settings screen using the navigation lambda received as argument (presently not implemented).
With this we have our Home screen complete and hooked up to listen for action menu item clicks.
Adding the Settings screen
Next we are going to add the Settings screen, so that we can navigate to it from the Home screen’s action menu item. For this screen we will not have any action menu items, we will just have the navigation icon, to navigate back, and the title. Let’s create the SettingsScreen
as a child class of Screen
:
This is very similar to our HomeScreen
, but here we provide a navigationIcon
and a corresponding click handler which, like the click handler for the action menu item in the home screen, pushes an event to a flow, to be observed by the Settings composable. For this screen we have no actions, so we provide an empty list.
We can now create the composable for our Settings screen, which will just have a placeholder text in it:
- Our Settings composable accepts a lamba to navigate back to the previous screen.
- Like we did on the Home screen, we launch a coroutine using
LaunchedEffect
to observe the emissions of the buttons flow. - When the user taps on the navigation icon, we call the navigate back lambda.
Now all we need to do is add this screen to the Navigation Controller, and hook-up the click handler on the Home Screen to navigate to Settings:
- We implement the
onSettingsClick
lambda by calling thenavController
and navigating to the Settings route. - We add a 2nd
composable
block to theNavController
for the Settings screen, and here we call ourSettingsScreen
composable, passing theonBackClick
lambda, which simply delegates to theNavController
to pop back the backstack.
With this we can now navigate from Home to Settings and back, with out Action Bar updating based on which screen is active:
Adding the screen without Action Bar
Next we will add the screen without an Action Bar, so that we can see how we can navigate between screens with and without the Action Bar. The process here is basically the same as we’ve done for the previous 2 screen, we simply have to set the isAppBarVisible
property on the Screen to false
, and, as we won’t have anything to show, set everything else to null
or empty:
And our screen is very basic, as we have no need for any coroutines this time:
And all we have left to do is to update our Navigation Controller and add the lambda for the Home Screen to navigate to this new screen:
- We implement the
toNoAppBarScreen
lambda by navigation to the new screen. - We add the new screen to the
NavHost
.
And this is our current state, where we can see the Action Bar showing or hiding based on the current screen:
Adding the screen with multiple action bar items
Next we will add the last screen, which displays a bunch of action menu items. The process here is exactly the same as the others, but there is something else we want to do here, we want the Favorites action menu item to toggle its state when the user taps it, so we need to update our Action Bar state accordingly.
Let’s first add the new screen as we’ve done for the others, without handling the Favorite menu item, as this is the same as we’ve done before, and later we will see how we can make this menu item mutable.
The initial code to add the new screen is shown below:
And our screen composable:
The only thing worth of note in this composable is that we pass in the SnakbarHostState
so that we can show a SnackBar whenever an actions menu item is clicked.
Now we just need to add our composable to the NavHost:
Here we complete the HomeScreen
callbacks by navigating to the new screen, and we add it to the NavHost
, passing the SnackbarHostState
as part of its arguments. With this we get this result:
Making the Favorite action menu item mutable
The next part we want to tackle is to make the Favorite button mutable, so that we can display an outline icone or a filled one to signifify if favorites is enabled or not.
The state for the Action Bar defines the action menu items we want to display, and currently this is read-only list, so we need the following changes:
- a means to tell our Screen state which icon to use for the Favorite action
- a means to update the actions list when we are provided with a new icon
As we want our list of menu actions to update when our favorite icon changes, we can leverage the tools from Jetpack Compose and make the actions list a derivedStateOf
— this is an observable property that will update whenever any of the observable properties read within its lambda changes. So, we will also need a property to observe, which is the favorite icon. Let’s see how we need to change our ManyOptionsScreen
to accommodate these needs:
- We define an observable property for the favorite icon, which we will use as trigger to regenerate the action menu items.
- The actions list is now a
derivedStateOf
, which will trigger whenever observed properties change. - We use the observable icon for the favorite actio menu in the favorite block — this is our trigger, whenever we change the favorite icon the list will be rebuilt.
- We provide a means to set the favorite icon.
- And all we have left to do is update our observable property with the new value.
Now that we have our Screen
updated, we need to hook it up to the composable, sot that we can update the favorite icon when tapped. Let’s see what changes we need to do:
- We define a
remember
ed property for the state of the favorite icon. In this simple example we keep this in the composable, but this would ideally go into a State Holder or to the viewmodel. - We handle the favorite click event separately from all other action menu items.
- When the favorite button is clicked, we toggle the flag for whether favorites are enabled or not.
- And finally we call the Screen to update the icon.
If we run our app with these changes we get this result, where we can see the Action Bar updating the favorite icon to represent its new state:
Objects vs classes
When we have mutating properties, like we do with the Favorite icon above, using an object
to represent the screen can be problematic, as the state will be preserved as long as the app is alive. This can lead to inconsistencies if the composable does not initialize the screen with the correct icon to render when the composable is added to the composition tree. The same applies to the flows for the button events, we don’t want to handle events from a previous screen if they were pushed to the flow
but not consumed yet.
While it’s possible to manage the state in the composable by resetting the screen object, it can become somewhat of a burden and a source of bugs, so it is preferable to, instead, have a new instance of the Screen
created whenever the composable enters the composition, as opposed to using object
s that outlive the composables.
In order to change from object
s to class
es we need to make a few changes to the Screen
and the composables themselves.
The changes to the Screen
objects is straightforward — we just need to change the keyword object
to class
for each of them and we’re done.
This, however, means that the method we use to retrieve the screen object from the route no longer works, so we need to create a different factory method that will instantiate the corresponding screen class from the route:
Here we compare the incoming route
with the known routes and return the Screen
instance that matches the route
.
Next we need to update our AppBarState
. Currently we are returning the screen using a property, here
but this no longer works if we have class
es and not object
s, as each call to currentScreen
would generate a new instance. We need to refactor this so that we generate a Screen
whenever the route changes and keep that instance around as long as we remain on that route. The changes we need are shown below:
- We pass a
CoroutineScope
to theAppBarState
. - We use this scope to launch a coroutine and observe the current route from the
NavController
, using thecurrentBackStackEntryFlow
which, as the name indicates, provides aflow
to observe the route. - We ensure we only act on changes of
route
by using adistictUntilChanged
here. - Whenever the
route
changes we instantiate a newScreen
using the updated factory method. - The screen is now simply a property in this class.
- And finally we have the updated factory method for the state with the new argument, the
CoroutineScope
.
Finally we need to update our composables as well. In our composables we are currently relying on the fact that each Screen
is an object
so that we can collect the button events but, after transitioning them to class
es, that’s no longer possible. The current screen is part of our AppBarState
which we use to drive the Action Bar, so we will pass that same AppBarState
to the screen composables, so that we can access the current screen and its state.
The changes are the same for each screen, so here I’m highlighting only the changes for the HomeScreen
:
- We pass the
AppBarState
to the screen composable. - We get the current screen from the
AppBarState
and cast to the type we expect. - Our
LaunchedEffect
now uses theScreen
as key, so that it restarts if the screen changes. - We use the
Screen
instance we got from theAppBarState
to collect the button events.
And with this our implementation is complete. Transitioning to class
es adds a bit of complexity, but it’s worth it because it can save us from subtle bugs when state leaks from one screen to another when the user revisits it.
The whole implementation is available in this gist, I hope you found this useful and I’ll see you on the next one!