Создание анимированной кнопки-счетчика в Jetpack Compose

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

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

Создание базового макета

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

Понадобится также корневой макет для хранения этих двух компонентов. Поскольку кнопка сброса скрыта под перетаскиваемым ползунком, а ползунок можно перетаскивать по вертикали за пределы кнопки, мы будем использовать компонент Box, позволяющий реализовать перекрывающиеся элементы.

Первоначальная composable корневого макета:

@Composable
private fun CounterButton(
value: String,
modifier: Modifier = Modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.width(200.dp)
.height(80.dp)
) {

ButtonContainer(
onValueDecreaseClick = { /*TODO*/ },
onValueIncreaseClick = { /*TODO*/ },
onValueClearClick = { /*TODO*/ },
modifier = Modifier
)

DraggableThumbButton(
value = value,
onClick = { /*TODO*/ },
modifier = Modifier.align(Alignment.Center)
)
}
}

Теперь рассмотрим composable ButtonContainer, в которой размещаются кнопки-иконки. Будем использовать компонент Row, поскольку три кнопки должны располагаться горизонтально. Arrangement.SpaceBetween поможет горизонтально расположить кнопки в начале, центре и конце макета. Кнопки представлены как composable IconControlButton, которая является просто оберткой IconButton.

Примечание: чтобы применить такие же иконки, добавьте в проект зависимость androidx.compose.material:material-icons-extended или иконки вручную.

Мы будем использовать модификатор clip(RoundedCornerShape()) для получения необходимой формы фона, а также зададим цвет фона. Изменим альфа-канал цветового насыщения фона, поскольку позже понадобится анимировать его при перетаскивании ползунка. То же самое касается насыщенности цвета кнопок. Кнопку сброса пока скроем, так как будем работать над ее логикой потом.

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

Composable контейнера кнопки:

private const val ICON_BUTTON_ALPHA_INITIAL = 0.3f
private const val CONTAINER_BACKGROUND_ALPHA_INITIAL = 0.6f

@Composable
private fun ButtonContainer(
onValueDecreaseClick: () -> Unit,
onValueIncreaseClick: () -> Unit,
onValueClearClick: () -> Unit,
modifier: Modifier = Modifier,
clearButtonVisible: Boolean = false,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(64.dp))
.background(Color.Black.copy(alpha = CONTAINER_BACKGROUND_ALPHA_INITIAL))
.padding(horizontal = 8.dp)
) {
// кнопка уменьшения
IconControlButton(
icon = Icons.Outlined.Remove,
contentDescription = "Decrease count",
onClick = onValueDecreaseClick,
tintColor = Color.White.copy(alpha = ICON_BUTTON_ALPHA_INITIAL)
)

// кнопка сброса
if (clearButtonVisible) {
IconControlButton(
icon = Icons.Outlined.Clear,
contentDescription = "Clear count",
onClick = onValueClearClick,
tintColor = Color.White.copy(alpha = ICON_BUTTON_ALPHA_INITIAL)
)
}

// кнопка увеличения
IconControlButton(
icon = Icons.Outlined.Add,
contentDescription = "Increase count",
onClick = onValueIncreaseClick,
tintColor = Color.White.copy(alpha = ICON_BUTTON_ALPHA_INITIAL)
)
}
}

@Composable
private fun IconControlButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tintColor: Color = Color.White,
) {
IconButton(
onClick = onClick,
modifier = modifier
.size(48.dp)
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
tint = tintColor,
modifier = Modifier.size(32.dp)
)
}
}

Для реализации кнопки ползунка используем composable Text, обернутую в Box, что позволит применить обрезку CircleShape и тень для создания эффекта круглой кнопки. Будем также использовать модификатор .clickable {} для поддержки кликов.

Первоначальная composable перетаскиваемого ползунка:

@Composable
private fun DraggableThumbButton(
value: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.shadow(8.dp, shape = CircleShape)
.size(64.dp)
.clip(CircleShape)
.clickable { onClick() }
.background(Color.Gray)
) {
Text(
text = value,
color = Color.White,
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
)
}
}

Наконец, воспользуемся непосредственно composable CounterButton.

Column(
modifier = Modifier.wrapContentSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CounterButton(value = "0")
}

После этого можно запустить код и получить следующий результат.

Результат первоначальных composable

Это отправная точка. Теперь займемся логикой счетчика и поддержкой перетаскивания.

Добавление логики счетчика

В исходных макетах мы оставили несколько TODO под слушатели кликов. Теперь добавим логику для увеличения и уменьшения значения счетчика. Чтобы сохранить состояние значения вне composable кнопки, вынесем состояние и добавим слушатели кликов в качестве аргументов composable кнопки CounterButton.

Composable кнопки-счетчика со слушателями кликов:

@Composable
private fun CounterButton(
value: String,
onValueDecreaseClick: () -> Unit,
onValueIncreaseClick: () -> Unit,
onValueClearClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.width(200.dp)
.height(80.dp)
) {

ButtonContainer(
onValueDecreaseClick = onValueDecreaseClick,
onValueIncreaseClick = onValueIncreaseClick,
onValueClearClick = onValueClearClick,
modifier = Modifier
)

DraggableThumbButton(
value = value,
onClick = onValueIncreaseClick,
modifier = Modifier.align(Alignment.Center)
)
}
}

Теперь необходимо определить изменяемое состояние счетчика, которое будем обновлять при каждом событии нажатия кнопки. Поместим его на верхнем уровне, как это обычно бывает в ViewModel.

Composable кнопки-счетчика с состоянием:

var valueCounter by remember {
mutableStateOf(0)
}

CounterButton(
value = valueCounter.toString(),
onValueIncreaseClick = {
valueCounter += 1
},
onValueDecreaseClick = {
valueCounter = maxOf(valueCounter - 1, 0)
},
onValueClearClick = {
valueCounter = 0
}
)

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

Функционирующая кнопка-счетчик

Поддержка горизонтального перетаскивания

Итак, рабочая кнопка-счетчик создана. Теперь добавим основную функциональность  —  перетаскивание ползунка для увеличения или уменьшения значения.

Сначала определим две новые переменные внутри composable DraggableThumbButton. Первая  —  thumbOffsetX: Animatable  —  поможет позиционировать и анимировать кнопку ползунка при перетаскивании. Вторая  —  область корутины  —  необходима для обновления thumbOffsetX и запуска анимации.

Две новые переменные внутри composable DraggableThumbButton:

val thumbOffsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()

Теперь добавим в Box ползунка модификатор .offset, который будет определять смещение composable по отношению к исходному положению. Примем значение thumbOffsetX для оси x, оставив пока ось y равной 0.

Модификатор offset в composable DraggableThumbButton.Box:

Box(
contentAlignment = Alignment.Center,
modifier = modifier
// изменяем позицию x composable
.offset {
IntOffset(
thumbOffsetX.value.toInt(),
0
)
}
...
...

Далее нужно определить жест перетаскивания. Один из способов сделать это  —  использовать модификатор .pointerInput, чтобы получить PointerInputScope, из которого можно вызвать функции forEachGesture и awaitPointEventScope. Это позволит обрабатывать каждое событие касания, когда оно происходит, а для ожидания изначального события можно использовать awaitFirstDown(). Затем применяется цикл do-while для обработки событий, пока пользователь удерживает кнопку. Таким образом, получим значение x события, которое можно применить к ползунку в качестве смещения. Применим функцию .snapTo(value), которая устанавливает целевое значение без какой-либо анимации.

Модификатор pointerInput в DraggableThumbButton.Box:

// в качестве последнего модификатора DraggableThumbButton.Box
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()

do {
val event = awaitPointerEvent()
event.changes.forEach { pointerInputChange ->
scope.launch {
val targetValue =
thumbOffsetX.value + pointerInputChange.positionChange().x
thumbOffsetX.snapTo(targetValue)
}
}
} while (event.changes.any { it.pressed })
}
}
}

Теперь можно перетаскивать ползунок влево и вправо. Однако, как видите, здесь еще есть над чем поработать. Ползунок можно перетащить за пределы кнопки, но он не возвращается в исходное положение, а прикосновение к нему увеличивает значение.

Первоначальная версия горизонтального перетаскивания

Добавление ограничений на перетаскивание

Добавим некоторые ограничения на расстояние, на которое можно перетаскивать ползунок. Прежде всего, определим максимально допустимое значение перетаскивания, выраженное в пикселях. Для упрощения примера захардкодим лимит. В идеале мы должны были получить ширину composable ButtonContainer и произвести динамическое вычисление. Пока же просто определим статическое значение в dp и преобразуем его в пиксели с помощью функции Density.toPx(), для чего нужно получить объект LocalDensity.current из CompositionLocalProvider.

Определение ограничений горизонтального перетаскивания с преобразованием dp в px:

// определяем в верхней части composable DraggableThumbButton
val dragLimitHorizontalPx = 60.dp.dpToPx()

// определяем внизу файла
@Composable
private fun Dp.dpToPx() = with(LocalDensity.current) { [email protected]() }

Следующим ключевым моментом является ограничение минимального и максимального значения targetValue так, чтобы оно находилось в диапазоне [-dragLimitHorizontalPx, dragLimitHorizontalPx]. Используем функцию coerceIn(minimumValue: Float, maximumValue: Float) из стандартной библиотеки Kotlin, которая проверяет, находится ли значение в заданном диапазоне.

Добавление ограничений для горизонтального перетаскивания:

// обновляем вычисление цели внутри модификатора pointerInput
val targetValue =
thumbOffsetX.value + pointerInputChange.positionChange().x

val targetValueWithinBounds = targetValue.coerceIn(
-dragLimitHorizontalPx,
dragLimitHorizontalPx
)

thumbOffsetX.snapTo(targetValueWithinBounds)

Благодаря этим простым изменениям, получим следующий результат:

Горизонтальное перетаскивание с ограничениями

Увеличение и уменьшение значения в результате перетаскивания

Сейчас пользователь может перетащить ползунок к границе кнопки, но ничего не произойдет. Обнаружив это событие, увеличим или уменьшим значение, как если бы пользователь нажимал кнопки уменьшения и увеличения.

Сначала обновим DraggableThumbButton, добавив два новых аргумента: лямбду для уменьшения значения и лямбду для увеличения значения.

Добавление лямбда-выражений для увеличения и уменьшения значения счетчика в DraggableThumbButton:

// добавляем в сигнатуру функции два новых аргумента
@Composable
private fun DraggableThumbButton(
value: String,
onClick: () -> Unit,
onValueDecreaseClick: () -> Unit,
onValueIncreaseClick: () -> Unit,
modifier: Modifier = Modifier
)

// дополняем composable CounterButton
DraggableThumbButton(
value = value,
onClick = onValueIncreaseClick,
onValueDecreaseClick = onValueDecreaseClick,
onValueIncreaseClick = onValueIncreaseClick,
modifier = Modifier.align(Alignment.Center)
)

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

Как узнать, когда пользователь отпустил ползунок и больше не перетаскивает его? Условие цикла do-while больше не будет истинным, и можно добавить любую логику после его завершения.

Добавление обнаружения ограничения горизонтального перетаскивания:

...
} while (event.changes.any { it.pressed })

// обнаружение перетаскивания до предела
if (thumbOffsetX.value.absoluteValue >= dragLimitHorizontalPx) {
if (thumbOffsetX.value.sign > 0) {
onValueIncreaseClick()
} else {
onValueDecreaseClick()
}
}

После того как пользователь отпустит ползунок, проверяем, насколько близко абсолютное значение ползунка к максимальному ограничению перетаскивания. Затем проверяем направление перетаскивания, чтобы вызвать нужную функцию.

Увеличение и уменьшение значения с помощью перетаскивания ползунка

Счетчик работает, но ползунок остается там, где остановлено перетаскивание. Исправим это.

Добавление пружинящей анимации

Нам нужно, чтобы ползунок возвращался в центр, когда пользователь прекращает его перетаскивать. В предыдущем разделе мы выяснили, что прерывание цикла do-while означает, что пользователь отпустил ползунок. Следовательно, нужно добиться возврата thumbOffsetX.value до 0, как только это произойдет.

Можно сделать это, запустив новую корутину после определения ограничения, чтобы обновить объект thumbOffsetX с помощью функции animateTo(). Она принимает целевое значение и спецификацию анимации. Использование пружинящей анимации spring позволит получить эффект отскока, как в оригинальном дизайне.

Использование пружинящей анимации для возврата ползунка в исходное положение:

// возвращаемся в исходное положение
scope.launch {
if (thumbOffsetX.value != 0f) {
thumbOffsetX.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = StiffnessLow
)
)
}
}
Анимированный возврат в исходное положение после перетаскивания

Вы можете попробовать изменить спецификации анимации, чтобы добиться еще более плавного эффекта.

Сдвиг всей кнопки

В оригинальном дизайне при перетаскивании ползунка вся кнопка перемещается в том же направлении. Чтобы добиться этого, нужно знать положение ползунка на более высоком уровне.

Поэтому перенесем определение thumbOffsetX из DraggableThumbButton в CounterButton. Затем можно будет передать значение thumbOffsetX в composable ButtonContainer и использовать его для определения сдвига кнопки.

Перенесение свойства thumbOffsetX:

@Composable
private fun CounterButton(
...
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.width(200.dp)
.height(80.dp)
) {
// перенесено из composable DraggableThumbButton
val thumbOffsetX = remember { Animatable(0f) }

ButtonContainer(
onValueDecreaseClick = onValueDecreaseClick,
onValueIncreaseClick = onValueIncreaseClick,
onValueClearClick = onValueClearClick,
modifier = Modifier
)

DraggableThumbButton(
value = value,
// передаем значение в качестве аргумента
thumbOffsetX = thumbOffsetX,
onClick = onValueIncreaseClick,
onValueDecreaseClick = onValueDecreaseClick,
onValueIncreaseClick = onValueIncreaseClick,
modifier = Modifier.align(Alignment.Center)
)
}
}

@Composable
private fun DraggableThumbButton(
value: String,
// новый аргумент
thumbOffsetX: Animatable<Float, AnimationVector1D>,
onClick: () -> Unit,
onValueDecreaseClick: () -> Unit,
onValueIncreaseClick: () -> Unit,
modifier: Modifier = Modifier
) {
...
}

Теперь, после перенесения thumbOffsetX в composable CounterButton, можно передать это значение в composable ButtonContainer и использовать его в модификаторе .offset {} для перемещения всего компонента Box кнопки. Умножим это смещение на коэффициент 0.1f, чтобы сдвинуть кнопку лишь на небольшую величину по сравнению с ползунком.

Смещение composable ButtonContainer целиком:

@Composable
private fun CounterButton(
...
) {
Box(
...
) {
// перенесено из composable DraggableThumbButton
val thumbOffsetX = remember { Animatable(0f) }

ButtonContainer(
// передаем значение в качестве аргумента
thumbOffsetX = thumbOffsetX.value,
onValueDecreaseClick = onValueDecreaseClick,
onValueIncreaseClick = onValueIncreaseClick,
onValueClearClick = onValueClearClick,
modifier = Modifier
)

DraggableThumbButton(
...
)
}
}

private const val CONTAINER_OFFSET_FACTOR = 0.1f

@Composable
private fun ButtonContainer(
// новый аргумент
thumbOffsetX: Float,
onValueDecreaseClick: () -> Unit,
onValueIncreaseClick: () -> Unit,
onValueClearClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
// добавляем новый модификатор смещения
.offset {
IntOffset(
(thumbOffsetX * CONTAINER_OFFSET_FACTOR).toInt(),
0
)
}
.fillMaxSize()
...
){
...
}
}

