Collapsing Toolbar Animation Implementation in Mobile Apps
A Collapsing Toolbar is a header that contracts as content scrolls downward. When expanded, it displays a large image and prominent title; as content scrolls, it becomes a compact navigation panel. Both iOS Contacts app and Android Play Store use this pattern.
The challenge lies in synchronizing scroll position with toolbar size, title position, and element opacity. All must work smoothly on 120 Hz displays without stuttering.
Android: CollapsingToolbarLayout
The declarative approach uses CollapsingToolbarLayout within AppBarLayout:
<CoordinatorLayout>
<AppBarLayout android:id="@+id/appBar" android:layout_height="250dp">
<CollapsingToolbarLayout
app:layout_scrollFlags="scroll|exitUntilCollapsed"
app:contentScrim="?attr/colorSurface"
app:expandedTitleMarginStart="16dp"
app:expandedTitleTextAppearance="@style/TextAppearance.App.HeadlineMedium"
app:collapsedTitleTextAppearance="@style/TextAppearance.App.TitleMedium">
<ImageView
android:layout_height="match_parent"
app:layout_collapseMode="parallax"
app:layout_collapseParallaxMultiplier="0.5" />
<Toolbar
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin" />
</CollapsingToolbarLayout>
</AppBarLayout>
<RecyclerView
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</CoordinatorLayout>
app:layout_collapseMode="parallax" on ImageView creates parallax during collapse. "pin" on Toolbar pins it when fully collapsed. app:layout_scrollFlags="scroll|exitUntilCollapsed" causes AppBar to scroll with content until reaching minimum height (Toolbar height).
contentScrim is a color or drawable that appears over the image as it collapses, animating smoothly.
Custom logic uses AppBarLayout.OnOffsetChangedListener:
appBarLayout.addOnOffsetChangedListener { appBar, offset ->
val progress = (-offset).toFloat() / appBar.totalScrollRange.toFloat()
// progress: 0f = expanded, 1f = collapsed
avatarView.alpha = 1f - (progress * 2).coerceIn(0f, 1f)
subtitleView.scaleX = 1f - progress * 0.3f
subtitleView.scaleY = subtitleView.scaleX
}
Jetpack Compose: TopAppBarScrollBehavior
Compose Material3 provides LargeTopAppBar with TopAppBarDefaults.exitUntilCollapsedScrollBehavior():
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
topBar = {
LargeTopAppBar(
title = { Text("Title") },
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
)
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { padding ->
LazyColumn(contentPadding = padding) { ... }
}
For custom collapsing toolbar with images (LargeTopAppBar doesn't support photos in the header), build using NestedScrollConnection:
val toolbarHeightExpanded = 250.dp
val toolbarHeightCollapsed = 56.dp
val toolbarHeightPx = with(LocalDensity.current) { toolbarHeightExpanded.toPx() }
val toolbarOffset = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = toolbarOffset.value + delta
toolbarOffset.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
return Offset.Zero
}
}
}
val progress = (-toolbarOffset.value / toolbarHeightPx).coerceIn(0f, 1f)
val currentHeight = lerp(toolbarHeightExpanded, toolbarHeightCollapsed, progress)
progress is the key value controlling image alpha, title scale, and visibility of additional elements.
iOS: UIScrollViewDelegate + Auto Layout
The classic iOS approach uses scrollViewDidScroll:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.y
let maxOffset: CGFloat = 200 // expanded header height
if offset < 0 {
// Overscroll downward—stretch image
headerHeightConstraint.constant = 250 - offset
headerImageView.transform = .identity
} else {
let progress = min(offset / maxOffset, 1.0)
headerHeightConstraint.constant = max(250 - offset, 56)
// Fade out image
headerImageView.alpha = 1 - progress
// Title appears in nav bar
navigationItem.title = progress > 0.9 ? screenTitle : ""
}
// No layoutIfNeeded in animation—direct constraint change; next layout pass applies it
}
Changing constraints directly in scrollViewDidScroll without animation is correct; layout pass occurs at the next CADisplayLink frame. Calling layoutIfNeeded() here would create recursion.
In SwiftUI, use ScrollView + GeometryReader to track scroll position and @State for header height management.
Timeline
Collapsing toolbar using standard CollapsingToolbarLayout or LargeTopAppBar: half a day. Custom with image, parallax, and multiple animated elements: 1–2 days. Cost is calculated individually.







