Как мы реализовали кнопку со свайпом на Jetpack Compose

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

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

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

320fe463f368691483d1dfcfbd8cbc36.gif

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

Теория

Перед тем, как перейти к реализации,  вспомним фазы 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

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

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

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

df93d053139e1cef8139676a5251288c.png

  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, 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, 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
        ...

	  }
}

efacb5d022c6232c32de6eed3b8c8743.png

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

private fun swipeableMeasure(
    size: SwipeableButtonSize,
    draggableOffsetProvider: () -> Int,
    maxAnchorProvider: () -> Int,
    endOfTrackState: MutableIntState,
    progressState: MutableFloatState,
): MeasureScope.(measurables: List, 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
            )
        )
        ...
	  }
}

38a7318132c7366c923734887fa34434.png

  1. Измеряем контент справа и контент по центру.

private fun swipeableMeasure(
    size: SwipeableButtonSize,
    draggableOffsetProvider: () -> Int,
    maxAnchorProvider: () -> Int,
    endOfTrackState: MutableIntState,
    progressState: MutableFloatState,
): MeasureScope.(measurables: List, 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)
        ...
	  }
}

90eb4b3e1ff8e553aee7a54c074975d7.png

  1. Размещаем все наши элементы.

private fun swipeableMeasure(
    size: SwipeableButtonSize,
    draggableOffsetProvider: () -> Int,
    maxAnchorProvider: () -> Int,
    endOfTrackState: MutableIntState,
    progressState: MutableFloatState,
): MeasureScope.(measurables: List, 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

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

f50ace25bb13f06a70ee2142165a5575.gif

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

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

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

© Habrahabr.ru