Делаем кастомный Collapsing Toolbar на Jetpack Compose

от автора

Реализация нашей дизайн-системы на 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()             }         }     },     // ... )

Полный код на GitHub

При этом для заголовка мы использовали два компонента 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             )         }         // ...     },     // ... )

Полный код на GitHub

Далее переходим к измерению дочерних компонентов. Для этого передадим в 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()               )           )        // ...    }

Полный код на GitHub

Здесь мы также используем объект 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)          // ...    }

Полный код на GitHub

В вычислениях высоты мы использовали некий 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()             )         }    }

Полный код на GitHub

На этом этапе мы также реализовали переход от многострочного заголовка к однострочному при помощи параметра 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)  } 

Полный код на GitHub

Посмотрим, как этот стейт используется для реализации 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             }         }          /* ... */      }  } 

Полный код на GitHub

Здесь мы не будем реагировать на скролл контента вниз, позволяя контенту под тулбаром сначала обработать доступный ему скролл. В этом случае в качестве 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         }           /* ... */              }  } 

Полный код на GitHub

Поскольку мы аккумулируем в 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) }  

Полный код на GitHub

Мы получили два ключевых компонента логики скроллла компонента: 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 */    } }

Демо-конструктор состояний компонента на GitHub

Это уже вторая версия компонента тулбара на Compose, которую мы внедрили на все наши Compose-экраны в проде. Помимо нашего основного приложения, пощупать и попереключать все возможные состояния описанной реализации тулбара можно в нашем демо-проекте. Там же вы найдете полный исходный код компонента, который мы разобрали в данной статье.

Делитесь в комментариях вашим опытом разработки кастомных компонентов на Compose, всем happy composing!


ссылка на оригинал статьи https://habr.com/ru/articles/703192/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *