AppBarLayout in Compose

Oya Canlı
4 min readDec 23, 2023

During our journey to convert our app to Compose, one thing I have been missing a lot was a built-in TopBar with scroll behaviors, just like the AppBarLayout with CoordinatorLayout in the View world. As it was not yet available, I left converting AppBarLayouts to the end, hoping that Google would provide it soon. And indeed, Google ended up providing similar built-in top bars with similar scroll behaviors in Material3! However, my excitement didn’t last long as I saw that their components were too opinionated and not flexible at all. For instance, here is how you build a LargeTopAppBar, from the documentation:

@Composable
fun LargeTopAppBarExample() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())

Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = {
Text(
"Large Top App Bar",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
IconButton(onClick = { /* do something */ }) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Localized description"
)
}
},
actions = {
IconButton(onClick = { /* do something */ }) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "Localized description"
)
}
},
scrollBehavior = scrollBehavior
)
},
) { innerPadding ->
ScrollContent(innerPadding)
}
}

So you pass the title, navigation icon (for top left), and other menu actions (for top right) and they layout it in a standard opinionated way, with predefined heights and margins. You can learn more about it here.

Not surprisingly, our designs were not fitting in these layouts. We have multiple groups of filters in our app bar, in addition to title and logo. Naturally, it has a larger height as well. And we need them to have enter-always behavior, as we want filters to be easily accessible even if user have scrolled far down on the page. We could put whatever we want inside AppBarLayout, and that was what I was expecting in the Compose version.

Disappointed and lost the hope that Google would provide this component, I ended up making a custom one, after examining Google’s own top bars in Material3. I copy pasted some of their code and changed the layout. This way, we can use it in a similar fashion with their M3 top bars, and pass the same scroll behaviors they have provided.

So here is the code of the FlexibleTopBar, which is a simple container in which you can put whatever you want and pass the scroll behavior you prefer. You can just copy-paste this complete file from the github somewhere in your codebase without modifying. (There are some extra helper methods there that are copied and modified from Google’s source code):

/**
* This top bar uses the same scroll behaviors as Material3 top bars,
* but it doesn't have a layout of its own. It is simply a container in
* which you can put whatever you want.
*/
@ExperimentalMaterial3Api
@Composable
fun FlexibleTopBar(
modifier: Modifier = Modifier,
colors: FlexibleTopBarColors = FlexibleTopBarDefaults.topAppBarColors(),
scrollBehavior: TopAppBarScrollBehavior? = null,
content: @Composable () -> Unit,
) {
// Sets the app bar's height offset to collapse the entire bar's height when content is
// scrolled.
var heightOffsetLimit by remember {
mutableFloatStateOf(0f)
}
LaunchedEffect(heightOffsetLimit) {
if (scrollBehavior?.state?.heightOffsetLimit != heightOffsetLimit) {
scrollBehavior?.state?.heightOffsetLimit = heightOffsetLimit
}
}

// Obtain the container color from the TopAppBarColors using the `overlapFraction`. This
// ensures that the colors will adjust whether the app bar behavior is pinned or scrolled.
// This may potentially animate or interpolate a transition between the container-color and the
// container's scrolled-color according to the app bar's scroll state.
val colorTransitionFraction = scrollBehavior?.state?.overlappedFraction ?: 0f
val fraction = if (colorTransitionFraction > 0.01f) 1f else 0f
val appBarContainerColor by animateColorAsState(
targetValue = colors.containerColor(fraction),
animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
)

// Set up support for resizing the top app bar when vertically dragging the bar itself.
val appBarDragModifier = if (scrollBehavior != null && !scrollBehavior.isPinned) {
Modifier.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
scrollBehavior.state.heightOffset = scrollBehavior.state.heightOffset + delta
},
onDragStopped = { velocity ->
settleAppBar(
scrollBehavior.state,
velocity,
scrollBehavior.flingAnimationSpec,
scrollBehavior.snapAnimationSpec
)
}
)
} else {
Modifier
}

// Compose a Surface with a TopAppBarLayout content.
// The surface's background color is animated as specified above.
// The height of the app bar is determined by subtracting the bar's height offset from the
// app bar's defined constant height value (i.e. the ContainerHeight token).
Surface(modifier = modifier.then(appBarDragModifier), color = appBarContainerColor) {
Layout(
content = content,
modifier = modifier,
measurePolicy = { measurables, constraints ->
val placeable = measurables.first().measure(constraints.copy(minWidth = 0))
heightOffsetLimit = placeable.height.toFloat() * -1
val scrollOffset = scrollBehavior?.state?.heightOffset ?: 0f
val height = placeable.height.toFloat() + scrollOffset
val layoutHeight = height.roundToInt()
layout(constraints.maxWidth, layoutHeight) {
placeable.place(0, scrollOffset.toInt())
}
}
)
}
}

And here is how to use it:

val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
// val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
// val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()

Scaffold(
containerColor = MaterialTheme.colorScheme.background,
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
FlexibleTopBar(
scrollBehavior = scrollBehavior,
content = {
// Top bar content here
}
)
},
content = { innerPadding ->
// Main page content here
}
)

If you don’t use material theme, check out the default colors defined there in the file (FlexibleTopBarDefaults) and adapt it to your needs.

That’s pretty much it! You can pass similar scroll behaviors and you can put whatever you want in it.

One extra thing I needed was the ability to programmatically expand the top bar, just like we did with the AppBarLayout. I added this extension function to expand it with a simple animation:

@OptIn(ExperimentalMaterial3Api::class)
suspend fun TopAppBarScrollBehavior.expandAnimating() {
AnimationState(
initialValue = this.state.heightOffset
)
.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 500)
) { this@expandAnimating.state.heightOffset = value }

I hope it helps! Thanks for reading and happy coding!

--

--