Наконец, изменим значение dragLimitHorizontalPx в DraggableThumbButton с 60.dp на 72.dp и перенесем его в отдельную константу. Это изменение необходимо, поскольку теперь перемещается вся кнопка, в результате чего ползунок больше не касается боковых сторон.

Изменение значения ограничения перетаскивания и создание константы:

private const val DRAG_LIMIT_HORIZONTAL_DP = 72

@Composable
private fun DraggableThumbButton(
...
) {
// меняем значение с 60 на 72 и переносим его в константу
val dragLimitHorizontalPx = DRAG_LIMIT_HORIZONTAL_DP.dp.dpToPx()
...
}

После внесения изменений получаем следующий результат:

Контейнер кнопки перемещается вслед за ползунком

Устранение нежелательных кликов

В composable исходного макета был применен модификатор .clickable к ползунку. Это позволило увеличить значение при нажатии на ползунок. Однако после добавления логики перетаскивания любое прикосновение к ползунку приводит к клику.

Нежелательные события кликов приводят к увеличению значения при незначительном перетаскивании

Исправить это можно добавлением проверки, разрешающей клики, только когда ползунок не перетаскивается и находится в исходном положении. Можно также определить минорное пороговое значение, которое следует считать статическим положением.

Исправление модификатора .clickable {} в DraggableThumbButton:

// определяем рядом с другими константами
private const val START_DRAG_THRESHOLD_DP = 2

// в начале composable DraggableThumbButton
val startDragThreshold = START_DRAG_THRESHOLD_DP.dp.dpToPx()

// обновляем модификатор clickable в composable DraggableThumbButton
.clickable {
// разрешаем клики только вне перетаскивания
if (thumbOffsetX.value.absoluteValue <= startDragThreshold) {
onClick()
}
}

После добавления этой проверки проблема устранена, и ползунок становится кликабельным, только когда находится в исходном положении:

Легкое перетаскивание ползунка больше не приводит к нежелательному увеличению значения

Добавление сопротивления перетаскиванию

Текущая логика перетаскивания работает, но она выглядит как-то простовато. Решить эту проблему можно, добавив немного сопротивления движению перетаскивания ползунка. Сейчас просто берем значение изменения положения и прибавляем его к текущему смещению ползунка, что приводит к линейному движению перетаскивания. Чтобы создать некоторое сопротивление, можно умножить изменение положения на определенный коэффициент, меньший 1. Таким образом, чем ближе будет ползунок к краю, тем больше усилий потребуется для его перетаскивания.

Обновление логики расчета смещения:

//  обновляем логику внутри DraggableThumbButton.Modifier.pointerInput
scope.launch {
// добавляем динамический коэффициент сопротивления для того, чтобы чем ползунок
// ближе был к границе, тем больше усилий требовалось для его перетаскивания
val dragFactor =
1 - (thumbOffsetX.value / dragLimitHorizontalPx).absoluteValue
val delta =
pointerInputChange.positionChange().x * dragFactor

val targetValue = thumbOffsetX.value + delta
val targetValueWithinBounds =
targetValue.coerceIn(
-dragLimitHorizontalPx,
dragLimitHorizontalPx
)

thumbOffsetX.snapTo(targetValueWithinBounds)
}

Поскольку теперь труднее достичь края кнопки, нужно обновить проверку, чтобы определить, достаточно ли перетаскивание ползунка для увеличения или уменьшения значения. Можно умножить dragLimitHorizontalPx на коэффициент, меньший 1, чтобы оставался небольшой зазор.

Добавление небольшого зазора в обнаружении триггеров:

// определяем в файле с остальными константами
private const val DRAG_LIMIT_HORIZONTAL_THRESHOLD_FACTOR = 0.9f

// обновляем проверку в модификаторе DraggableThumbButton.pointerInput
if (thumbOffsetX.value.absoluteValue >= (dragLimitHorizontalPx * DRAG_LIMIT_HORIZONTAL_THRESHOLD_FACTOR)) {
...
}

С обновленным расчетом смещения требуется больше усилий, чтобы перетащить ползунок к самому краю:

Перетаскивание ползунка к краю

Выделение иконок увеличения и уменьшения

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

Обновим composable ButtonContainer и определим новое значение для точки, в которой иконка должна стать полностью видимой. Можно использовать текущее смещение ползунка и новое значение для вычисления процента и использовать его в качестве альфа-канала цветового насыщения. Ограничим альфа-канал как минимум до 30%. Позже обновим логику кнопки сброса.

Обновление расчета яркости иконки:

// добавляем к остальным константам
private const val DRAG_HORIZONTAL_ICON_HIGHLIGHT_LIMIT_DP = 36

// добавляем в верхнюю часть composable ButtonContainer
// определяем, в какой момент иконка должна быть полностью видимой
val horizontalHighlightLimitPx = DRAG_HORIZONTAL_ICON_HIGHLIGHT_LIMIT_DP.dp.dpToPx()

// кнопка уменьшения
IconControlButton(
icon = Icons.Outlined.Remove,
contentDescription = "Decrease count",
onClick = onValueDecreaseClick,
// обновляем расчет альфа-канала
tintColor = Color.White.copy(
alpha = if (thumbOffsetX < 0) {
(thumbOffsetX.absoluteValue / horizontalHighlightLimitPx).coerceIn(
ICON_BUTTON_ALPHA_INITIAL,
1f
)
} else {
ICON_BUTTON_ALPHA_INITIAL
}
)
)

