Jetpack Compose. Header animation like in IOS Weather App.

Michael Katkov
7 min readMar 1, 2023

--

Learning Jetpack Compose I started wondering if it is possible to make with Jetpack Compose a list animation like in IOS Weather App.

This is what I have finished until now. I think it is similar, but without a beautiful OpenGL layer in the background like in the IOS Weather App.

Implementing the toolbar animation

We can see the behaviour which reminds us the Coordinator Layout here. This was the first thing I’ve started looking for. I was wondering if there is something like the Coordinator Layout in Jetpack Compose. It turned out that there is the nestedScroll modifier which we can use to implement something similar. On the nestedScroll modifier page you can find an example of the collapsing toolbar implementation. If you run the code from the example you will see something like this:

But it is not exactly what we want. We need the toolbar longer, we do not want it to disappear completely and the toolbar shouldn’t expand when we scroll down until all the list is visible on the screen. Something like this:

This is the code for this functionality:

@Composable
fun ForecastBody() {
val density = LocalDensity.current
val maxToolbarHeightDp = 220.dp
val maxToolbarHeightPx =
with(density) { maxToolbarHeightDp.roundToPx().toFloat() }
val minToolbarHeightDp = 60.dp
val toolbarDelta = with(density) {
(maxToolbarHeightDp - minToolbarHeightDp).roundToPx().toFloat()
}
val listItemHeightDp = 50.dp
val listItemCount = 100
val listContentHeightPx = with(density) {
(listItemHeightDp * listItemCount).roundToPx().toFloat()
}
val maxScrollOffset = remember { mutableStateOf(0f) }
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
val toolbarOffsetHeightDp =
with(density) { toolbarOffsetHeightPx.value.toDp() }
val scrollOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val postScrollOffset = scrollOffsetHeightPx.value + available.y
scrollOffsetHeightPx.value = postScrollOffset.coerceIn(maxScrollOffset.value, 0f)
if (available.y < 0.0f) {
val newOffset = toolbarOffsetHeightPx.value + available.y
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarDeltaPx, 0f)
} else if (available.y > 0.0f &&
scrollOffsetHeightPx.value >= toolbarOffsetHeightPx.value) {
toolbarOffsetHeightPx.value = scrollOffsetHeightPx.value
}
return Offset.Zero
}
}
}
Box(
Modifier
.fillMaxSize()
.onGloballyPositioned {
val layoutHeight = it.size.height.toFloat()
maxScrollOffset.value = layoutHeight - maxToolbarHeightPx - listContentHeightPx
}
.nestedScroll(nestedScrollConnection)
) {
LazyColumn(
modifier = Modifier.padding(top = minToolbarHeightDp ),
contentPadding =
PaddingValues(top = maxToolbarHeightDp - minToolbarHeightDp )
) {
items(listItemCount) { index ->
Text(modifier = Modifier
.fillMaxWidth()
.height(listItemHeightDp),
text = "I'm item $index",
color = Color.Black)
}
}
TopAppBar(
modifier = Modifier
.height(maxToolbarHeightDp + toolbarOffsetHeightDp),
title = { Text("Toolbar") }
)
}
}

The Box composable is the main composable of our layout. It holds TopAppBar composable, which is our toolbar and LazyColumn composable, which is our List.

The toolbar height changes between minimum toolbar height and maximum toolbar height.

The list top padding equals to the minimum toolbar height. The list top content padding takes the rest of the maximum toolbar height.

The box has a NestedScrollConnection, which informs us about the list scroll offset.

Once we scroll up the NestedScrollConnection receives information about negative list moves and informs the toolbar to start shrinking by updating the toolbar offset height.

Once we scroll down the NestedScrollConnection receives information about positive list moves, but it starts informing the toolbar to expand only when all the list items are visible on the screen.

Animating the toolbar content

Let’s define our toolbar content:

@Composable
fun ToolbarContent(forecast: ForecastModel, alpha: Float) {
val oppositeAlpha: Float = 1.0f - alpha
val density = LocalDensity.current
val middleMargin = with(density) { 8.dp.roundToPx() }
val smallMargin = with(density) { 4.dp.roundToPx() }
val locationOffset = with(density) { 8.dp.roundToPx() }
val locationFontSize = MaterialTheme.typography.h5.fontSize
val locationHeight = with(density) { locationFontSize.roundToPx() }
val temperatureOffset = locationHeight + smallMargin
val temperatureFontSize = MaterialTheme.typography.h1.fontSize
val temperatureHeight = with(density) { temperatureFontSize.roundToPx() }
val imageOffset =
locationHeight + temperatureHeight + 2 * middleMargin + smallMargin
val imageSize = 32.dp
val imageHeight = with(density) { imageSize.roundToPx() }
val conditionOffset = imageOffset + imageHeight + middleMargin
val conditionSize = MaterialTheme.typography.subtitle1.fontSize
val temperatureOffset2 = locationOffset + locationHeight + smallMargin
Box(modifier = Modifier.fillMaxSize()) {
Text(
modifier = Modifier
.align(Alignment.TopCenter)
.offset { IntOffset(0, locationOffset) },
text = forecast.location?.name ?: "None",
color = MaterialTheme.colors.secondary,
fontSize = locationFontSize
)
Text(
modifier = Modifier
.align(Alignment.TopCenter)
.offset { IntOffset(0, temperatureOffset2) }
.alpha(oppositeAlpha),
text = "${forecast.current?.tempC?.toInt()}\u00B0 | ${forecast.current?.condition?.text ?: "None"}",
color = MaterialTheme.colors.primaryVariant,
fontSize = conditionSize
)
Text(
modifier = Modifier
.align(Alignment.TopCenter)
.offset { IntOffset(0, temperatureOffset) }
.alpha(alpha),
text = "${forecast.current?.tempC?.toInt()}\u00B0",
color = MaterialTheme.colors.primaryVariant,
fontSize = temperatureFontSize
)
AsyncImage(
modifier = Modifier
.align(Alignment.TopCenter)
.size(size = imageSize)
.offset { IntOffset(0, imageOffset) }
.alpha(alpha),
model = forecast.current?.condition?.iconImg,
placeholder = DebugPlaceholder(R.drawable.sun),
contentDescription = "Description"
)
Text(
modifier = Modifier
.align(Alignment.TopCenter)
.offset { IntOffset(0, conditionOffset) }
.alpha(alpha),
text = forecast.current?.condition?.text ?: "None",
color = MaterialTheme.colors.secondary,
fontSize = conditionSize
)
}
}

