Всем привет! Я Женя Мельцайкин, андроид-разработчик в Контуре.

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

Перед тем как перейти к основной части статьи предлагаю взглянуть на скринкаст и ответить: какая кнопка работает лучше?

Если вы не замечаете тут разницы, это очень хорошо. Значит, Compose уже всё сделал за нас, и на этом можно было бы заканчивать эту статью, но не зря у нас две кнопки — значит, в их реализации всё же есть разница.

Теория

Перед тем, как перейти к реализации, вспомним фазы Compose и преимущества Layout.

Фазы Compose

Compose отрисовывает кадр за три фазы:

  1. Composition — Compose запускает composable-функции и строит дерево этих функций.

  2. Layout — элементы внутри composable-функции измеряют и располагают себя и дочерние элементы на экране.

  3. Drawing — элементы отрисовывают себя на Canvas.

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

Layout

У многих ещё остались флешбеки с Custom View на Android View, но кастомные Layout в Compose делаются намного проще. Использование кастомных Layout позволяет избежать случайного изменения размеров элементов в Compose. Такая проблема может возникнуть, если размер Compose элемента не является фиксированным и изменяется после отрисовки. Непредвиденное изменение размера или расположения может произойти из-за модификаторов onGloballyPositioned(), onSizeChanged() и аналогичных. Это может привести к излишнему повторному рендерингу. Если элементам нужно знать о расположении и размерах других элементов, это часто означает, что либо используется неподходящий макет, либо требуется создать кастомный.

Больше теории по Compose можно почитать в этих статьях: Осознанная оптимизация Compose: часть 1, часть 2; Профилирование и оптимизация Jetpack Compose

Техническая часть

Данную задачу можно решить множеством способов, но в данной статья я рассмотрю один из них, который остался финальным в нашем проекте.

Первый шаг — разобраться из каких компонентов состоит кнопка.

  1. Задний фон

  2. Прогресс свайпа

  3. Слайд-якорь

  4. Контент посередине

  5. Контент справа

Основная сложность в том, что нам предстоит за один проход compose функции вычислить все отступы и размеры этих компонентов и разместить их. Как раз для этой задачи нам поможет Layout.

Второй шаг — поместить все compose-компоненты, которые будут участвовать в отрисовке в параметр content у Layout.

Layout(
    content = {
        // Помещаем слайд-якорь
        Box(
            modifier = Modifier
                .layoutId(SwipeableButtonLayout.ThumbLayout)
                .clip(shape)
                .background(colors.thumbBackgroundColor(), shape)
                .anchoredDraggable(
                    state = state.anchoredDraggableState,
                    orientation = Orientation.Horizontal,
                    enabled = enabled,
                    startDragImmediately = false
                ),
            contentAlignment = Alignment.Center
        ) {
            thumbContent(progressState, targetAnchorState)
        }
        // Помещаем элемент, отвечающий за прогресс свайпа
        Box(
            modifier = Modifier
                .layoutId(SwipeableButtonLayout.ProcessLayout)
                .drawWithCache {
                    onDrawBehind {
                        drawRect(
                            color = colors.progressColor(),
                            size = Size(width = this.size.width, height = this.size.height),
                        )
                    }
                }
        )
        // Помещаем контент справа
        Box(
            modifier = Modifier.layoutId(SwipeableButtonLayout.EndLayout)
        ) {
            endContent(progressState)
        }
        // Помещаем контент в центре
        Box(
            modifier = Modifier.layoutId(SwipeableButtonLayout.CenterLayout)
        ) {
            centerContent(progressState, currentAnchorState)
        }
    },
...
)

Третий шаг — рассчитать и расположить наши compose-компоненты.

  1. Измеряем слайд-якорь.

private fun swipeableMeasure(
    size: SwipeableButtonSize,
    draggableOffsetProvider: () -> Int,
    maxAnchorProvider: () -> Int,
    endOfTrackState: MutableIntState,
    progressState: MutableFloatState,
): MeasureScope.(measurables: List<Measurable>, constraints: Constraints) -> MeasureResult {
    return { measurables, constraints ->
        // Измеряем слайд-якорь
        val thumbPlaceable = measurables.first { it.layoutId == SwipeableButtonLayout.ThumbLayout }.measure(
            constraints.copy(
                minHeight = constraints.minHeight.coerceAtLeast(size.minHeight.roundToPx()),
                minWidth = constraints.minWidth.coerceAtLeast(size.minWidth.roundToPx())
            )
        )
        ...

	  }
}
Untitled
  1. Рассчитаем длину свайпа.