...

// кнопка увеличения
IconControlButton(
icon = Icons.Outlined.Add,
contentDescription = "Increase count",
onClick = onValueIncreaseClick,
tintColor = Color.White.copy(
alpha = if (thumbOffsetX > 0) {
(thumbOffsetX.absoluteValue / horizontalHighlightLimitPx).coerceIn(
ICON_BUTTON_ALPHA_INITIAL,
1f
)
} else {
ICON_BUTTON_ALPHA_INITIAL
}
)
)

С новым расчетом иконки становятся более заметными при перетаскивании ползунка ближе к краю:

Подсветка иконок по мере приближения ползунка

Выделение цвета фона кнопки

Как и в случае с иконками, можно использовать процесс перетаскивания для динамического обновления фона ButtonContainer, чтобы его цвет становился темнее по мере приближения ползунка к краю кнопки. Ограничим значение альфа-канала, по крайней мере, начальным значением, прибавив незначительный фактор прогресса.

Вычисление альфа-канала фонового цвета на основе дальности перетаскивания ползунка:

// обновляем модификатор ButtonContainer.background
.background(
Color.Black.copy(
alpha = CONTAINER_BACKGROUND_ALPHA_INITIAL +
((thumbOffsetX.absoluteValue / horizontalHighlightLimitPx) / 10f)
)
)

И вот результат этого небольшого изменения. Можете поэкспериментировать с коэффициентами, чтобы его изменить.

Перетаскивание ползунка ближе к краю кнопки приводит к затемнению фона

Добавление поддержки вертикального перетаскивания

Горизонтальное перетаскивание работает так, как надо. Теперь добавим поддержку вертикального перетаскивания, которое должно сбросить текущее значение счетчика.

Начнем с определения нового свойства thumbOffsetY: Animatable в composable CounterButton, которое будем использовать для управления смещением по оси y аналогично существующему свойству thumbOffsetX.

Определение нового свойства thumbOffsetY и обновление composable DraggableThumbButton:

// 1. обновляем composable CounterButton
val thumbOffsetY = remember { Animatable(0f) }

DraggableThumbButton(
value = value,
thumbOffsetX = thumbOffsetX,
thumbOffsetY = thumbOffsetY,
onClick = onValueIncreaseClick,
onValueDecreaseClick = onValueDecreaseClick,
onValueIncreaseClick = onValueIncreaseClick,
onValueReset = onValueClearClick,
modifier = Modifier.align(Alignment.Center)
)

// 2. обновляем composable DraggableThumbButton
@Composable
private fun DraggableThumbButton(
value: String,
thumbOffsetX: Animatable<Float, AnimationVector1D>,
thumbOffsetY: Animatable<Float, AnimationVector1D>,
onClick: () -> Unit,
onValueDecreaseClick: () -> Unit,
onValueIncreaseClick: () -> Unit,
onValueReset: () -> Unit,
modifier: Modifier = Modifier
) {
...
}

Обновим модификатор .offset, заставив его наблюдать за thumbOffsetY, чтобы положение ползунка обновлялось по вертикали. Нужно также обновить модификатор .clickable, чтобы избежать нежелательных кликов в процессе вертикального перетаскивания.

Добавление в модификаторы проверки смещения .offset и .clickable по оси y:

// изменяем положение composable по x и y
.offset {
IntOffset(
thumbOffsetX.value.toInt(),
thumbOffsetY.value.toInt(),
)
}

// обновляем модификатор clickable, чтобы он также регулировал вертикальное перетаскивание
.clickable {
// разрешаем клики только вне перетаскивания
if (thumbOffsetX.value.absoluteValue <= startDragThreshold &&
thumbOffsetY.value.absoluteValue <= startDragThreshold
) {
onClick()
}
}

В качестве следующего шага определим новый класс перечисления DragDirection и новое изменяемое свойство с именем dragDirection: DragDirection, которое будем использовать для отслеживания состояния направления перетаскивания. Это позволит разрешить только одномерное (по одной оси) перетаскивание, предотвращая одновременное перетаскивание по горизонтали и вертикали.

Определение нового класса перечисления DragDirection:

// в верхней части composable DraggableThumbButton
val dragDirection = remember {
mutableStateOf(DragDirection.NONE)
}

// в нижней части файла
private enum class DragDirection {
NONE, HORIZONTAL, VERTICAL
}

Определим также новую переменную dragLimitVerticalPx, которая будет контролировать, насколько далеко можно перетаскивать ползунок по вертикали.

Определение нового свойства в DraggableThumbButton:

// добавляем в файл с остальными константами
private const val DRAG_LIMIT_VERTICAL_DP = 64

// добавляем в DraggableThumbButton
val dragLimitVerticalPx = DRAG_LIMIT_VERTICAL_DP.dp.dpToPx()

Теперь нужно написать логику для обнаружения вертикального перетаскивания внутри существующего модификатора .pointerInput.

Первый шаг  —  определить, в каком направлении происходит перетаскивание: горизонтальном или вертикальном. Можно определить это, проверив, что изменилось: pointerInputChange.positionChange().x или pointerInputChange.positionChange().y. Но поскольку даже в случае вертикального перетаскивания положение x может незначительно измениться, необходимо также предусмотреть некий порог для этих значений, чтобы избежать определения неправильного направления.

