Blog Infos
Author
Published
Topics
Published

Photo by Iñaki del Olmo on Unsplash

 

There are many ways to build collapsible top bars in traditional RecyclerViews. In this article, I’ll explore three different approaches to achieve the same effect in a Jetpack Compose LazyColumn.

  1. Using Scaffold
  2. Using Box
  3. Using Scaffold with Material 3’s LargeTopAppBar

I use the terms “toolbar” and “top bar” interchangeably day-to-day, but I went with “top bar” for this article since that’s the term Material Design uses.

The sample code lives in this repository if you want to check it out.

Using Scaffold

The animation will look like this:

Before we jump into the top bar part, here’s the simple BookModel I use throughout this article to display the list of books:

data class BookModel(val title: String, val author: String, val pageCount: Int)
val DEFAULT_BOOKS = listOf(
  BookModel(
    title = "Tomorrow and Tomorrow and Tomorrow",
    author = "Gabrielle Zevin",
    pageCount = 416
  ),
  BookModel(title = "Babel", author = "R.F. Kuang", pageCount = 545),
  …
)

And the Book Composable:

@Composable
fun Book(modifier: Modifier = Modifier, model: BookModel) =
  Card(modifier = modifier.fillMaxWidth()) {
    Column(
      modifier = Modifier.padding(16.dp)
    ) {
      Text(model.title)
      Text(model.author)
      Text("${model.pageCount} pages")
     }
  }

In all examples, I use these height constants:

val COLLAPSED_TOP_BAR_HEIGHT = 56.dp
val EXPANDED_TOP_BAR_HEIGHT = 200.dp

In Compose, a Scaffold is a layout that implements a basic Material Design layout structure. It supports adding a Top Bar, Bottom Bar, Floating Action Button, or Drawer. It follows the Slot design pattern, making it easy to slot in any Composable you want as the top bar.

Here’s the code for showing the list of BookModel without any top bar logic:

@Composable
fun ScaffoldLibrary(books: List<BookModel> = DEFAULT_BOOKS) {
  val listState = rememberLazyListState()
  Scaffold(
    topBar = {/** TODO **/ }
  ) { padding ->
    LazyColumn(
      modifier = Modifier.padding(padding),
      state = listState
    ) {
      items(items = libraryState) { book ->
        Book(model = book)
        Spacer(modifier = Modifier.height(24.dp))
      }
    }
  }
}

Now onto the top bar. First, I created an ExpandedTopBar Composable:

@Composable
private fun ExpandedTopBar() {
  Box(
    modifier = Modifier
      .background(MaterialTheme.colors.primaryVariant)
      .fillMaxWidth()
      .height(EXPANDED_TOP_BAR_HEIGHT - COLLAPSED_TOP_BAR_HEIGHT),
    contentAlignment = Alignment.BottomStart
  ) {
    Text(
      modifier = Modifier.padding(16.dp),
      text = "Library",
      color = MaterialTheme.colors.onPrimary,
      style = MaterialTheme.typography.h3,
    )
  }
}

ExpandedTopBar preview

 

I put the ExpandedTopBar as the first item in the LazyColumn, so that it scrolls up with the regular items:

@Composable
fun ScaffoldLibrary(books: List<BookModel> = DEFAULT_BOOKS) {
  ...
  Scaffold(
    ...
    ) {
      item { ExpandedTopBar() }
      items(items = libraryState) { book ->
        Book(model = book)
        Spacer(modifier = Modifier.height(24.dp))
      }
    }
  }
}

For the topBar slot, I created a CollapsedTopBar Composable that shows the “Library” top bar text against the background/white background color when the top bar is collapsed and the primaryVariant/purple top bar color when the top bar is expanded. That way, when ExpandedTopBar is still partially visible, CollapsedTopBar’s color matches it, making it visually hidden.