private fun swipeableMeasure(
    size: SwipeableButtonSize,
    draggableOffsetProvider: () -> Int,
    maxAnchorProvider: () -> Int,
    endOfTrackState: MutableIntState,
    progressState: MutableFloatState,
): MeasureScope.(measurables: List<Measurable>, constraints: Constraints) -> MeasureResult {
    return { measurables, constraints ->
        // Измеряем слайд-якорь
        val thumbPlaceable = measurables.first { it.layoutId == SwipeableButtonLayout.ThumbLayout }.measure(
            constraints.copy(
                minHeight = constraints.minHeight.coerceAtLeast(size.minHeight.roundToPx()),
                minWidth = constraints.minWidth.coerceAtLeast(size.minWidth.roundToPx())
            )
        )
        // Высота кнопки
        val height = thumbPlaceable.height
        // Ширина якоря
        val thumbWidth = thumbPlaceable.width
        // Рассчитываем длину свайпа
        val endOfTrackWidth = constraints.maxWidth - thumbWidth
        ...

	  }
}
  1. Рассчитываем текущий отступ по икс и измеряем элемент прогресса.

private fun swipeableMeasure(
    size: SwipeableButtonSize,
    draggableOffsetProvider: () -> Int,
    maxAnchorProvider: () -> Int,
    endOfTrackState: MutableIntState,
    progressState: MutableFloatState,
): MeasureScope.(measurables: List<Measurable>, constraints: Constraints) -> MeasureResult {
    return { measurables, constraints ->
        ...
        // Высота кнопки
        val height = thumbPlaceable.height
        // Ширина якоря
        val thumbWidth = thumbPlaceable.width
        // Рассчитываем длину свайпа
        val endOfTrackWidth = constraints.maxWidth - thumbWidth
        // Рассчитываем отступ по икс для якоря
        val thumbX = draggableOffsetProvider().coerceAtLeast(0).coerceAtMost(endOfTrackWidth)
		// Рассчитываем ширину прогресса
		val progressWidth = thumbX + thumbWidth / 2
        // Измеряем элемент прогресса
        val progressPlaceable = measurables.first { it.layoutId == SwipeableButtonLayout.ProcessLayout }.measure(
            constraints.copy(
                minWidth = progressWidth,
                minHeight = height
            )
        )
        ...
	  }
}
  1. Измеряем контент справа и контент по центру.

private fun swipeableMeasure(
    size: SwipeableButtonSize,
    draggableOffsetProvider: () -> Int,
    maxAnchorProvider: () -> Int,
    endOfTrackState: MutableIntState,
    progressState: MutableFloatState,
): MeasureScope.(measurables: List<Measurable>, constraints: Constraints) -> MeasureResult {
    return { measurables, constraints ->
        ...     
        // Рассчитываем отступ по икс для якоря
        val thumbX = draggableOffsetProvider().coerceAtLeast(0).coerceAtMost(endOfTrackWidth)
		// Рассчитываем ширину прогресса
		val progressWidth = thumbX + thumbWidth / 2
        // Измеряем элемент прогресса
        val progressPlaceable = measurables.first { it.layoutId == SwipeableButtonLayout.ProcessLayout }.measure(
            constraints.copy(
                minWidth = progressWidth,
                minHeight = height
            )
        )
        // Измеряем правый элемент
        val endContentPlaceable = measurables.first { it.layoutId == SwipeableButtonLayout.EndLayout }.measure(constraints)
        // Измеряем центральный элемент
        val centerContentPlaceable = measurables.first { it.layoutId == SwipeableButtonLayout.CenterLayout }.measure(constraints)
        ...
	  }
}
  1. Размещаем все наши элементы.

private fun swipeableMeasure(
    size: SwipeableButtonSize,
    draggableOffsetProvider: () -> Int,
    maxAnchorProvider: () -> Int,
    endOfTrackState: MutableIntState,
    progressState: MutableFloatState,
): MeasureScope.(measurables: List<Measurable>, constraints: Constraints) -> MeasureResult {
    return { measurables, constraints ->
        ...     
        // Высота кнопки
        val height = thumbPlaceable.height
        // Рассчитываем отступ по икс для якоря
        val thumbX = draggableOffsetProvider().coerceAtLeast(0).coerceAtMost(endOfTrackWidth)
		// Рассчитываем ширину прогресса
        val progressWidth = thumbX + thumbWidth / 2
        // Измеряем элемент прогресса
        val progressPlaceable = measurables.first { it.layoutId == SwipeableButtonLayout.ProcessLayout }.measure(
            constraints.copy(
                minWidth = progressWidth,
                minHeight = height
            )
        )
        // Измеряем правый элемент
        val endContentPlaceable = measurables.first { it.layoutId == SwipeableButtonLayout.EndLayout }.measure(constraints)
        // Измеряем центральный элемент
        val centerContentPlaceable = measurables.first { it.layoutId == SwipeableButtonLayout.CenterLayout }.measure(constraints)
        layout(constraints.maxWidth, height) {
            // Размещаем элемент прогресса
            progressPlaceable.placeRelative(x = 0, y = 0)
            // Размещаем центральный элемент
            centerContentPlaceable.placeRelative(
                x = constraints.maxWidth / 2 - centerContentPlaceable.width / 2,
                y = height / 2 - centerContentPlaceable.height / 2
            )
            // Размещаем элемент-якорь
            thumbPlaceable.placeRelative(
                x = thumbX,
                y = height / 2 - thumbPlaceable.height / 2
            )
            // Размещаем правый элемент
            endContentPlaceable.placeRelative(
                x = constraints.maxWidth - endContentPlaceable.width,
                y = height / 2 - endContentPlaceable.height / 2
            )
        }
	  }
}

На этом наша кнопка готова!

Чтобы понять, что кнопка оптимальна по количеству рекомпозиций, следует воспользоваться несколькими инструментами:

  1. Layout inspector внутри Android studio

  2. Плагин от ВКонтакте vkcompose

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

По данному видео видно, что верхняя кнопка перерисовывается только один раз за всё действие свайпа, когда она меняет исходное состояние на состояние загрузки. При этом при слайде не происходит рекомпозиции. Нижняя кнопка перерисовывается в процессе свайпа, что является неоптимальным решением.

Примеры оптимальной и неоптимальной кнопки можно найти тут. Пишите в комментарии какие ошибки были допущены в неоптимальной реализации и как можно ещё улучшить оптимальное решение, буду рад почитать.

Не бойтесь использовать кастомные Layout и пишите оптимизированные compose-компоненты. Всем спасибо за прочтение!

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


  1. tipapro
    14.05.2024 06:19
    +3

    Если идти во все тяжкие и пытаться дожать оптимальную реализацию дальше, то:

    • Не очень понятна цель derivedState по всему коду, так как нету операций сокращения итогового множества значений. Это приводит к лишней нагрузке

    • Не стоит использовать State.value, как ключ к LaunchedEffect. Для треккинга можно snapshotFlow внутри LaunchedEffect слушать. Инвалидировать эффект по ключу на сам стейт (а не на . value). Сейчас может лишний раз рекомпозироваться элемент, даже если это ему не нужно в идеале. В конкретно этом случае не критично

    • В кастомных лейаутах стоит сперва проверять на ограниченность maxHeight/width через метод hasBounded*, если не хотите в будущем ловить непонятные краши в трудновоспроизводимых кейсах, когда туда придёт бесконечность

    • DrawWithCache не нужен по сути в том коде. Его для других кейсов используют. В целом можно заменить пустой Box с drawWithCache на Canvas


  1. Slparma
    14.05.2024 06:19
    +2

    Это конечно ужас, что такая простая вещь так сложно делается, но это гугл, он в своём репертуара, сделать кучу глупых и сложных фреемворкрв, а потом думать почему андройд такой кривой и из костылей сделан. Я просто вспоминаю как под винлу рисовал кнопки, слайды с анимацией, градиентом и тп, и всё это на winforms, на winforms, Карл! И даже там это выглядело лучше и писалась проще на канвасе, почему мобильная разработка требует таких патуг для простого слайда не понятно


    1. loltrol
      14.05.2024 06:19

      На канвасе проще потому что тогда и требования были другие. Сейчас миллион девайсов, сто разных dpi, всем подавай flex responsive mega puper ui. Если вы сейчас попробуете учесть все нюансы современного ui на winforms, то у вас выйдет compose или Maui со всеми сложностями.


    1. grishkaa
      14.05.2024 06:19

      Так и на андроиде можно сделать то же самое просто, в лоб и на канвасе. Просто автор статьи не ищет лёгких путей.