Shared Action Bar in Jetpack Compose
Share a common Action Bar across the screens of your app
In today’s article 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 article, 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 app’s skeleton
We will start by creating a bare bones app with a single screen, the Home screen. For this blog 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 articles, 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 MutableSharedFlow 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 the flow, so we use a LaunchedEffect for that.
Within the LaunchedEffect, we observe the emissions of the buttons flow and apply a when 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 the navController and navigating to the Settings route.
We add a second composable block to the NavController for the Settings screen, and here we call our SettingsScreen composable, passing the onBackClick lambda, which simply delegates to the NavController 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 two 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 action 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 objects to 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 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 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 CoroutineScope to the AppBarState.
We use this scope to launch a coroutine and observe the current route from the NavController, using the currentBackStackEntryFlow which, as the name indicates, provides a flow to observe the route.
We ensure we only act on changes of route by using a distictUntilChanged here.
Whenever the route changes we instantiate a new Screen 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 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 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 the Screen as key, so that it restarts if the screen changes.
We use the Screen instance we got from the AppBarState to 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,