Как только определим направление перетаскивания, можно обновить либо свойство thumbOffsetX, либо свойство thumbOffsetY для перемещения ползунка.

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

Определение оси перетаскивания:

.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()

// сбрасываем направление перетаскивания
dragDirection.value = DragDirection.NONE

do {
val event = awaitPointerEvent()
event.changes.forEach { pointerInputChange ->
scope.launch {
if ((dragDirection.value == DragDirection.NONE &&
pointerInputChange.positionChange().x.absoluteValue >= startDragThreshold) ||
dragDirection.value == DragDirection.HORIZONTAL
) {
// указываем горизонтальное направление перетаскивания, чтобы предотвратить вертикальное перетаскивание, пока ползунок не будет отпущен
dragDirection.value = DragDirection.HORIZONTAL

// рассчитываем коэффициент сопротивления, чтобы чем ползунок
// ближе был к границе, тем больше усилий требовалось для его перетаскивания
1 - (thumbOffsetX.value / dragLimitHorizontalPx).absoluteValue
val delta =
pointerInputChange.positionChange().x * dragFactor

val targetValue = thumbOffsetX.value + delta
val targetValueWithinBounds =
targetValue.coerceIn(
-dragLimitHorizontalPx,
dragLimitHorizontalPx
)

thumbOffsetX.snapTo(targetValueWithinBounds)
} else if (
(dragDirection.value != DragDirection.HORIZONTAL &&
pointerInputChange.positionChange().y >= startDragThreshold)
) {
// указываем вертикальное направление перетаскивания, чтобы предотвратить горизонтальное перетаскивание, пока ползунок не будет отпущен

val dragFactor =
1 - (thumbOffsetY.value / dragLimitVerticalPx).absoluteValue
val delta =
pointerInputChange.positionChange().y * dragFactor

val targetValue = thumbOffsetY.value + delta
val targetValueWithinBounds =
targetValue.coerceIn(
-dragLimitVerticalPx,
dragLimitVerticalPx
)

thumbOffsetY.snapTo(targetValueWithinBounds)
}
}
}
} while (event.changes.any { it.pressed })
}

...
}
}

Поскольку перетаскивание выполняется теперь в обоих направлениях, нужно добавить логику для определения вертикального перетаскивания до предела. Как только значение thumbOffsetY пересекает значение dragLimitVerticalPx (с некоторым зазором), запускаем обратный вызов onValueReset(), который сбрасывает счетчик.

Обнаружение вертикального перетаскивания до предела:

// определяем с остальными константами
private const val DRAG_LIMIT_VERTICAL_THRESHOLD_FACTOR = 0.9f

// добавляем определение направления в модификаторе DraggableThumbButton.pointerInput
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
...
}

// обнаруживаем перетаскивание до предела
if (thumbOffsetX.value.absoluteValue >= (dragLimitHorizontalPx * DRAG_LIMIT_HORIZONTAL_THRESHOLD_FACTOR)) {
if (thumbOffsetX.value.sign > 0) {
onValueIncreaseClick()
} else {
onValueDecreaseClick()
}
} else if (thumbOffsetY.value.absoluteValue >= (dragLimitVerticalPx * DRAG_LIMIT_VERTICAL_THRESHOLD_FACTOR)) {
onValueReset()
}
}
}

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

Возврат ползунка в исходное положение:

// добавляем определение направления в модификаторе DraggableThumbButton.pointerInput
scope.launch {
if (dragDirection.value == DragDirection.HORIZONTAL && thumbOffsetX.value != 0f) {
thumbOffsetX.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = StiffnessLow
)
)
} else if (dragDirection.value == DragDirection.VERTICAL && thumbOffsetY.value != 0f) {
thumbOffsetY.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = StiffnessLow
)
)
}
}

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

Рабочее вертикальное перетаскивание

Отображение иконки сброса значения

Нам нужно отображать иконку сброса значения при вертикальном перетаскивании ползунка. Начнем с определения точки, после которой должна появиться иконка, чтобы избежать ее отображения при незначительном перетаскивании. Нужно также установить аргумент clearButtonVisible в ButtonContainer.

Делаем видимой иконку сброса значения:

// определяем с другими константами
private const val DRAG_CLEAR_ICON_REVEAL_DP = 2

// в верхней части CounterButton
val verticalDragButtonRevealPx = DRAG_CLEAR_ICON_REVEAL_DP.dp.dpToPx()

ButtonContainer(
offsetX = thumbOffsetX.value,
onValueDecreaseClick = onValueDecreaseClick,
onValueIncreaseClick = onValueIncreaseClick,
onValueClearClick = onValueClearClick,
clearButtonVisible = thumbOffsetY.value >= verticalDragButtonRevealPx,
modifier = Modifier
)
Отображение иконки сброса значения

Благодаря этим изменениям, иконка сброса значения становится видимой при вертикальном перетаскивании ползунка и остается скрытой при горизонтальном перетаскивании.

Теперь сделаем иконку сброса значения более заметной по мере приближения ползунка к пределу. Для этого нужно знать значение thumbOffsetY внутри ButtonContainer, чтобы рассчитать прогресс. Кроме того, определим новое свойство verticalHightlightLimitPx и будем использовать его вместе с thumbOffsetY для вычисления свойства tintColor.

Использование thumbOffsetY в ButtonContainer для изменения яркости иконки:

// определяем с остальными константами
private const val DRAG_VERTICAL_ICON_HIGHLIGHT_LIMIT_DP = 60

// обновляем ButtonContainer
@Composable
private fun ButtonContainer(
thumbOffsetX: Float,
// определяем новый аргумент
thumbOffsetY: Float,
onValueDecreaseClick: () -> Unit,
onValueIncreaseClick: () -> Unit,
onValueClearClick: () -> Unit,
modifier: Modifier = Modifier,
clearButtonVisible: Boolean = false,
) {
// в этот момент иконка должна быть максимально видима
val verticalHighlightLimitPx = DRAG_VERTICAL_ICON_HIGHLIGHT_LIMIT_DP.dp.dpToPx()

Row(
...
) {
...

// кнопка сброса
if (clearButtonVisible) {
IconControlButton(
icon = Icons.Outlined.Clear,
contentDescription = "Clear count",
onClick = onValueClearClick,
tintColor = Color.White.copy(
alpha = (thumbOffsetY.absoluteValue / verticalHighlightLimitPx).coerceIn(
ICON_BUTTON_ALPHA_INITIAL,
1f
)
)
)
}

...
}
}

// обновляем CounterButton, чтобы он передавал thumbOffsetY в ButtonContainer
ButtonContainer(
thumbOffsetX = thumbOffsetX.value,
thumbOffsetY = thumbOffsetY.value,
onValueDecreaseClick = onValueDecreaseClick,
onValueIncreaseClick = onValueIncreaseClick,
onValueClearClick = onValueClearClick,
clearButtonVisible = thumbOffsetY.value >= verticalDragButtonRevealPx,
modifier = Modifier
)

Теперь иконка сброса становится более заметной по мере того, как ползунок приближается к пределу.

Теперь иконка сброса более заметна

Сдвиг всей кнопки по вертикали

Аналогично тому, как весь контейнер кнопки слегка сдвигается в направлении горизонтального перетаскивания, он должен перемещаться в случае вертикального перетаскивания. Обновим модификатор .offset в ButtonContainer, добавив значение thumbOffsetY, чтобы вся кнопка следовала за ползунком при вертикальном перетаскивании. А также обновим модификатор .background, чтобы затемнять цвет фона по мере вертикального перемещения ползунка. Кроме того, обновим расчеты для горизонтального перетаскивания, следя за тем, чтобы не превысить значение новой константы CONTAINER_BACKGROUND_ALPHA_MAX.

Применение сдвига по вертикали и вычисление альфа-канала фонового цвета:

// определяем с остальными константами
private const val CONTAINER_BACKGROUND_ALPHA_MAX = 0.7f

// обновляем ButtonContainer
.offset {
IntOffset(
(thumbOffsetX * CONTAINER_OFFSET_FACTOR).toInt(),
(thumbOffsetY * CONTAINER_OFFSET_FACTOR).toInt(),
)
}
...
.background(
Color.Black.copy(
alpha = if (thumbOffsetX.absoluteValue > 0.0f) {
// горизонтальная
(CONTAINER_BACKGROUND_ALPHA_INITIAL + ((thumbOffsetX.absoluteValue / horizontalHighlightLimitPx) / 20f))
.coerceAtMost(CONTAINER_BACKGROUND_ALPHA_MAX)
} else if (thumbOffsetY.absoluteValue > 0.0f) {
// вертикальная
(CONTAINER_BACKGROUND_ALPHA_INITIAL + ((thumbOffsetY.absoluteValue / verticalHighlightLimitPx) / 10f))
.coerceAtMost(CONTAINER_BACKGROUND_ALPHA_MAX)
} else {
CONTAINER_BACKGROUND_ALPHA_INITIAL
}
)
)

Теперь вся кнопка смещается вертикально вслед за ползунком.

Смещение кнопки по вертикали

Скрытие кнопок увеличения и уменьшения при вертикальном перетаскивании

В качестве следующего шага нужно скрыть кнопки уменьшения и увеличения в случае вертикального перетаскивания. Необходимо обновить расчет насыщенности цвета для этих двух кнопок и установить их как невидимые.

Скрываем кнопки увеличения и уменьшения при вертикальном перетаскивании:

// кнопка уменьшения
IconControlButton(
icon = Icons.Outlined.Remove,
contentDescription = "Decrease count",
onClick = onValueDecreaseClick,
tintColor = Color.White.copy(
alpha = if (clearButtonVisible) {
0.0f
} else if (thumbOffsetX < 0) {
(thumbOffsetX.absoluteValue / horizontalHighlightLimitPx).coerceIn(
ICON_BUTTON_ALPHA_INITIAL,
1f
)
} else {
ICON_BUTTON_ALPHA_INITIAL
}
)
)

// кнопка увеличения
IconControlButton(
icon = Icons.Outlined.Add,
contentDescription = "Increase count",
onClick = onValueIncreaseClick,
tintColor = Color.White.copy(
alpha = if (clearButtonVisible) {
0.0f
} else if (thumbOffsetX > 0) {
(thumbOffsetX.absoluteValue / horizontalHighlightLimitPx).coerceIn(
ICON_BUTTON_ALPHA_INITIAL,
1f
)
} else {
ICON_BUTTON_ALPHA_INITIAL
}
)
)

Благодаря этим изменением, иконки увеличения и уменьшения становятся невидимыми во время вертикального перетаскивания.

