Collapsing Toolbar in Jetpack Compose | A closer look at the Toolbar — Part 2

Glenn Sandoval
Kotlin and Kotlin for Android
10 min readJul 14, 2023

--

The composition and dynamics of the Toolbar

You can go to any article of this guide by clicking on one of the links below:

Collapsing Toolbar in Jetpack Compose

  1. Problem, solutions and alternatives
  2. Codebase
  3. ‘Column’ version
  4. ‘LazyColumn’ version — Part 1
  5. ‘LazyColumn’ version — Part 2
  6. A closer look at the Toolbar — Part 1
  7. A closer look at the Toolbar — Part 2

In the previous article I showed you how a custom layout can be used to position all our toolbar components according to a progress/fraction value which represents our toolbar state. In this article I’ll show you “the fine-tuning” of all the elements that make up the toolbar.

Let’s start with sketches of the collapsed and expanded toolbar states to clearly understand the dimensions of every element contained in the toolbar.

Sketches

— Collapsed state:

The previous image shows the toolbar in its collapsed state. The red border box that occupies almost the entire space shows where the custom layout borders are. Each element is marked with a colored box that shows where its edges are before applying any padding. The white box inside each colored box shows where the edges are after applying their corresponding paddings.

Now we need to determine the dimension of each element inside the toolbar. Take a look at the Greek letters at the bottom, left and right sides. They represent the following measures:

  • α: Content padding.
  • β: Button size (width and height).
  • δ: ‘Costa’ and ‘Rica’ images height.
  • ε:Wildlife’ image height.
  • ω: Country map shape height.

— Expanded state:

The previous image shows the toolbar in its expanded state. Since all the logo images get slightly larger in the expanded state, their measures are represented with their corresponding uppercase Greek letters. The rest of the measures remain the same:

  • Δ: ‘Costa’ and ‘Rica’ images height.
  • Ε:Wildlife’ image height.

In the expanded state, the country map shape element is not visible, although it is located at the center-left, which is marked with an empty box with white borders.

And last but not least, we have the background image. In the collapsed state, it’s invisible. On the other hand, in the expanded state, we can notice a blueish tone that comes from the base color of the toolbar. This is because the maximum opacity of the background image is set to 75%.

Now that all the measures are represented with Greek letters, we can define concrete values. You can experiment with them until you achieve the desired result.

Measures

⚠️ I defined all the measures inside the Tooolbar.kt file for simplicity, but they should be defined inside the res/values/dimen.xml file. That way, you’ll have more flexibility to define different dimensions for all the screen sizes that you’re required to support. In case you need to import a measure from the dimen.xml file, Jetpack Compose provides us with the dimensionResource(R.dimen.my_dimension) function.

Here are the concrete values for all the previous measures:

  • α: ContentPdding = 8.dp
  • β: ButtonSize = 24.dp
  • δ: CollapsedCostaRicaHeight = 16.dp
  • Δ: ExpandedCostaRicaHeight = 20.dp
  • ε: CollapsedWildlifeHeight = 24.dp
  • Ε: ExpandedWildlifeHeight = 32.dp
  • ω: MapHeight = CollapsedCostaRicaHeight * 2

There are also other constants that we should take into account, such as the elevation of the toolbar and the maximum opacity (alpha value) of the background image:

  • Toolbar elevation: Elevation = 4.dp
  • Max background image opacity: Alpha = 0.75f

To avoid having all the logo images adjacent to each other with no space in between, we need to define a padding for them:

  • ExpandedPadding = 1.dp
  • CollapsedPadding = 3.dp

And that’s it for all the measures. As I mentioned before, those measures are defined at the top of the Toolbar.kt file for simplicity, and they look as follows:

private val ContentPadding = 8.dp
private val Elevation = 4.dp
private val ButtonSize = 24.dp
private const val Alpha = 0.75f

private val ExpandedPadding = 1.dp
private val CollapsedPadding = 3.dp

private val ExpandedCostaRicaHeight = 20.dp
private val CollapsedCostaRicaHeight = 16.dp

private val ExpandedWildlifeHeight = 32.dp
private val CollapsedWildlifeHeight = 24.dp

private val MapHeight = CollapsedCostaRicaHeight * 2

Now that all the measures are defined with concrete values, all we have left to do is define the composable function where we build the collapsing toolbar.

The ‘CollapsingToolbar’ composable function

@Composable
fun CollapsingToolbar(
@DrawableRes backgroundImageResId: Int,
progress: Float,
onPrivacyTipButtonClicked: () -> Unit,
onSettingsButtonClicked: () -> Unit,
modifier: Modifier = Modifier
) {
...
}

This composable function is the one that we expose so that the toolbar can be included into any screen that requires it.