I used animateColorAsState() and AnimatedVisibility() to achieve smooth transitions between expanded and collapsed states.

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CollapsedTopBar(
  modifier: Modifier = Modifier, 
  isCollapsed: Boolean
) {
  val color: Color by animateColorAsState(
    if (isCollapsed) {
      MaterialTheme.colors.background
     } else {
      MaterialTheme.colors.primaryVariant
    }
  )
  Box(
    modifier = modifier
      .background(color)
      .fillMaxWidth()
      .height(COLLAPSED_TOP_BAR_HEIGHT)
      .padding(16.dp),
      contentAlignment = Alignment.BottomStart
  ) {
    AnimatedVisibility(visible = isCollapsed) {
      Text(text = "Library", style = MaterialTheme.typography.h6)
    }
  }
}

CollapsedTopBar(isCollapsed = true) preview

 

CollapsedTopBar(isCollapsed = false) preview

 

Here’s how CollapsedTopBar is used in the Scaffold. I added a LocalOverscrollConfiguration provides null — this removes the overscroll animation, which shows an unwanted white gap between the toolbar and LazyColumn. It’s experimental in the Compose foundation API.

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ScaffoldLibrary(books: List<BookModel> = DEFAULT_BOOKS) {
  ...
  Scaffold(
    topBar = {
      CollapsedTopBar(isCollapsed = isCollapsed)
    }
  ) { padding ->
      CompositionLocalProvider(LocalOverscrollConfiguration provides null) {
      LazyColumn(
        modifier = Modifier.padding(padding),
        state = listState
      ) {
        ...
      }
    }
  }
}

The final piece is calculating isCollapsed. Compose’s LazyListState makes this trivial. It contains information about item visibility that’s hard to calculate with RecyclerViews. Here, we’ll use firstVisibleItemIndex. Since the first item in the list is ExpandedTopBar, once firstVisibleItemIndex is larger than 0, we know we’ve scrolled past it and we should show the CollapsedTopBar.

@Composable
fun ScaffoldLibrary(books: List<BookModel> = DEFAULT_BOOKS) {
  val listState = rememberLazyListState()
  val isCollapsed: Boolean by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
  }
  Scaffold(
    topBar = {
    CollapsedTopBar(isCollapsed = isCollapsed)
  }
  ){
    ...
  }
}

The check needs to be wrapped in a remember { } because firstVisibleItemIndex is an observable property, and would cause recompositions on every change if used like a normal Int, leading topotential performance issues.

The Scaffold approach’s main limitation is it looks fine with solid-colored top bars or even vector illustrations, but not with a full bleed image top bar, since the image gets cut off abruptly by the top bar slot. There are probably ways around this using Scaffold, but I find it more straightforward to simply use a Box.

Using Box

The animation will look like this:

We’ll take an approach similar to using Scaffold, but instead of the CollapsedTopBar appearing above the LazyColumn along the y-axis, the CollapsedTopBar is overlayed on the LazyColumn along the z-axis.

@Composable
fun BoxLibrary(books: List<BookModel> = DEFAULT_BOOKS) {
  val listState = rememberLazyListState()
  val isCollapsed: Boolean by remember {
    /** TODO **/
  }
  Box {
    CollapsedTopBar(modifier = Modifier.zIndex(2f), isCollapsed = isCollapsed)
    LazyColumn(state = listState) {
      item { ExpandedTopBar() }
      items(items = libraryState) { book ->
        Book(model = book)
        Spacer(modifier = Modifier.height(24.dp))
      }
    }
  }
}

 

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

With the advent of Android 15, edge-to-edge design has become the default configuration. Consequently, applications must be capable of accommodating window insets, including the system status bar and navigation bar, as well as supporting drawing…
Watch Video

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility GmbH

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android develop ...
Seven Principles Mob ...

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility ...

Jobs

I added modifier = Modifier.zIndex(2f) so that CollapsedTopBar is drawn over the LazyColumn. We could avoid this modifier by putting CollapsedTopBar() below LazyColumn() in the Box content code, but I find the code easier to reason about like this.

I’m using a version of ExpandedTopBar with an Image background to better show the animation:

@Composable
private fun ExpandedTopBar() {
  Box(
    modifier = Modifier
      .background(MaterialTheme.colors.primaryVariant)
      .fillMaxWidth()
      .height(EXPANDED_TOP_BAR_HEIGHT),
    contentAlignment = Alignment.BottomStart
  ) {
    Image(
      modifier = Modifier.fillMaxSize(),
      painter = painterResource(R.drawable.library),
      contentDescription = null,
      contentScale = ContentScale.Crop,
    )
    Text(
      modifier = Modifier.padding(16.dp),
      text = "Library",
      color = MaterialTheme.colors.onPrimary,
      style = MaterialTheme.typography.h3,
    )
  }
}

ExpandedTopBar preview

 

The CollapsedTopBar is almost identical to the one from above, except its background color is Transparent instead of primaryVariant when the top bar is expanded. That way, when ExpandedTopBar is still on-screen, the CollapsedTopBar allows the top bar image to be fully visible below it.

@Composable
private fun CollapsedTopBar(
  modifier: Modifier = Modifier, 
  isCollapsed: Boolean
) {
  val color: Color by animateColorAsState(
    if (isCollapsed) MaterialTheme.colors.background else Color.Transparent
  )
  Box(
    modifier = modifier
      .background(color)
      .fillMaxWidth()
      .height(COLLAPSED_TOP_BAR_HEIGHT)
      .padding(16.dp),
    contentAlignment = Alignment.BottomStart
  ) {
    AnimatedVisibility(visible = isCollapsed) {
    Text(text = "Library", style = MaterialTheme.typography.h6)
  }
}

The isCollapsed calculation is more complicated, since we still want the animation to occur once the ExpandedTopBar is fully covered by the CollapsedTopBar, but the firstVisibleItemIndex doesn’t update until ExpandedTopBar scrolls past the top of the screen.

We can use LazyListState.firstVisibleItemScrollOffset to calculate when CollapsedTopBar completely overlaps ExpandedTopBarfirstVisibleItemScrollOffset changes whenever the firstVisibleItemIndex changes — ie. when we scroll to the second item, firstVisibleItemScrollOffset resets to 0 to reflect the offset of the second item — so we still need a firstVisibleItemIndex > 0 check.

@Composable
fun BoxLibrary(books: List<BookModel> = DEFAULT_BOOKS) {
  val listState = rememberLazyListState()
  
  val overlapHeightPx = with(LocalDensity.current) {
    EXPANDED_TOP_BAR_HEIGHT.toPx() - COLLAPSED_TOP_BAR_HEIGHT.toPx() 
  }
  val isCollapsed: Boolean by remember {
    derivedStateOf {
      val isFirstItemHidden = 
        listState.firstVisibleItemScrollOffset > overlapHeightPx
      isFirstItemHidden || listState.firstVisibleItemIndex > 0
    }
  }
  Box {
    CollapsedTopBar(modifier = Modifier.zIndex(2f), isCollapsed = isCollapsed)
    ...
  }
}
Using Scaffold with LargeTopAppBar

Material 3 had its first stable release recently, and it includes an experimental LargeTopAppBar that handles scrolling out of the box. I decided to try it out in a Scaffold.

The animation will look like this:

Here’s my LibraryTopBar Composable:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LibraryTopBar(
  scrollBehavior: TopAppBarScrollBehavior, 
  isCollapsed: Boolean
) = LargeTopAppBar(
  title = { Text(text = "Library") },
  colors = TopAppBarDefaults.mediumTopAppBarColors(
    containerColor = MaterialTheme.colorScheme.primary,
    scrolledContainerColor = MaterialTheme.colorScheme.background,
    titleContentColor = if (isCollapsed) {
      MaterialTheme.colorScheme.onBackground
    } else {
      MaterialTheme.colorScheme.onPrimary
    },
  ),
  scrollBehavior = scrollBehavior,
)

Since LargeTopAppBar handles the collapsing animation, we only need a single Composable instead of a collapsed and expanded one — all we have to do is pass in a TopAppBarScrollBehavior.

TopAppBarScrollBehavior defines how an app bar should behave when the content under it is scrolled. I went with enterAlwaysScrollBehavior provided in TopAppBarDefaults.

In order to have the behavior work correctly, we need its parent — in this case, the Scaffold — to include a Modifier.nestedScroll.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Material3ScaffoldLibrary(books: List<BookModel> = DEFAULT_BOOKS) {
  val listState = rememberLazyListState()
  val scrollBehavior = 
    TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
  Scaffold(
    modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
    topBar = { LibraryTopBar(scrollBehavior, isCollapsed) }
  ) { padding ->
    LazyColumn(
      modifier = Modifier.padding(padding),
      state = listState
    ) {
      items(items = libraryState) { book ->
        Book(model = book)
        Spacer(modifier = Modifier.height(24.dp))
      }
    }
  }
}

Finally, we can use the collapsedFraction in TopAppBarScrollBehavior’s TopAppBarState to calculate whether the toolbar is collapsed.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MaterialScaffoldLibrary(books: List<BookModel> = DEFAULT_BOOKS) {
  ...
  val isCollapsed: Boolean by remember {
    derivedStateOf {
      scrollBehavior.state.collapsedFraction == 1f
    }
  }
  Scaffold(
    modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
    topBar = { TopBar(scrollBehavior, isCollapsed) }
  ) { padding ->
    ...
  }
}

There are a few caveats with using LargeTopAppBar.

The most obvious one is that it’s an experimental API, so there may be breaking changes in later versions. Or it may disappear completely. 🤷

I noticed that TwoRowsTopAppBar, which it calls under the hood, recomposes with every scroll, even when the top bar doesn’t visually change. The Scaffold and Box implementations only recompose when animating between collapsed and expanded states.

LargeTopAppBar is also disappointingly limited in terms of customization. It only supports solid color top bars and can’t display images. Since it’s experimental, I’m hoping more powerful customization will be added later.

Most apps probably haven’t fully migrated to Material 3 yet, but LargeTopAppBar doesn’t interop well with the older Material API. My first attempt included imports from both material and material3, and there were some weird visual bugs. For example, the “Library” top bar text appeared twice when I was using material.Text, and switching the import to material3.Text fixed it.

I wouldn’t recommend using LargeTopAppBar in a production app until the API is stable, but it seems promising!

Not pictured: stickyHeader

I also played around with the experimental LazyListScope.stickyHeader API. It “adds a sticky header item, which will remain pinned even when scrolling after it. The header will remain pinned until the next header will take its place.”

I found that it didn’t work well for this use case. The main issue was it required manually updating the header height based on the scroll, which was tricky to calculate even with the LazyListState API. I managed to get it working using a NestedScrollConnection, but it was quite verbose and recomposed constantly as it recalculated the header height.

That said, stickyHeader should be useful for other cases. It’s probably the simplest solution for handling top bars where the displayed content changes depending on which item of the LazyColumn we’re scrolled to, but the height remains constant. For example, a LazyColumn of contacts where the top bar displays different letters of the alphabet depending on the contacts’ names.

Takeaways
  • Having easy access to items’ visibility info through LazyListState simplifies a lot of effects that are difficult to implement in RecyclerView. However, many of the state’s properties are observable and would cause performance issues if accessed directly. We generally want to wrap them inside a remember { derivedStateOf(…) }. It’s worth checking the Recomposition Counts in the Layout Inspector to make sure there aren’t unexpected recompositions.
  • Compose animation APIs are much more intuitive and expressive than the traditional APIs! I highly recommend exploring them.
  • There are multiple solutions for similar animations — my three examples of creating a collapsible top bar are nowhere near exhaustive – and we can use more complex solutions as the animation complexity increases. For example, here’s a collapsing toolbar tutorial I consulted that includes a parallax effect as well.

Here’s the repository with the sample code again:

GitHub – frostyshadows/CollapsingTopBarLibrary

As always, thanks for reading 🖤

 

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
Menu