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

Дизайн кнопки-счетчика от Эхсана Рахими.
Дизайн кнопки-счетчика от Эхсана Рахими.

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

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

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

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

@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 корневого базового макета.

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

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

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

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

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)
        )
    }
}

Cmposable контейнера нашей кнопки.

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

@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 перетаскиваемого ползунка.

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

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

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

Результат наших composable.
Результат наших composable.

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

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

@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)
        )
    }
}

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

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

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. Она поможет нам с позиционированием и анимацией кнопки-ползунка, когда мы ее перетаскиваем. Вторая — это scope корутины, которая необходима для обновления thumbOffsetX и запуска анимации.

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

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

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

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

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

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

// в качестве последнего модификатора 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 })
        }
    }
}

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

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

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

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

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

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

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

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

Следующим ключевым моментом является ограничение минимума и максимума 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 два новых аргумента: одну лямбду для уменьшения значения и еще одну для увеличения.

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

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

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

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

Как нам понять, что пользователь отпустил ползунок и перетаскивание закончено? Условие цикла 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 и будем передавать его в ButtonContainer, чтобы использовать для определения смещения кнопки.

@Composable
private fun CounterButton(
    ...
) {
    Box(
        contentAlignment = Alignment.Center,
        modifier = modifier
            .width(200.dp)
            .height(80.dp)
    ) {
        // перенесено из 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 на уровень выше.

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

@Composable
private fun CounterButton(
    ...
) {
    Box(
        ...
    ) {
        // перенесено из 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()
            ...
      ){
      ...
    }
}

Смещение всего ButtonContainer.

Наконец, мы должны изменить значение 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, который позволил нам увеличить значение при нажатии на ползунок. Однако после добавления логики перетаскивания любое прикосновение к ползунку все еще считается кликом.

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

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

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

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

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

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

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

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

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

Текущая логика перетаскивания работает, но выглядит слишком постно. Мы можем сгладить это ощущение, добавив немного сопротивления при перетаскивании ползунка. Сейчас мы просто берем значение изменения положения и добавляем его к текущему смещению ползунка, из-за чего он двигается совершенно линейно. Мы можем умножать изменение положения на какой-нибудь коэффициент, меньший 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 на тот же коэффициент, чтобы получился некоторый зазор.

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

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

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

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

Перетаскивание ползунка к границе.
Перетаскивание ползунка к границе.

Акцентирование иконок увеличения и уменьшения

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

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

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

// добавляем в верхнюю часть 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 в CounterButton, которое мы будем использовать для управления смещением y аналогично существующему свойству thumbOffsetX.

// 1. обновляем 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. обновляем 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
) {
  ...
}

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

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

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

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

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

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

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

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

Определение нового перечисления DragDirection.

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

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

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

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

Затем нам нужно написать логику обнаружения перетаскивания по вертикали внутри уже существующего модификатора .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
                          
                            // рассчитываем коэффициент сопротивления,  чтобы чем ползунок
                            // ближе к границе, тем больше усилий требовалось для его перетаскивания
                            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)
                        } else if (
                            (dragDirection.value != DragDirection.HORIZONTAL &&
                                    pointerInputChange.positionChange().y >= startDragThreshold)
                        ) {
                            // указываем вертикальное направление перетаскивания, чтобы предотвратить горизонтальное перетаскивание, пока ползунок не будет отпущен
                            dragDirection.value = DragDirection.VERTICAL
                            
                            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 динамически.

// определяем с остальными константами
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
)

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

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

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

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

Подобно тому, как весь контейнер кнопки слегка сдвигается в направлении горизонтального перетаскивания, он также должен немного сдвигаться и в случае вертикального перетаскивания. Давайте добавим в модификатор .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,
        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. Мы можем использовать это состояние для изменения tintColor иконки.

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

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


Всех, кто прочитал материал до конца, приглашаем на бесплатные уроки, которые пройдут в рамках онлайн-курсов OTUS в ближайшее время:

  • Фоновая работа в Android — Service и WorkManager: рассмотрим особенности фоновой работы в Android и научимся выбирать правильный инструмент для конкретной задачи. Регистрация на урок

  • Архитектура приложения и модуль бизнес-логики. На занятии обсудим, как поддерживать чистую архитектуру приложения и контролируемо внедрять изменения. Исследуем библиотеку для реализации бизнес-процессов, написанную на Kotlin. Посмотрим пример модуля бизнес-логики, в котором сконцентрированы все требования заказчика. Регистрация на урок

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


  1. gohrytt
    06.06.2023 18:52

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