— Input paramters

  • backgroundImageResId: Drawable resource ID to be displayed as background.
  • progress: Value between 0f and 1f, where 1f corresponds to its expanded state and 0f corresponds to its collapsed state.
  • onPrivacyTipButtonClicked: Lambda that defines the event to be triggered when the ‘Privacy Tip’ button is clicked.
  • onSettingsButtonClicked: Lambda that defines the event to be triggered when the ‘Settings’ button is clicked.
  • modifier: Optional parameter applied to the Surface.

— Logo images with changing size

The three logo images (‘Costa’, ‘Rica’, and ‘Wildlife’) and their paddings increase and decrease their sizes according to the toolbar state. To accomplish that, we need to calculate their sizes with the help of the lerp function. The lerp function does not receive dp values, so we need to convert them into pixels (float values) first and convert them back into dp units after we have made every calculation. That's exactly what we do at the beginning of the CollapsingToolbar composable function:

@Composable
fun CollapsingToolbar(
...
) {
val costaRicaHeight = with(LocalDensity.current) {
lerp(CollapsedCostaRicaHeight.toPx(), ExpandedCostaRicaHeight.toPx(), progress).toDp()
}
val wildlifeHeight = with(LocalDensity.current) {
lerp(CollapsedWildlifeHeight.toPx(), ExpandedWildlifeHeight.toPx(), progress).toDp()
}
val logoPadding = with(LocalDensity.current) {
lerp(CollapsedPadding.toPx(), ExpandedPadding.toPx(), progress).toDp()
}

...

}

—The ‘Surface’ component

The composable function CollapsingToolbar uses a Surface component as its main structure. We use a Surface for two reasons:

  1. It applies Material Design styles by default.
  2. It ajusts itself automatically to the current theme.
@Composable
fun CollapsingToolbar(
...
) {
...

Surface(
color = MaterialTheme.colors.primary,
elevation = Elevation,
modifier = modifier
) {
...
}
}

We simply set its color, elevation, and modifier, which is the modifier that the CollapsingToolbar composable function receives.

—A ‘Box’ component to overlay the custom layout on the background image

Now we need to place the custom layout over the background image. The Box component lets us stack its children on top of each other. Inside the Box component, we have to include the background image first, as well as another Box component that will work as a container for our custom layout:

...
Surface(
...
) {

Box (modifier = Modifier.fillMaxSize()) {
//#region Background Image
Image(
painter = painterResource(id = backgroundImageResId),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = progress * Alpha
},
alignment = BiasAlignment(0f, 1f - ((1f - progress) * 0.75f))
)
//#endregion
Box(
modifier = Modifier
.statusBarsPadding()
.padding(horizontal = ContentPadding)
.fillMaxSize()
) {
...
}
}
}
...

— Background image opacity and alignment

For the background image, we calculate its opacity based on the progress value. In the expanded state (progress = 1f), its opacity will be equal to the Alpha constant (0.75f). In the collapsed state (progress = 0f), its opacity will be equal to 0, which makes it invisible.

Now let’s see the alignment parameter. We can use this parameter to give it a parallax effect with a BiasAligment data object. The BiasAligment data object receives two arguments: horizontalBias and verticalBias. Its official documentation states the following:

📖 A bias of -1 represents alignment to the start/top, a bias of 0 will represent centering, and a bias of 1 will represent end/bottom.

When horizontalBias = 0, the background image is horizontally fixed at its center. When verticalBias = 1f — ((1f — progress) * 0.75f), as the toolbar reaches its collapsed state, the background image alignment moves closer to the start/top, which makes it look like it is moving downwards. We can see that clearly if we replace the progress value with 1f for its expanded state, and 0f for its collapsed state.

  • When the toolbar reaches its expanded state:
Given progress = 1f:

verticalBias = 1f - ((1f - progress) * 0.75)
-> verticalBias = 1f - ((1f - 1f) * 0.75)
-> verticalBias = 1f - (0f * 0.75)
-> verticalBias = 1f - 0f
-> verticalBias = 1f //The image is vertically aligned to the end/bottom
  • When the toolbar reaches its collapsed state:
Given progress = 0f:

verticalBias = 1f - ((1f - progress) * 0.75)
-> verticalBias = 1f - ((1f - 0f) * 0.75)
-> verticalBias = 1f - (1f * 0.75)
-> verticalBias = 1f - 0.75
-> verticalBias = 0.25 //The image is vertically aligned towards the start/top

— Using a ‘Box’ component as a container for the custom layout

As I mentioned before, over the background image, we have another Box component which works as a container exclusively for our custom layout. We simply add paddings and make it occupy the rest of the space inside the toolbar with the fillMaxSize() modifier function.

...
Box(
modifier = Modifier
.statusBarsPadding()
.padding(horizontal = ContentPadding)
.fillMaxSize()
) {
CollapsingToolbarLayout (progress = progress) {
...
}
}
...

Now the only thing left to do is pass in all the elements that our custom layout is expecting.

Custom layout elements

Let’s take a look at the CollapsingToolbarLayout composable function signature from the first part.