The composable takes 2 parameters a forecast and an alpha. The forecast contains simple data to populate our composable. The alpha is needed to make some elements appear and some disappear once we scroll.

Then the code for our forecast screen will look like this:

@Composable
fun ForecastBody(forecast: ForecastModel) {
val density = LocalDensity.current
val maxToolbarHeightDp = 220.dp
val maxToolbarHeightPx =
with(density) { maxToolbarHeightDp.roundToPx().toFloat() }
val minToolbarHeightDp = 60.dp
val toolbarDelta = with(density) {
(maxToolbarHeightDp - minToolbarHeightDp).roundToPx().toFloat()
}
val listItemHeightDp = 50.dp
val listItemCount = 100
val listContentHeightPx = with(density) {
(listItemHeightDp * listItemCount).roundToPx().toFloat()
}
val maxScrollOffset = remember { mutableStateOf(0f) }
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
val toolbarOffsetHeightDp =
with(density) { toolbarOffsetHeightPx.value.toDp() }
val scrollOffsetHeightPx = remember { mutableStateOf(0f) }
val alpha: Float = (toolbarDelta + toolbarOffsetHeightPx.value) / toolbarDelta
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val postScrollOffset = scrollOffsetHeightPx.value + available.y
scrollOffsetHeightPx.value = postScrollOffset.coerceIn(maxScrollOffset.value, 0f)
if (available.y < 0.0f) {
val newOffset = toolbarOffsetHeightPx.value + available.y
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarDeltaPx, 0f)
} else if (available.y > 0.0f &&
scrollOffsetHeightPx.value >= toolbarOffsetHeightPx.value) {
toolbarOffsetHeightPx.value = scrollOffsetHeightPx.value
}
return Offset.Zero
}
}
}
Box(
Modifier
.fillMaxSize()
.onGloballyPositioned {
val layoutHeight = it.size.height.toFloat()
maxScrollOffset.value = layoutHeight - maxToolbarHeightPx - listContentHeightPx
}
.nestedScroll(nestedScrollConnection)
) {
LazyColumn(
modifier = Modifier.padding(top = minToolbarHeightDp ),
contentPadding =
PaddingValues(top = maxToolbarHeightDp - minToolbarHeightDp )
) {
items(listItemCount) { index ->
Text(modifier = Modifier
.fillMaxWidth()
.height(listItemHeightDp),
text = "I'm item $index",
color = MaterialTheme.colors.secondary)
}
}
TopAppBar(
modifier = Modifier
.height(maxToolbarHeightDp + toolbarOffsetHeightDp),
title = { ToolbarContent(forecast = forecast, alpha = alpha) }
)
}
}

We only added ToolbarContent composable as a content of out toolbar and created a new alpha state, which depends on the scroll offset amount.

ToolbarContent consumes alpha to make the text right below the city name appear and the rest of the content disappear when we are scrolling up. When we scroll down the text right below the city starting disappear and the rest of the content starting appear.

So, our screen will look like this:

Implementing sections

In the list we have items and sticky headers. One item and one sticky header form a section. An item has rounded bottom corners, and a header has rounded top corners. Once a header sticks the item continues scrolling. There are two problems with this.

  1. The item content starts to be visible under the header while we are scrolling. This happens because of the header rounded top corners.
  2. The header bottom corners stay sharp once the item disappears under the header.

You can see these 2 problems here:

The first problem we can solve by adding a gradient background to the header. You can see on the preview image that section headers have the white background on top. This is because of the background white to transparent gradient. This solution will work only if we have one colour background. So, probably, in the IOS Weather App they use another technic. I will try to find a better solution and update the article with it.

The second problem we can solve by watching scroll offset. When the scroll offset is equal to the list portion height, which takes the corresponding section minus the section header height, we round the header bottom corners.

The last thing I want to mention. The Weather App IOS is fading out the sticky header once the next sections scrolls up, but in my implementation the section header slides up. This is default sticky header behaviour in Jetpack Compose. I didn’t find how to rewrite the behaviour yet, but I am working on that.

Finally, I have implemented the Coordinator library:

Which allowed me to simplify the widget by using the CoordinatorLayout from the library and by extending ExitUntilCollapsedBehavior from the library:

CoordinatorLayout(
behavior = ForecastCollapsedBehavior(forecastToolbarState),
toolbarContent = {
TopAppBar(
modifier = Modifier.fillMaxSize(),
title = { ToolbarContent(forecast = forecast, alpha = forecastToolbarState.alpha) },
)
}) {
forecastContent(forecast, forecastToolbarState)
}

The full implementation you can find here:

Thanks for reading.

--

--