Share TopAppBar Across Screens with Dynamic Content and Actions — Jetpack Compose and Compose Multiplatform

Kotlearn
5 min readSep 22, 2024

--

In this article, we’ll explore how to share a TopAppBar across multiple screens using a Scaffold in Compose with Material3. This is an effective pattern for building a consistent and reusable UI across your app, and I’ll guide you through two different approaches to implementing it.

The repository for this sample can be found here.

An accompanying video for this tutorial can be found here.

While I’ll be showcasing this in a Kotlin Multiplatform project using Compose, the same principles hold true for standard Android projects using Jetpack Compose.

Setup

For the following examples, I’ve set up a couple of screens — A list of cats and a detail screen once one of these have been clicked.

sealed class Screen {

@Serializable
data object CatList

@Serializable
data class CatDetail(val id: Int)

}
@Composable
fun CatListScreen(
goToDetail: (id: Int) -> Unit,
modifier: Modifier = Modifier,
) {

val cats = remember { CatRepository.cats }

LazyColumn(
modifier = modifier,
) {
items(cats) { cat ->
Text(
text = cat.name,
modifier = Modifier
.fillMaxWidth()
.clickable {
goToDetail(cat.id)
}
.padding(16.dp)
)
}
}

}
@Composable
fun CatDetailScreen(
id: Int,
modifier: Modifier = Modifier,
) {

val cat = remember { CatRepository.getById(id) }

Column(
modifier = modifier
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
Text(
text = cat.description,
modifier = Modifier.fillMaxWidth(),
)
}

}

Example 1 — Using a when expression based on the back stack entry

First we will set up the Scaffold with a NavHost.

val navController = rememberNavController()

Scaffold(
topBar = {
LargeTopAppBar(
title = {
Text(text = "Cats")
},
)
},
modifier = Modifier
.fillMaxSize()
) { innerPadding ->
NavHost(
startDestination = Screen.CatList,
navController = navController,
) {

composable<Screen.CatList> {
CatListScreen(...)
}

composable<Screen.CatDetail> {
val args = it.toRoute<Screen.CatDetail>()
CatDetailScreen(...)
}

}
}

This is enough to share the TopAppBar between screens, but the title will always display “Cats”, even once we click through to a detail screen.

We will create a function to get the title based on the current back stack entry.

val backStackEntry by navController.currentBackStackEntryAsState()
LargeTopAppBar(
title = {
Text(getTopAppBarTitle(backStackEntry))
}
}


private fun getTopAppBarTitle(entry: NavBackStackEntry?): String {
...
}

Within this function, we can use a when expression based on the destination route to determine the title.

private fun getTopAppBarTitle(entry: NavBackStackEntry?): String {
val destination = entry?.destination
return when {
destination == null -> ""
destination.hasRoute<Screen.CatList>() -> "Cats"
destination.hasRoute<Screen.CatDetail>() -> "Detail"
else -> ""
}
}

We can even get the arguments that were passed to the screen to fine-tune our titles.

return when {
...
destination.hasRoute<Screen.CatDetail>() -> {
val args = entry.toRoute<Screen.CatDetail>()
val cat = CatRepository.getById(args.id)
cat.name
}
}

This method is useful for simple apps but does not scale well due to this when expression increasing in size with every screen added to the app.

Simple up/back navigation can easily be added by being based on the current back stack.

navigationIcon = {
// Screen.CatList is our start destination in this case
if (backStackEntry?.destination?.hasRoute<Screen.CatList>() != true) {
IconButton(
onClick = {
navController.popBackStack()
}
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back"
)
}
}
},

Example 2— Providing the content for the TopAppBar directly from the screens

For this example, we will assume that dynamic titles, navigation logic, and actions are needed.

First, we need to create a new composable that houses our TopAppBar.

@Composable
fun ContentAwareTopAppBar(
navController: NavController,
modifier: Modifier = Modifier,
) {

LargeTopAppBar(
title = ...,
navigationIcon = ...,
actions = ...,
modifier = modifier,
)

}