@Composable
private fun CollapsingToolbarLayout(
progress: Float,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
)

The first parameter corresponds to the progress value. The second parameter is optional and corresponds to the modifier. The third and last parameter is a composable lambda (composable trailing lambda) which allows us to pass in all its elements enclosed in curly braces.

— Country map shape

...
CollapsingToolbarLayout (progress = progress) {
//#region Logo Images
Image(
painter = painterResource(id = R.drawable.logo_costa_rica_map),
contentDescription = null,
modifier = Modifier
.padding(logoPadding)
.height(MapHeight)
.wrapContentWidth()
.graphicsLayer { alpha = ((0.25f - progress) * 4).coerceIn(0f, 1f) },
colorFilter = ColorFilter.tint(MaterialTheme.colors.onPrimary)
)
...
//#endregion
...
}
...

Here we apply the padding that we calculated for all the logo images (logoPadding), a fixed height (MapHeight), and a changing opacity that depends on the progress value.

If the toolbar is in its collapsed state (progress = 0f), then alpha = 1f, which means that the image is completely visible.

As the toolbar gets expanded, or in other words, while the progress value is increased, alpha is decreased. The point alpha = 0 is reached when progress = 0.25f, so the image becomes invisible when the toolbar is 25% expanded.

— ‘Costa’ image

...
CollapsingToolbarLayout (progress = progress) {
//#region Logo Images
...
Image(
painter = painterResource(id = R.drawable.logo_costa),
contentDescription = null,
modifier = Modifier
.padding(logoPadding)
.height(costaRicaHeight)
.wrapContentWidth(),
colorFilter = ColorFilter.tint(MaterialTheme.colors.onPrimary)
)
...
//#endregion
...
}
...

For this image, we apply the padding (logoPadding) in addition to the calculated height for the ‘Costa’ and ‘Rica’ images (costaRicaHeight).

— ‘Rica’ image

...
CollapsingToolbarLayout (progress = progress) {
//#region Logo Images
...
Image(
painter = painterResource(id = R.drawable.logo_rica),
contentDescription = null,
modifier = Modifier
.padding(logoPadding)
.height(costaRicaHeight)
.wrapContentWidth(),
colorFilter = ColorFilter.tint(MaterialTheme.colors.onPrimary)
)
...
//#endregion
...
}
...

Again, we apply the padding (logoPadding) in addition to the calculated height for the ‘Costa’ and ‘Rica’ images (costaRicaHeight).

— Wildlife image

...
CollapsingToolbarLayout (progress = progress) {
//#region Logo Images
...
Image(
painter = painterResource(id = R.drawable.logo_wildlife),
contentDescription = null,
modifier = Modifier
.padding(logoPadding)
.height(wildlifeHeight)
.wrapContentWidth(),
colorFilter = ColorFilter.tint(MaterialTheme.colors.onPrimary)
)
//#endregion
...
}
...

The only difference here is the calculated height for the ‘Wildlife’ image (wildlifeHeight).

— Buttons block

...
CollapsingToolbarLayout (progress = progress) {
...
//#region Buttons
Row (
modifier = Modifier.wrapContentSize(),
horizontalArrangement = Arrangement.spacedBy(ContentPadding)
) {
IconButton(
onClick = onPrivacyTipButtonClicked,
modifier = Modifier
.size(ButtonSize)
.background(
color = LocalContentColor.current.copy(alpha = 0.0f),
shape = CircleShape
)
) {
Icon(
modifier = Modifier.fillMaxSize(),
imageVector = Icons.Rounded.PrivacyTip,
contentDescription = null,
)
}
IconButton(
onClick = onSettingsButtonClicked,
modifier = Modifier
.size(ButtonSize)
.background(
color = LocalContentColor.current.copy(alpha = 0.0f),
shape = CircleShape
)
) {
Icon(
modifier = Modifier.fillMaxSize(),
imageVector = Icons.Rounded.Settings,
contentDescription = null,
)
}
}
//#endregion
}
...

There’s nothing special about this block, it’s just a Row that contains two IconButton components. Instead of using paddings, we separate both buttons by setting a value to the horizontalArrangement argument that the Row component receives. In our case, all we need to do is set its value by calling the Arrangement.spaceBy function and passing in our ContentPadding constant.

For the IconButton components, we assign their corresponding lambdas to their onClick methods. Also, with their modifiers we set their size, shape, and color, which in our case is transparent (alpha = 0f). The last thing to do is pass an Icon component to their content parameter, expressed as a composable trailing lambda.

And that’s it! I hope you now have a clear understanding of the “magic” behind the composition and dynamics of a collapsing toolbar. If you have any questions, please feel free to leave a comment below.

💬 If you enjoyed this article, you can show your appreciation by buying me a coffee at the link below. Thanks for reading and for your support.

--

--

Glenn Sandoval
Kotlin and Kotlin for Android

I’m a software developer who loves learning and making new things all the time. I especially like mobile technology.