Создание анимированной кнопки-счетчика в Jetpack Compose (часть 1.)
https://t.me/ai_machinelearning_big_data
Недавно мне понадобилось создать кнопку-счетчик для простого приложения. Просматривая интернет-ресурсы в поисках вдохновения, наткнулся на 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")
}
Это отправная точка. Теперь займемся логикой счетчика и поддержкой перетаскивания.
Добавление логики счетчика
В исходных макетах мы оставили несколько 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) { this@dpToPx.toPx() }
Следующим ключевым моментом является ограничение минимального и максимального значения 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()
...
}