Collapsing Toolbar in Jetpack Compose | A closer look at the Toolbar — Part 2
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
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 theres/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 thedimen.xml
file, Jetpack Compose provides us with thedimensionResource(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 between0f
and1f
, where1f
corresponds to its expanded state and0f
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 theSurface
.
— 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:
- It applies Material Design styles by default.
- 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.
- 📖 You can find the Material Design for Android official documentation at https://developer.android.com/develop/ui/views/theming/look-and-feel.
- 📖 You can find the BiasAlignment official documentation at https://developer.android.com/reference/kotlin/androidx/compose/ui/BiasAlignment.