Исчезновение кнопок увеличения и уменьшения при вертикальном перетаскивании

Отключение кнопок при перетаскивании

Хотя кнопки на самом деле не видны, они по-прежнему доступны для нажатия. Итак, определим новый аргумент enabled для IconControlButton и установим его в false для кнопки сброса, поскольку она никогда не должна быть кликабельной. А кнопки уменьшения и увеличения следует отключать только в случае вертикального перетаскивания.

Отключение кнопок при вертикальном перетаскивании:

@Composable
private fun IconControlButton(
...
// добавляем новый аргумент
enabled: Boolean = true
) {

IconButton(
onClick = onClick,
// устанавливаем свойство enabled
enabled = enabled,
modifier = modifier
.size(48.dp)
) {
...
}
}

// обновляем вызовы в ButtonContainer.Row
// кнопка уменьшения
enabled = !clearButtonVisible

// кнопка сброса
enabled = false,

// кнопка увеличения
enabled = !clearButtonVisible,

Добавление быстрой накрутки счетчика

Теперь пользователь может увеличить значение, либо кликнув на ползунок, либо кликнув на кнопку увеличения, либо перетащив ползунок к правому краю. Но что, если понадобится увеличить значение быстрее и на большую величину?

Ради интереса добавим функцию быстрой накрутки счетчика на случай, если пользователь перетащит ползунок к правому или левому краю и подержит его там некоторое время.

Сделаем это, запустив корутину при первом перетаскивании ползунка, а затем используя функцию приостановки delay для проверки в каждый временной интервал, находится ли ползунок все еще в предельном положении.

Будем отслеживать объект Job, отменив его после того, как пользователь отпустит ползунок.

Добавление логики для быстрого увеличения и уменьшения значения:

// определяем где-то в файле
private const val COUNTER_DELAY_INITIAL_MS = 500L
private const val COUNTER_DELAY_FAST_MS = 100L

awaitPointerEventScope {
...

var counterJob: Job? = null

do {
val event = awaitPointerEvent()
event.changes.forEach { pointerInputChange ->
scope.launch {
// рассчитываем коэффициент сопротивления, чтобы чем ползунок
// ближе был к границе, тем больше усилий требовалось для его перетаскивания
if ((dragDirection.value == DragDirection.NONE &&
pointerInputChange.positionChange().x.absoluteValue
dragDirection.value == DragDirection.HORIZONTAL
) {
// в случае начала перетаскивания
if (dragDirection.value == DragDirection.NONE) {
counterJob = scope.launch {
delay(COUNTER_DELAY_INITIAL_MS)

var elapsed = COUNTER_DELAY_INITIAL_MS
while (isActive && thumbOffsetX.value.absoluteValue >= (dragLimitHorizontalPx * DRAG_LIMIT_HORIZONTAL_THRESHOLD_FACTOR)) {
if (thumbOffsetX.value.sign > 0) {
onValueIncreaseClick()
} else {
onValueDecreaseClick()
}

delay(COUNTER_DELAY_FAST_MS)
elapsed += COUNTER_DELAY_FAST_MS
}
}
}

// указываем горизонтальное направление перетаскивания, чтобы предотвратить вертикальное перетаскивание, пока ползунок не будет отпущен
dragDirection.value = DragDirection.HORIZONTAL

...
} else if (...) {
...
}
}
}
} while (event.changes.any { it.pressed })

counterJob?.cancel()

...
}

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

Изменение цвета выделения на кнопках-иконках

В качестве последнего шага заставим кнопки увеличения и уменьшения изменять цвет иконки на белый при нажатии. Чтобы сделать это, определим interactionSource и получим из него состояние isPressed. Можно использовать это состояние, чтобы изменить насыщенность цвета иконки.

Изменение цвета иконки при нажатии на кнопку:

@Composable
private fun IconControlButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tintColor: Color = Color.White,
// добавляем аргумент
clickTintColor: Color = Color.White,
enabled: Boolean = true
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

IconButton(
onClick = onClick,
// устанавливаем источник взаимодействия
interactionSource = interactionSource,
enabled = enabled,
modifier = modifier
.size(48.dp)
) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
// устанавливаем оттенок при нажатии кнопки
tint = if (isPressed) clickTintColor else tintColor,
modifier = Modifier.size(32.dp)
)
}
}

Теперь при нажатии кнопок иконки отображаются белым цветом.

Изменение цвета иконки при нажатии

Конечный результат

Теперь, когда анимированная кнопка-счетчик полностью создана, конечный результат должен выглядеть следующим образом:

Готовая кнопка-счетчик

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

Окончательную версию кода можно найти в этом гисте на GitHub.

Советы по улучшению:

  • Добавьте тактильную обратную связь, когда пользователь достигает предела перетаскивания, чтобы уведомить об изменении значения.
  • Оптимизируйте производительность composable помощью Layout Inspector.

Примечание: в этом руководстве было использованы некоторые сокращения, чтобы упростить работу. Постарайтесь устранить любые обнаруженные проблемы, если планируете использовать кнопку-счетчик в производственной среде.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Domen Lanišnik: Creating an Animated Counter Button in Jetpack Compose

Предыдущая статьяNetMock: простой подход к тестированию HTTP-запросов в Java, Android и Kotlin Multiplatform
Следующая статьяОбзор 10 приемов JavaScript для эффективного программирования