Реализация нашей дизайн-системы на Jetpack Compose не всегда проходила гладко. Большинство компонентов мы переписали без проблем, но с некоторыми пришлось повозиться. Одним из таких компонентов стал аналог старого доброго CollapsingToolbarLayout
из View-мира. В статье разберем тонкости его реализации на Compose: погрузимся в тонкости кастомного лейаутинга и системы вложенного скролла Compose, а также посмотрим в исходники библиотеки androidx.compose.material3, вдохновившей нас на итоговое решение.
Материал может быть полезен тем, кто собирается делать сложные кастомные компоненты на Compose, и всем, кто интересуется внутренними деталями работы Compose-компонентов.
![](https://habrastorage.org/getpro/habr/upload_files/c90/d3d/b9c/c90d3db9cc15887dbe4b28efcacb4244.jpeg)
Состояния компонента
Компонент дизайн-системы, который нам нужно было реализовать на Compose, имеет достаточно много состояний. Вот так выглядит самое простое:
![](https://habrastorage.org/getpro/habr/upload_files/7a2/84d/6cc/7a284d6cc7e0d144e65ee4f9d6e39d18.png)
При этом мы хотим, чтобы заголовок схлопывался при скролле контента экрана:
![](https://habrastorage.org/getpro/habr/upload_files/d96/19d/2c6/d9619d2c6dd6289448472c209251366f.gif)
Опционально мы можем менять стиль заголовка, добавлять иконку навигации, кнопки дополнительных действий и произвольный контент внизу тулбара (например, табы ViewPager):
![](https://habrastorage.org/getpro/habr/upload_files/e4d/a5d/2a6/e4da5d2a6e8441c6566defd85b57f62d.gif)
Также мы должны уметь добавлять произвольный контент в центре тулбара, отключая при этом схлопывание заголовка при скролле:
![](https://habrastorage.org/getpro/habr/upload_files/310/7e9/801/3107e9801311e48e23753e6e5d465c5c.gif)
На некоторых экранах заголовок может иметь несколько строк. В таких случаях мы должны плавно переходить между заголовком из двух строк к заголовку из одной строки с многоточием:
![](https://habrastorage.org/getpro/habr/upload_files/082/e7b/194/082e7b194d7e26b7d145ebb253679985.gif)
И, конечно же, в каждом из состояний при проскролле контента экрана нам нужно анимированно переключить тень в нижней части тулбара.
Ищем инструмент
Такой компонент было бы легко реализовать с помощью стандартных 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:
![](https://habrastorage.org/getpro/habr/upload_files/75f/f6d/e1e/75ff6de1e6dee0acf04d0604036354f8.gif)
На момент нашего ресерча мы рассматривали и другой вариант: библиотека 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
. Так мы избежим лишнего проскролливания контента под тулбаром. Если мы этого не сделаем, то получим вот такое некорректное поведение:
![](https://habrastorage.org/getpro/habr/upload_files/751/656/810/7516568104897eb049596a8b61f74786.gif)
В 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-жеста
![](https://habrastorage.org/getpro/habr/upload_files/699/4a0/01e/6994a001eb9ee867dd1990fdcdb2cb1e.gif)
Скорость должна постепенно уменьшаться до полной остановки. Для этого метод 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!