Реализация нашей дизайн-системы на 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!

Комментарии (6)


  1. MaxZverev
    08.12.2022 10:34

    Красива :)


  1. quaer
    08.12.2022 15:47
    -1

    А для собственно содержимого старницы место-то остаётся на экране, например, в ландшафтном режиме? Или цель просто иметь Compose и что-то модное, динамичное, чтобы на экране всё двигалось и мелькало?

    Как увидеть весь текст длинной надписи в портретном режиме, когда не помещается полностью?


    1. horseunnamed Автор
      08.12.2022 16:33
      +2

      Привет! Кажется, вопрос больше про визуальный дизайн UI, а не про технологию его реализации (Compose/View).

      для собственно содержимого старницы место-то остаётся на экране, например, в ландшафтном режиме

      Как увидеть весь текст длинной надписи в портретном режиме, когда не помещается полностью?

      Переход от большого заголовка к маленькому при скролле как раз создает компромисс между видимостью контента экрана и заголовка. При открытии экрана пользователь сначала видит большой расхлопнутый заголовок, где текст не обрезается и всегда помещается полностью: тулбар в таком состоянии у нас растягивается по высоте всего текста заголовка (в ландшафтном тоже все видно будет). Когда пользователь начинает скроллить, заголовок схлопывается в компактный вариант, уступая максимальное место контенту, на котором пользователь может сфокусироваться без отвлечения на верхнюю часть экрана.


      1. quaer
        08.12.2022 17:25

        А если нет прокручиваемого списка и верстка статична, как бы вы решили проблему показа текста?


        1. horseunnamed Автор
          08.12.2022 20:15

          В таком случае вёрстку контента можно обернуть в контейнер с поддержкой скролла (на случай, когда контент полностью не помещается на экране) + добавить в тулбар возможность обработать жест скролла. То есть чтобы можно было за тулбар потянуть и схлопнуть заголовок.


  1. Rusrst
    08.12.2022 17:44

    Спасибо! Пишите ещё и выпускайте ролики! :)