Реализация нашей дизайн-системы на Jetpack Compose не всегда проходила гладко. Большинство компонентов мы переписали без проблем, но с некоторыми пришлось повозиться. Одним из таких компонентов стал аналог старого доброго CollapsingToolbarLayout из View-мира. В статье разберем тонкости его реализации на Compose: погрузимся в тонкости кастомного лейаутинга и системы вложенного скролла Compose, а также посмотрим в исходники библиотеки androidx.compose.material3, вдохновившей нас на итоговое решение.
Материал может быть полезен тем, кто собирается делать сложные кастомные компоненты на Compose, и всем, кто интересуется внутренними деталями работы Compose-компонентов.

Состояния компонента
Компонент дизайн-системы, который нам нужно было реализовать на Compose, имеет достаточно много состояний. Вот так выглядит самое простое:

При этом мы хотим, чтобы заголовок схлопывался при скролле контента экрана:

Опционально мы можем менять стиль заголовка, добавлять иконку навигации, кнопки дополнительных действий и произвольный контент внизу тулбара (например, табы ViewPager):

Также мы должны уметь добавлять произвольный контент в центре тулбара, отключая при этом схлопывание заголовка при скролле:

На некоторых экранах заголовок может иметь несколько строк. В таких случаях мы должны плавно переходить между заголовком из двух строк к заголовку из одной строки с многоточием:

И, конечно же, в каждом из состояний при проскролле контента экрана нам нужно анимированно переключить тень в нижней части тулбара.
Ищем инструмент
Такой компонент было бы легко реализовать с помощью стандартных Compose-компонентов: Row и Column, если бы не поведение заголовка при скролле. В старом View-мире для такого поведения мы привыкли использовать связку из CollapsingToolbarLayout и CoordinatorLayout из material-библиотеки. Поэтому, прежде чем изобретать свое решение, давайе посмотрим на готовые варианты из Compose-мира.
Один из таких вариантов — компонент TopAppBar из compose.material3. Мы рассматриваем именно material3, так как в compose.material аналогичный компонент статичен и никак не взаимодействует со скроллом. TopAppBar из material3 реализован через кастомный лейаут, но поведение при скролле (на момент версии 1.0.1) у него выглядит совсем не так, как у привычного нам CollapsingToolbarLayout. Заголовок TopAppBar не схлопывается, а просто делает fade-in/fade-out:

