Реализация анимации сворачивания/раскрытия (Collapsing Toolbar) в мобильном приложении
Collapsing Toolbar — шапка экрана, которая сворачивается при скролле контента вниз. В развёрнутом виде — большое изображение и крупный заголовок, при скролле — компактная навигационная панель. iOS Contacts app, Android Play Store карточка приложения — оба используют этот паттерн.
Сложность: нужно синхронизировать scroll position с размером toolbar, позицией заголовка, opacity элементов. И сделать это без скачков на 120 Hz дисплеях.
Android: CollapsingToolbarLayout
Декларативный путь через Material Components — 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 — ключевая величина. Через неё управляем alpha изображения, scale заголовка, visibility дополнительных элементов.
iOS: UIScrollViewDelegate + Auto Layout
В UIKit — классика через 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
// Title появляется в nav bar
navigationItem.title = progress > 0.9 ? screenTitle : ""
}
// Без layoutIfNeeded в animate — прямое изменение constraint, следующий layout pass подхватит
}
Изменение constraint без анимации прямо в scrollViewDidScroll — правильно, layout pass происходит при следующем CADisplayLink кадре. Вызов layoutIfNeeded() здесь создаст рекурсию.
В SwiftUI — аналогично параллаксу через ScrollView + GeometryReader для отслеживания scroll position и @State для управления высотой header.
Сроки
Collapsing toolbar через стандартный CollapsingToolbarLayout или LargeTopAppBar: полдня. Кастомный с изображением, parallax и несколькими анимируемыми элементами: 1–2 дня. Стоимость рассчитывается индивидуально.







