Реалізація анімації Collapsing Toolbar у мобільних додатках
Collapsing Toolbar — це шапка екрана, яка стискується при прокручуванні контенту вниз. У розгорнутому стані вона показує велике зображення та великий заголовок, при прокручуванні стає компактною навігаційною панеллю. Як iOS Contacts, так і Android Play Store використовують цей паттерн.
Складність полягає в синхронізації позиції прокручування з розміром toolbar, позицією заголовка та непрозорістю елементів. Все має працювати гладко на дисплеях з частотою 120 Hz без затримок.
Android: CollapsingToolbarLayout
Декларативний підхід використовує CollapsingToolbarLayout всередину 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" на ImageView створює паралакс під час стиснення. "pin" на Toolbar фіксує його при повному стиснені. app:layout_scrollFlags="scroll|exitUntilCollapsed" змушує AppBar прокручуватися разом з контентом до мінімальної висоти (висота Toolbar).
contentScrim — це колір або drawable, який з'являється над зображенням при стиснені, гладко анімуючись.
Кастомна логіка використовує AppBarLayout.OnOffsetChangedListener:
appBarLayout.addOnOffsetChangedListener { appBar, offset ->
val progress = (-offset).toFloat() / appBar.totalScrollRange.toFloat()
// progress: 0f = розгорнуто, 1f = стиснуто
avatarView.alpha = 1f - (progress * 2).coerceIn(0f, 1f)
subtitleView.scaleX = 1f - progress * 0.3f
subtitleView.scaleY = subtitleView.scaleX
}
Jetpack Compose: TopAppBarScrollBehavior
Compose Material3 надає LargeTopAppBar з TopAppBarDefaults.exitUntilCollapsedScrollBehavior():
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
topBar = {
LargeTopAppBar(
title = { Text("Заголовок") },
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
)
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { padding ->
LazyColumn(contentPadding = padding) { ... }
}
Для кастомного collapsing toolbar із зображеннями (LargeTopAppBar не підтримує фото в заголовку) побудуйте через 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 — це ключова величина для управління альфою зображення, масштабом заголовка та видимістю додаткових елементів.
iOS: UIScrollViewDelegate + Auto Layout
Класичний iOS підхід використовує scrollViewDidScroll:
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.y
let maxOffset: CGFloat = 200 // висота розгорнутого header
if offset < 0 {
// Overscroll вниз — розтягуємо зображення
headerHeightConstraint.constant = 250 - offset
headerImageView.transform = .identity
} else {
let progress = min(offset / maxOffset, 1.0)
headerHeightConstraint.constant = max(250 - offset, 56)
// Fade out зображення
headerImageView.alpha = 1 - progress
// Заголовок з'являється в nav bar
navigationItem.title = progress > 0.9 ? screenTitle : ""
}
// Без layoutIfNeeded в animate — пряма зміна constraint, наступний layout pass це підхопить
}
Зміна constraint без анімації прямо в scrollViewDidScroll — це правильно, layout pass відбувається при наступному CADisplayLink кадрі. Виклик layoutIfNeeded() тут створить рекурсію.
У SwiftUI використовуйте ScrollView + GeometryReader для відстеження позиції прокручування та @State для управління висотою header.
Терміни
Collapsing toolbar через стандартний CollapsingToolbarLayout або LargeTopAppBar: половина дня. Кастомний з зображенням, паралаксом та кількома анімованими елементами: 1–2 дні. Вартість розраховується індивідуально.