На момент нашего ресерча мы рассматривали и другой вариант: библиотека compose-collapsing-toolbar, которая предлагает свою систему лейаутинга для контента тулбара и позволяет гибко настроить его содержимое. Система лейаутинга в этой библиотеке строится на модифаерах, которыми можно настроить отдельные компоненты. Например, задать им выравнивание в зависимости от состояния схлопнутости или настроить параллакс. Если вам необходимо реализовать несложный collapsing toolbar, то, возможно, эта библиотека хорошо подойдет под ваш кейс.
К сожалению, системы лейаутинга из этой библиотеки оказалось недостаточно для нашего компонента, так как положение динамически меняющегося заголовка у нас сильно зависит от расположения опциональных компонентов внутри тулбара. Из-за этого приходилось вычислять вручную довольно много параметров вложенных компонентов, и в итоге мы пришли к тому, что фактически пишем свой лейаут, но поверх сторонней библиотеки.
А еще компонент из нашей дизайн-системы наверняка можно было бы сделать через Compose-реализацию MotionLayout. Но отчасти нас остановил неидеальный опыт работы с ним в прошлом, а отчасти, потому что очень привлекательным показался вариант адаптировать TopAppBar из compose.material3 под специфику нашей дизайн-системы, ведь эти компоненты во многом схожи. Если у вас есть опыт реализации аналогичных Compose-компонентов через MotionLayout, пишите в комментариях ваш фидбек о работе с ним, но мы в итоге пошли в сторону адаптации исходников TopAppBar из compose.material3 под наши нужды.
Кастомный layout в Compose
TopAppBar из compose.material3 построен на основе кастомного лейаута. Ключевой Composable-функцией для создания кастомного лейаута является Layout. С ее помощью мы можем задать произвольный способ измерения и расположения виджетов. Реализация кастомного лейаута состоит из трех этапов:
измерить размеры вложенных в лейаут (дочерних) компонентов;
определить итоговые размеры лейаута;
расположить дочерние компоненты внутри лейаута.
Дочерние компоненты мы передаем в функцию Layout через аргумент content. В нашем случае это: текст тайтла, опциональные слоты для navigation icon, экшенов, контента по центру тулбара и слот для произвольного контента в его нижней части. Для каждого компонента мы укажем модифаер layouId, который поможет нам в дальнейшем ссылаться на эти компоненты при лейаутинге:
Передача дочерних компонентов в Layout
Layout( content = { if (collapsingTitle != null) { Text( modifier = Modifier.layoutId(ExpandedTitleId) // ... ) Text( modifier = Modifier.layoutId(CollapsedTitleId) // ... ) } if (navigationIcon != null) { Box( modifier = Modifier.layoutId(NavigationIconId) ) { navigationIcon() } } if (actions != null) { Row( modifier = Modifier.layoutId(ActionsId) ) { actions() } } if (centralContent != null) { Box( modifier = Modifier.layoutId(CentralContentId) ) { centralContent() } } if (additionalContent != null) { Box( modifier = Modifier.layoutId(AdditionalContentId) ) { additionalContent() } } }, // ... )
При этом для заголовка мы использовали два компонента Text, между которыми в дальнейшем будем вычислять альфа-переход при схлопывании: постепенно скрывать многострочный развернутый текст и переходить к показу схлопнутого однострочного текста. View-реализация CollapsingToolbarLayout использует под капотом аналогичный трюк для поддержки схлопывания многострочных заголовков:
Компоненты Text для перехода от многострочного заголовка к однострочному
Layout( content = { if (collapsingTitle != null) { Text( modifier = Modifier .layoutId(ExpandedTitleId) .wrapContentHeight(align = Alignment.Top) .graphicsLayer( scaleX = collapsingTitleScale, scaleY = collapsingTitleScale, transformOrigin = TransformOrigin(0f, 0f) ), text = collapsingTitle.titleText, style = collapsingTitle.expandedTextStyle ) Text( modifier = Modifier .layoutId(CollapsedTitleId) .wrapContentHeight(align = Alignment.Top) .graphicsLayer( scaleX = collapsingTitleScale, scaleY = collapsingTitleScale, transformOrigin = TransformOrigin(0f, 0f) ), text = collapsingTitle.titleText, style = collapsingTitle.expandedTextStyle, maxLines = 1, overflow = TextOverflow.Ellipsis ) } // ... }, // ... )
Далее переходим к измерению дочерних компонентов. Для этого передадим в Layout лямбду measurePolicy. Один из параметров этой лямбды measurables — список объектов Measurable, которые отражают готовые к измерению дочерние компоненты лейаута, переданные ранее в аргументе content. По layoutId мы можем найти конкретные компоненты и измерить их нужным образом:
Измерение дочерних компонентов лейаута
Layout( content = { /* ... */ }, modifier = /* ... */, measurePolicy = { measurables, constraints -> val horizontalPaddingPx = HorizontalPadding.toPx() val expandedTitleBottomPaddingPx = ExpandedTitleBottomPadding.toPx() // Measuring widgets inside toolbar: val navigationIconPlaceable = measurables.firstOrNull { it.layoutId == NavigationIconId } ?.measure(constraints.copy(minWidth = 0)) val actionsPlaceable = measurables.firstOrNull { it.layoutId == ActionsId } ?.measure(constraints.copy(minWidth = 0)) val expandedTitlePlaceable = measurables.firstOrNull { it.layoutId == ExpandedTitleId } ?.measure( constraints.copy( maxWidth = (constraints.maxWidth - 2 * horizontalPaddingPx).roundToInt(), minWidth = 0, minHeight = 0 ) ) val additionalContentPlaceable = measurables.firstOrNull { it.layoutId == AdditionalContentId } ?.measure(constraints) val navigationIconOffset = when (navigationIconPlaceable) { null -> horizontalPaddingPx else -> navigationIconPlaceable.width + horizontalPaddingPx * 2 } val actionsOffset = when (actionsPlaceable) { null -> horizontalPaddingPx else -> actionsPlaceable.width + horizontalPaddingPx * 2 } val collapsedTitleMaxWidthPx = (constraints.maxWidth - navigationIconOffset - actionsOffset) / fullyCollapsedTitleScale val collapsedTitlePlaceable = measurables.firstOrNull { it.layoutId == CollapsedTitleId } ?.measure( constraints.copy( maxWidth = collapsedTitleMaxWidthPx.roundToInt(), minWidth = 0, minHeight = 0 ) ) val centralContentPlaceable = measurables.firstOrNull { it.layoutId == CentralContentId } ?.measure( constraints.copy( minWidth = 0, maxWidth = (constraints.maxWidth - navigationIconOffset - actionsOffset).roundToInt() ) ) // ... }
Здесь мы также используем объект constraints, из которого достаем ограничения для нашего лейаута: максимально доступные ему ширину и высоту. Например, дочерним компонентам navigationIcon и actions мы дали для измерения все доступное пространство, а для размера заголовков в схлопнутом и расхлопнутом состоянии выполнили вычисления с учетом боковых отступов итогового компонента и размеров соседних компонентов.
Вызов функции измерения (measure) на объектах типа Measurable возвращает Placeable, то есть измеренные компоненты, готовые к расположению на лейауте. Теперь мы можем посчитать координаты дочерних компонентов и определиться с итоговыми размерами лейаута: будем использовать максимально доступную ширину, а высоту рассчитаем на основе размеров дочерних компонентов и текущией схлопнутости тулбара:
Определяем координаты дочерних компонентов и размеры лейаута
Layout( content = { /* ... */ }, modifier = /* ... */, measurePolicy = { measurables, constraints -> // ... val collapsedHeightPx = when { centralContentPlaceable != null -> max(MinCollapsedHeight.toPx(), centralContentPlaceable.height.toFloat()) else -> MinCollapsedHeight.toPx() } var layoutHeightPx = collapsedHeightPx // Calculating coordinates of widgets inside toolbar: // Current coordinates of navigation icon val navigationIconX = horizontalPaddingPx.roundToInt() val navigationIconY = ((collapsedHeightPx - (navigationIconPlaceable?.height ?: 0)) / 2).roundToInt() // Current coordinates of actions val actionsX = (constraints.maxWidth - (actionsPlaceable?.width ?: 0) - horizontalPaddingPx).roundToInt() val actionsY = ((collapsedHeightPx - (actionsPlaceable?.height ?: 0)) / 2).roundToInt() // Current coordinates of title var collapsingTitleY = 0 var collapsingTitleX = 0 if (expandedTitlePlaceable != null && collapsedTitlePlaceable != null) { // Measuring toolbar collapsing distance val heightOffsetLimitPx = expandedTitlePlaceable.height + expandedTitleBottomPaddingPx scrollBehavior?.state?.heightOffsetLimit = when (centralContent) { null -> -heightOffsetLimitPx else -> -1f } // Toolbar height at fully expanded state val fullyExpandedHeightPx = MinCollapsedHeight.toPx() + heightOffsetLimitPx // Coordinates of fully expanded title val fullyExpandedTitleX = horizontalPaddingPx val fullyExpandedTitleY = fullyExpandedHeightPx - expandedTitlePlaceable.height - expandedTitleBottomPaddingPx // Coordinates of fully collapsed title val fullyCollapsedTitleX = navigationIconOffset val fullyCollapsedTitleY = collapsedHeightPx / 2 - CollapsedTitleLineHeight.toPx().roundToInt() / 2 // Current height of toolbar layoutHeightPx = lerp(fullyExpandedHeightPx, collapsedHeightPx, collapsedFraction) // Current coordinates of collapsing title collapsingTitleX = lerp(fullyExpandedTitleX, fullyCollapsedTitleX, collapsedFraction).roundToInt() collapsingTitleY = lerp(fullyExpandedTitleY, fullyCollapsedTitleY, collapsedFraction).roundToInt() } else { scrollBehavior?.state?.heightOffsetLimit = -1f } val toolbarHeightPx = layoutHeightPx.roundToInt() + (additionalContentPlaceable?.height ?: 0) // ... }
В вычислениях высоты мы использовали некий collapsedFraction, то есть текущую долю схлопнутости тулбара (от 0 до 1). В дальнейшем мы свяжем ее со скроллом контента экрана. По этому значению очень удобно вычислять текущие координаты заголовка, используя функцию линейной интерполяции (lerp).
После того, как координаты дочерних компонентов и размеры лейаута определены, остается вызвать метод placeRelative, чтобы зафиксировать компоненты на лейауте:
Размещаем дочерние компоненты на лейауте
Layout( content = { /* ... */ }, modifier = /* ... */, measurePolicy = { measurables, constraints -> // ... layout(width = constraints.maxWidth, height = toolbarHeightPx) { navigationIconPlaceable?.placeRelative( x = navigationIconX, y = navigationIconY ) actionsPlaceable?.placeRelative( x = actionsX, y = actionsY ) centralContentPlaceable?.placeRelative( x = navigationIconOffset.roundToInt(), y = ((collapsedHeightPx - centralContentPlaceable.height) / 2).roundToInt() ) if (expandedTitlePlaceable?.width == collapsedTitlePlaceable?.width) { expandedTitlePlaceable?.placeRelative( x = collapsingTitleX, y = collapsingTitleY, ) } else { expandedTitlePlaceable?.placeRelativeWithLayer( x = collapsingTitleX, y = collapsingTitleY, layerBlock = { alpha = 1 - collapsedFraction } ) collapsedTitlePlaceable?.placeRelativeWithLayer( x = collapsingTitleX, y = collapsingTitleY, layerBlock = { alpha = collapsedFraction } ) } additionalContentPlaceable?.placeRelative( x = 0, y = layoutHeightPx.roundToInt() ) } }
На этом этапе мы также реализовали переход от многострочного заголовка к однострочному при помощи параметра layerBlock.
И-и-и... Наш кастомный лейаут готов! Остается связать его со скроллом.
Обработка nested scroll в Compose
Мы хотим добиться поведения, при котором скролл от одного и того же жеста пользователя может переходить из пролистывания контента в схлопывание тулбара и наоборот. Для реализации такого поведения мы можем использовать модифайер nestedScroll. Определим его на общем родительском контейнере для тулбара и схлопывающегося контента под ним:
Использование nestedScroll
val nestedScrollConnection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { return /* consumed amount of scroll */ } override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { return /* consumed amount of scroll */ } override suspend fun onPreFling(available: Velocity): Velocity { return /* consumed amount of scroll velocity */ } override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { return /* consumed amount of scroll velocity */ } } Scaffold( modifier = Modifier.nestedScroll(nestedScrollConnection), topBar = { CustomToolbar(/* toolbar params */) }, ) { contentPadding -> LazyColumn { /* screen content */ } }
С помощью интерфейса NestedScrollConnection мы можем реагировать на события скролла иерархии компонентов внутри контейнера. Система вложенного скролла в Compose сначала прокидывает скролл вниз по иерархии: от родительских компонентов к дочерним, позволяя каждому компоненту "забрать себе" некоторое количество скролла. На такое событие мы можем реагировать через методы onPreScroll и onPreFling интерфейса NestedScrollConnection. Их возвращаемое значение — количество скролла, которое компонент "забрал себе" (consumed).
После того, как скролл спустился вниз и его обработали дочерние компоненты, мы можем поймать оставшийся после них скролл и обработать его подъем вверх по иерархии — от дочерних компонентов к родительским. Чтобы поймать отставший скролл, мы используем методы onPostScroll и onPostFling.
Для реализации скролла TopAppBar из compose.material3 использует вспомогательный класс, в котором трекается состояние проскроллености контента и количество пикселей, на которое должен схлопываться тулбар. В этом же классе задается максимальное и минимальное ограничение схлопнутости тулбара, на основе которых можно вычислить collapsedFraction, то есть определить долю схлопнутости тулбара:
ScrollState для тулбара
@Stable class CustomToolbarScrollState( initialHeightOffsetLimit: Float, initialHeightOffset: Float, initialContentOffset: Float, ) { /* ... */ /** * The top app bar's height offset limit in pixels, which represents the limit that a top app * bar is allowed to collapse to. * * Use this limit to coerce the [heightOffset] value when it's updated. */ var heightOffsetLimit by mutableStateOf(initialHeightOffsetLimit) /** * The top app bar's current height offset in pixels. This height offset is applied to the fixed * height of the app bar to control the displayed height when content is being scrolled. * * Updates to the [heightOffset] value are coerced between zero and [heightOffsetLimit]. */ var heightOffset: Float get() = _heightOffset.value set(newOffset) { _heightOffset.value = newOffset.coerceIn( minimumValue = heightOffsetLimit, maximumValue = 0f ) } /** * The total offset of the content scrolled under the top app bar. * * This value is updated by a [CustomToolbarScrollBehavior] whenever a nested scroll connection * consumes scroll events. A common implementation would update the value to be the sum of all * [NestedScrollConnection.onPostScroll] `consumed.y` values. */ var contentOffset by mutableStateOf(initialContentOffset) /** * A value that represents the collapsed height percentage of the app bar. * * A `0.0` represents a fully expanded bar, and `1.0` represents a fully collapsed bar (computed * as [heightOffset] / [heightOffsetLimit]). */ val collapsedFraction: Float get() = if (heightOffsetLimit != 0f) { heightOffset / heightOffsetLimit } else { 0f } private var _heightOffset = mutableStateOf(initialHeightOffset) }
Посмотрим, как этот стейт используется для реализации NestedScrollConection тулбара. Начнем с метода onPreScroll:
Обработка onPreScroll
class CustomToolbarScrollBehavior( val state: CustomToolbarScrollState, // ... ) { val nestedScrollConnection = object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { // Don't intercept if scrolling down. if (available.y > 0f) return Offset.Zero val prevHeightOffset = state.heightOffset state.heightOffset = state.heightOffset + available.y return if (prevHeightOffset != state.heightOffset) { // We're in the middle of top app bar collapse or expand. // Consume only the scroll on the Y axis. available.copy(x = 0f) } else { Offset.Zero } } /* ... */ } }
Здесь мы не будем реагировать на скролл контента вниз, позволяя контенту под тулбаром сначала обработать доступный ему скролл. В этом случае в качестве consumed-значения скролла мы возвращаем 0, поскольку никакой скролл наш компонент пока не использовал.
При скролле вверх мы добавляем доступное значение скролла к текущему состоянию проскроллености тулбара и, если тулбару еще есть куда схлопываться, сообщим системе скролла, что мы использовали доступный нам скролл, вернув соответствующее consumed-значение из onPreScroll. Так мы избежим лишнего проскролливания контента под тулбаром. Если мы этого не сделаем, то получим вот такое некорректное поведение:

В onPostScroll, мы получаем значение скролла, которое уже обработал дочерний контент, и прибавляем это количество к накапливаемому нами состоянию проскроллености контента:
Обработка onPostScroll
class CustomToolbarScrollBehavior( val state: CustomToolbarScrollState, // ... ) { val nestedScrollConnection = object : NestedScrollConnection { /* ... */ override fun onPostScroll( consumed: Offset, available: Offset, source: NestedScrollSource, ): Offset { state.contentOffset += consumed.y if (available.y < 0f || consumed.y < 0f) { // When scrolling up, just update the state's height offset. val oldHeightOffset = state.heightOffset state.heightOffset = state.heightOffset + consumed.y return Offset(0f, state.heightOffset - oldHeightOffset) } if (consumed.y == 0f && available.y > 0) { // Reset the total content offset to zero when scrolling all the way down. This // will eliminate some float precision inaccuracies. state.contentOffset = 0f } if (available.y > 0f) { // Adjust the height offset in case the consumed delta Y is less than what was // recorded as available delta Y in the pre-scroll. val oldHeightOffset = state.heightOffset state.heightOffset = state.heightOffset + available.y return Offset(0f, state.heightOffset - oldHeightOffset) } return Offset.Zero } /* ... */ } }
Поскольку мы аккумулируем в state.contentOffset сумму из большого количества чисел плавающей точкой, необходимо обнулять это количество, когда скролл внутреннего контента возвращается в свое начальное положение. Это помогает отчасти устранить последствия ошибок округления чисел. Доступное количество скролла после обработки дочерним компонентом мы прибавляем к состоянию схлопнутости тулбара. Это кейс со скроллом контента вниз.
Теперь поговорим про fling-жест. Fling-жест — это резкий свайп вверх или вниз. Он отличается от обычного скролла тем, что вместо конкретного количества скролла мы сообщаем экрану определенную скорость, с которой контент должен скроллиться, даже когда мы пользователь уберет палец с экрана:
Демо fling-жеста

Скорость должна постепенно уменьшаться до полной остановки. Для этого метод onPostFling принимает не просто количество скролла, а velocity: скорость в количестве пикселей в секунду. Обработка флинг-жеста — это технически обработка анимации. Только анимируем мы не сам контент, а уменьшение velocity, чтобы создать имитацию физического эффекта замедления. Эту анимацию мы будем проигрывать до тех пор, пока не используем всю доступную нашему компоненту скорость:
Обработка onPostFling
class CustomToolbarScrollBehavior( val state: CustomToolbarScrollState, // ... ) { val nestedScrollConnection = object : NestedScrollConnection { /* ... */ override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { var result = super.onPostFling(consumed, available) // Check if the app bar is partially collapsed/expanded. // Note that we don't check for 0f due to float precision with the collapsedFraction // calculation. if (state.collapsedFraction > 0.01f && state.collapsedFraction < 1f) { result += flingToolbar( state = state, initialVelocity = available.y, flingAnimationSpec = flingAnimationSpec ) } return result } /* ... */ } } private suspend fun flingToolbar( state: CustomToolbarScrollState, initialVelocity: Float, flingAnimationSpec: DecayAnimationSpec<Float>?, ): Velocity { var remainingVelocity = initialVelocity // In case there is an initial velocity that was left after a previous user fling, animate to // continue the motion to expand or collapse the app bar. if (flingAnimationSpec != null && abs(initialVelocity) > 1f) { var lastValue = 0f AnimationState( initialValue = 0f, initialVelocity = initialVelocity, ) .animateDecay(flingAnimationSpec) { val delta = value - lastValue val initialHeightOffset = state.heightOffset state.heightOffset = initialHeightOffset + delta val consumed = abs(initialHeightOffset - state.heightOffset) lastValue = value remainingVelocity = this.velocity // avoid rounding errors and stop if anything is unconsumed if (abs(delta - consumed) > 0.5f) { cancelAnimation() } } } return Velocity(0f, remainingVelocity) }
Мы получили два ключевых компонента логики скроллла компонента: CustomToolbarScrollState и реализацию интерфейса NestedScrollConnection. Для удобства мы сложили их в один класс CustomToolbarScrollBehavior, который будет частью публичного API компонента.
Итоговый компонент
Посмотрим на использование итогового компонента тулбара. В его API вошли:
Слот для иконки навигации
Слот для действий в правой верхней части тулбара
Слот для дополнительного контента внизу тулбара
Слот для дополнительного контента по центру тулбара
Объект для кастомизации схлопывающегося заголовка
Scroll behavior для интеграции с системой скролла
Пример использования получившегося компонента
val scrollBehavior = rememberToolbarScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) topBar = { CustomToolbar( scrollBehavior = scrollBehavior, collapsingText = CollapsingTitle( text = "Toolbar tile", expandedTextStyle = MaterialTheme.typography.headlineLarge ), actions = { ToolbarActionIcon( painter = painterResource(id = R.drawable.ic_settings), onClick = { } ) }, navigationIcon = { ToolbarBackIcon() }, additionalContent = { /* optional content at toolbar bottom */ }, centralContent = { /* opotional content at toolbar center */ }, collapsedElevation = 4.dp, ) } ) { LazyColumn { /* some screen content */ } }
Это уже вторая версия компонента тулбара на Compose, которую мы внедрили на все наши Compose-экраны в проде. Помимо нашего основного приложения, пощупать и попереключать все возможные состояния описанной реализации тулбара можно в нашем демо-проекте. Там же вы найдете полный исходный код компонента, который мы разобрали в данной статье.
Делитесь в комментариях вашим опытом разработки кастомных компонентов на Compose, всем happy composing!