We will create a ViewModel to hold its data. This has 3 properties holding the composables for each part of the TopAppBar. Note that we use the RowScope available to us for the actions part.

private class TopAppBarViewModel : ViewModel() {

var title by mutableStateOf<@Composable () -> Unit>({ }, referentialEqualityPolicy())

var navigationIcon by mutableStateOf<@Composable () -> Unit>({ }, referentialEqualityPolicy())

var actions by mutableStateOf<@Composable RowScope.() -> Unit>({ }, referentialEqualityPolicy())

}

To get an instance of this ViewModel, we can use the NavController that we’ve passed through. We can do this as NavBackStackEntry is a ViewModelStoreOwner. Using this, we can reliably get the same instance of our ViewModel when needed.

val backStackEntry by navController.currentBackStackEntryAsState()
backStackEntry?.let { entry ->

val viewModel: TopAppBarViewModel = viewModel(
viewModelStoreOwner = entry,
initializer = { TopAppBarViewModel() }, // We need to provide an initializer since it's a private class
)
}

These can now be passed to the TopAppBar directly.

LargeTopAppBar(
title = viewModel.title,
navigationIcon = viewModel.navigationIcon,
actions = viewModel.actions,
)

To provide the composables for this ViewModel, it would be nice to do it directly from the screen composables. We will create a composable function to allow for this. This is what we’ll achieve within the screen.

@Composable
fun CatDetailScreen(
...,
modifier: Modifier = Modifier,
) {

ProvideAppBarTitle {
// Note that this can easily be dynamically updated.
Text(text = cat.name)
}

ProvideAppBarNavigationIcon {
IconButton(
onClick = goBack, // Or any custom navigation logic.
) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Back"
)
}
}

ProvideAppBarActions {
// Add whichever actions are applicable to this screen.
IconButton(
onClick = {
// Perform action
}
) {
Icon(...)
}
}
}

The functions to provide each part will be very similar to each other.

First we need to get our instance of the ViewModel. We can do this by getting the current NavBackStackEntry. This can be achieved using the The CompositionLocal containing the current ViewModelStoreOwner. When accessed within a NavHost, this should be a NavBackStackEntry. If it exists, it can then be used to get the TopAppBarViewModel instance.

Once we have that, we can simply update whichever part of the TopAppBarViewModel we require. We will do this within a LaunchedEffect, with the key being the composable so that it is only updated once.

@Composable
fun ProvideAppBarTitle(title: @Composable () -> Unit) {

val viewModelStoreOwner = LocalViewModelStoreOwner.current
(viewModelStoreOwner as? NavBackStackEntry)?.let { owner ->
val viewModel: TopAppBarViewModel = viewModel(
viewModelStoreOwner = owner,
initializer = { TopAppBarViewModel() },
)
LaunchedEffect(title) {
viewModel.title = title
}
}

}

We can repeat the function above for the navigation icon and the actions.

@Composable
fun ProvideAppBarNavigationIcon(navigationIcon: @Composable () -> Unit) {

val viewModelStoreOwner = LocalViewModelStoreOwner.current
(viewModelStoreOwner as? NavBackStackEntry)?.let { owner ->
val viewModel: TopAppBarViewModel = viewModel(
viewModelStoreOwner = owner,
initializer = { TopAppBarViewModel() },
)
LaunchedEffect(navigationIcon) {
viewModel.navigationIcon = navigationIcon
}
}

}

@Composable
fun ProvideAppBarActions(actions: @Composable RowScope.() -> Unit) {

val viewModelStoreOwner = LocalViewModelStoreOwner.current
(viewModelStoreOwner as? NavBackStackEntry)?.let { owner ->
val viewModel: TopAppBarViewModel = viewModel(
viewModelStoreOwner = owner,
initializer = { TopAppBarViewModel() },
)
LaunchedEffect(actions) {
viewModel.actions = actions
}
}

}

And that’s everything we need to have a fully controllable TopAppBar. Please check out the repository listed at the top of the article to see the full implementation, or check out the tutorial video for a step-by-step explanation.

--

--

Kotlearn

Kotlin, KMP, Compose Multiplatform, and Android tutorials.