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
NavControllerthat we will use to navigate to our screens.
- We use a
Scaffoldas 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
- For the content, we use the
NavHostto define our screens.
- For now we have just 1 screen, the
- This is our starting point for the custom Action Bar, it’s based on the Material3
TopAppBarand, 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
Screeninterface 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
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
NavControlleras 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
remembermethod to create an instance of the Action Bar state class.
It’s worth to note that the
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
IconButtonto the Action Bar.
- We do the same with the title, creating a
Textcomposable to display our Action Bar title.
- Finally we check if we have any actions and add them to the
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
rememberfactory 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
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
enumto represent the different action menu items for this screen.
- We also define a private
MutaableSharedFlowand 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 scopeto observe the emissions of the
flow, so we use a
- Within the
LaunchedEffect, we observe the emissions of the
buttonsflow and apply a
whento 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
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
LaunchedEffectto 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
onSettingsClicklambda by calling the
navControllerand navigating to the Settings route.
- We add a 2nd
composableblock to the
NavControllerfor the Settings screen, and here we call our
SettingsScreencomposable, passing the
onBackClicklambda, which simply delegates to the
NavControllerto 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
toNoAppBarScreenlambda by navigation to the new screen.
- We add the new screen to the
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
remembered 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
objects that outlive the composables.
In order to change from
classes 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
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
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
classes and not
objects, 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
- We use this scope to launch a coroutine and observe the current route from the
NavController, using the
currentBackStackEntryFlowwhich, as the name indicates, provides a
flowto observe the route.
- We ensure we only act on changes of
routeby using a
- Whenever the
routechanges we instantiate a new
Screenusing 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
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
classes, 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
- We pass the
AppBarStateto the screen composable.
- We get the current screen from the
AppBarStateand cast to the type we expect.
LaunchedEffectnow uses the
Screenas key, so that it restarts if the screen changes.
- We use the
Screeninstance we got from the
AppBarStateto collect the button events.
And with this our implementation is complete. Transitioning to
classes 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!