Недавно передо мной возникла необходимость создать кнопку счетчика для небольшого приложения. Бороздя просторы интернета в поисках хороших готовых решений, я наткнулся на этот дизайн от Эхсана Рахими (Ehsan Rahimi) на Dribble. Придя к выводу, что воссоздать его в Compose — задача нетривиальная, я закатал рукава и принялся экспериментировать. В этом руководстве я поделюсь с вами каждым шагом, который я предпринял для реализации этого дизайна в Jetpack Compose. Сразу оговорюсь, что хоть мы и будем стараться максимально точно соответствовать исходному дизайну, в окончательной версии еще есть над чем поработать — кнопку можно сделать ее еще более плавной и близкой к оригиналу. Начнем мы с создания базового макета без каких-либо функций перетаскивания или анимации. Мы можем деконструировать этот дизайн на два основных компонента: перетаскиваемый ползунок и закругленный макет кнопки с иконками уменьшения, сброса и увеличения значения. Нам также нужен корневой макет для хранения этих двух компонентов. Поскольку кнопка сброса скрыта под перетаскиваемым ползунком, а ползунок можно перетаскивать вертикально за пределы кнопки, нам нужно использовать Box, так как этот компонент позволяет реализовать перекрывающиеся элементы. Давайте теперь разберемся с composable ButtonContainer, который содержит наши кнопки-иконки. Так как эти три кнопки должны быть расположены горизонтально, мы будем использовать компонент Row. Горизонтально разместить кнопки в начале, в центре и в конце макета нам поможет Arrangement.SpaceBetween. Кнопки представлены с помощью composable IconControlButton, который попросту является оберткой IconButton. Примечание: Чтобы использовать такие же иконки, вам нужно либо добавить зависимость androidx.compose.material:material-icons-extended, либо добавить их в проект вручную. Фон необходимой формы мы можем получить с помощью модификатора clip(RoundedCornerShape()), и нам еще нужно будет установить соответствующий цвет фона. Мы также меняем значение альфа-канала цвета фона, так как позже нам нужно будет анимировать его при перетаскивании ползунка. И то же самое нам нужно сделать с параметром tintСolor наших кнопок-иконок. Кнопку сброса мы пока скроем, так как ее логикой мы займемся в самом конце. Примечание: Вообще не рекомендуется так хардкодить цвета, потому что это вызовет проблемы со светлой/темной темой. Здесь делаю это так топорно только для того, чтобы код примера был максимально лаконичным. Для реализации кнопки-ползунка мы будем использовать composable Text, обернутый в Box, что позволит нам применять обрезку CircleShape и тень, чтобы создать эффект круглой кнопки. Для поддержки кликов мы будем использовать модификатор .clickable {}. Наконец, мы будем использовать непосредственно composable CounterButton. Если сейчас запустить наш код, то мы увидим следующий результат. Теперь мы можем заняться добавлением логики счетчика и поддержки перетаскивания. В исходных макетах мы оставили несколько TODO под слушатели кликов. Теперь мы будем прописывать логику для увеличения и уменьшения значения счетчика. Мы бы не хотели, чтобы состояние значения было внутри composable кнопки, поэтому давайте вынесем состояние и добавим слушатели кликов в качестве аргументов CounterButton. Затем нам нужно определить изменяемое состояние для нашего счетчика, которое мы будем обновлять при каждом нажатии кнопки. Мы поместим его на верхний уровень, где оно, скорее всего, и располагалось бы в рамках вашей ViewModel. Благодаря этим изменениям наша кнопка-счетчик наконец ожила. Мы можем нажимать на ползунок или кнопки уменьшения и увеличения, и значение будет изменяться. Кнопка сброса значения в настоящее время не задействована, так как она скрыта за ползунком. Мы вернемся к ней позже, когда добавим вертикальное перетаскивание. Кнопка-счетчик худо-бедно работает. Теперь давайте добавим основную фичу этого дизайна, которая заключается в возможности перетаскивания бегунка для увеличения или уменьшения значения. Во-первых, нам нужно определить две новые переменные внутри DraggableThumbButton. Первая из них — thumbOffsetX: Animatable. Она поможет нам с позиционированием и анимацией кнопки-ползунка, когда мы ее перетаскиваем. Вторая — это scope корутины, которая необходима для обновления thumbOffsetX и запуска анимации. Две новые переменные внутри composable DraggableThumbButton. Далее мы добавим в Box ползунка модификатор .offset, который будет определять смещение composable по отношению к его исходному положению. Для оси x мы будем использовать значение thumbOffsetX, а для оси y пока оставим 0. Теперь нам нужно как-то обнаружить жест перетаскивания. Один из способов сделать это — использовать модификатор .pointerInput, чтобы получить PointerInputScope, из которого мы можем вызывать функции forEachGesture и awaitPointEventScope. Это позволит нам обрабатывать каждое событие касания, как только оно происходит. Дождаться изначального события касания мы можем с помощью awaitFirstDown(). Мы будем использовать цикл do-while для обработки поступающих событий, пока пользователь удерживает кнопку. Это позволит нам получать из событий значение x, которое мы можем применить к ползунку в качестве смещения. Мы используем функцию .snapTo(value), которая устанавливает целевое значение без каких-либо анимаций. Это позволит нам перетаскивать ползунок влево и вправо. Однако, как видите, нам еще есть над чем поработать. Ползунок можно перетаскивать за пределы кнопки, он не возвращается в исходное положение, а прикосновение к нему увеличивает значение. Давайте добавим некоторые ограничения на то, как далеко можно перетаскивать ползунок. Во-первых, нам нужно определить значение максимально допустимого расстояния перетаскивания. Значение должно быть указано в пикселях. В целях упрощения примера мы захардкодим это ограничение, но в идеале мы должны вычислять его динамически на основе ширины ButtonContainer. Сейчас же мы просто укажем статическое значение в dp и конвертируем его в пиксели с помощью функции Density.toPx(), для использования которой нужно получить объект LocalDensity.current из CompositionLocalProvider. Следующим ключевым моментом является ограничение минимума и максимума targetValue, чтобы он находился в пределах . Для этого мы используем функцию coerceIn(minimumValue: Float, maximumValue: Float) из стандартной библиотеки Kotlin, которая гарантирует, что значение находится в пределах предоставленного диапазона. Несколько этих простых изменений дадут нам следующий результат: Сейчас пользователь может перетащить ползунок к границе кнопки, но этого пока ничего не делает. Давайте обнаружим это событие и либо увеличим, либо уменьшим значение, как если бы пользователь просто нажимал на соответствующие кнопки. Во-первых, нам нужно добавить в DraggableThumbButton два новых аргумента: одну лямбду для уменьшения значения и еще одну для увеличения. Далее мы должны обнаружить, что ползунок был передвинут до предела, и вызвать соответствующую функцию в зависимости от направления перетаскивания: влево или вправо. Как нам понять, что пользователь отпустил ползунок и перетаскивание закончено? Условие цикла do-while больше не будет истинным. Мы также можем добавить любую логику по его завершению. После того, как пользователь отпускает ползунок, мы проверяем, насколько близко положение ползунка к максимальному пределу перетаскивания, а затем проверяем направление перетаскивания, чтобы вызвать соответствующую функцию. Счетчик начал работать, но пока ползунок остается там, где мы его отпустили. Сейчас мы это исправим. Мы хотим, чтобы ползунок возвращался в центр, когда пользователь перестает его перетаскивать. В предыдущем разделе мы выяснили, что когда цикл do-while прерывается, это означает, что пользователь отпустил ползунок. Это означает, что все, что нам нужно сделать — это просто заставить thumbOffsetX.value вернуться к 0, как только это произойдет. Мы можем сделать это, запустив новую корутину после обнаружения достижения предела, чтобы обновить объект thumbOffsetX с помощью функции animateTo(). Она принимает значение цели и спецификацию анимации. Если мы выберем анимацию spring, то получим эффект отскока из оригинального дизайна. Возврат ползунка в исходное положение с пружинящей анимацией. Вы можете поэкспериментировать со спецификацией анимации, чтобы получить еще более плавный эффект. В исходном дизайне при перетаскивании ползунка вся кнопка сдвигается в том же направлении. Для этого нам нужно знать положение ползунка на более высоком уровне. Поэтому давайте вынесем определение thumbOffsetX из DraggableThumbButton в CounterButton и будем передавать его в ButtonContainer, чтобы использовать для определения смещения кнопки. Теперь, когда мы перенесли thumbOffsetX в CounterButton, мы можем передать значение в ButtonContainer и использовать его в модификаторе .offset {} для сдвига всего компонента Box нашей кнопки. Умножим это смещение на коэффициент 0.1f, чтобы кнопка по сравнению с ползунком перемещалась совсем чуть-чуть. Наконец, мы должны изменить значение dragLimitHorizontalPx в DraggableThumbButton с 60.dp на 72.dp и перенести его в отдельную константу. Нам необходимо внести это изменение, так как теперь мы перемещаем всю кнопку, из-за чего ползунок больше не касается сторон. После внесения всех этих изменений мы получим следующий результат: В composable с исходным макетом мы использовали на ползунке модификатор .clickable, который позволил нам увеличить значение при нажатии на ползунок. Однако после добавления логики перетаскивания любое прикосновение к ползунку все еще считается кликом. Чтобы исправить это, мы можем добавить проверку, пропускающую клики только тогда, когда ползунок не перетаскивается и находится в исходном положении. Мы также можем определить порог того, что следует считать статической позицией. После добавления этой проверки проблема исправлена, и ползунок можно прокликать только тогда, когда он находится в исходном положении: Текущая логика перетаскивания работает, но выглядит слишком постно. Мы можем сгладить это ощущение, добавив немного сопротивления при перетаскивании ползунка. Сейчас мы просто берем значение изменения положения и добавляем его к текущему смещению ползунка, из-за чего он двигается совершенно линейно. Мы можем умножать изменение положения на какой-нибудь коэффициент, меньший 1, в результате чего мы создадим эффект небольшого сопротивления. Таким образом, чем ближе ползунок к краю, тем больше усилий требуется, чтобы перетащить его дальше. Поскольку достичь края кнопки теперь труднее, мы должны обновить проверку, где мы решаем, достаточно ли близко к нему ползунок, чтобы увеличить или уменьшить значение. Мы можем умножить dragLimitHorizontalPx на тот же коэффициент, чтобы получился некоторый зазор. С обновленным расчетом смещения требуется больше усилий, чтобы перетащить ползунок до самой границы: Иконки уменьшения и увеличения изначально должны быть едва заметными и становиться ярче по мере того, как к ним приближается ползунок. Давайте определим в ButtonContainer новую переменную, представляющую точку, в которой иконка должен стать полностью видимой. Мы можем использовать текущее смещение ползунка и новое значение, чтобы вычислить процент и использовать его в качестве альфа-канала tintColor. Мы также устанавливаем для альфы минимум в 30%. Логикой кнопки сброса мы займемся позже. Теперь иконки становятся более заметными, когда мы перетаскиваем ползунок ближе к краю: Подобно иконкам, мы можем использовать перетаскивание для динамического изменения фона ButtonContainer, чтобы его цвет становился темнее по мере приближения ползунка к краю кнопки. Мы сделаем значение альфа-канала равным изначальному значению плюс небольшой множитель на основе отклонения. И вот результат этого небольшого изменения. Не бойтесь экспериментировать с коэффициентами, чтобы добиться лучшего результата. Мы добились того, горизонтальное перетаскивание работает именно так, как мы хотели. Отлично, теперь давайте добавим поддержку вертикального перетаскивания, которое должно сбрасывать текущее значение счетчика. Начнем с определения нового свойства thumbOffsetY: Animatable в CounterButton, которое мы будем использовать для управления смещением y аналогично существующему свойству thumbOffsetX. Далее заставим модификатор .offset наблюдать за thumbOffsetY, чтобы положение ползунка могло обновляться по вертикали. И еще нам нужно обновить модификатор .clickable, чтобы избежать нежелательных кликов в процессе вертикального перетаскивания. Далее мы определим новое перечисление DragDirection и новое изменяемое свойство dragDirection: DragDirection, который мы будем использовать для отслеживания состояния направления перетаскивания. Это позволит нам разрешить перетаскивание только по одной оси, предотвращая одновременное перетаскивание по горизонтали и вертикали. Нам также потребуется определить новую переменную dragLimitVerticalPx, которая будет контролировать, насколько далеко можно перетаскивать ползунок по вертикали. Затем нам нужно написать логику обнаружения перетаскивания по вертикали внутри уже существующего модификатора .pointerInput. Первый шаг — определить, в каком направлении происходит перетаскивание: горизонтальном или вертикальном. Мы можем сделать это, проверив, что из pointerInputChange.positionChange().x и pointerInputChange.positionChange().y изменилось. Но поскольку в случае вертикального перетаскивания позиция x может немного измениться (и наоборот), нам также нужно будет предусмотреть некоторый порог для этих значений, чтобы уменьшить вероятность определения неправильного направления. Как только мы определили направление перетаскивания, мы можем обновить либо свойство thumbOffsetX, либо thumbOffsetY для перемещения ползунка. Нам также нужно использовать переменную dragDirection, контролирующую направление перетаскивания, чтобы разрешить перетаскивание только по одной оси за раз. Теперь, когда перетаскивание работает в обоих направлениях, нам нужно добавить логику для обнаружения вертикального перетаскивания до предела. Как только значение thumbOffsetY пересекает значение dragLimitVerticalPx (с некоторой зазором), мы запускаем коллбэк onValueReset(), который сбрасывает счетчик. Наконец, мы должны обновить логику возврата ползунка в исходное положение. Нам нужно сначала проверить направление перетаскивания, а затем сбросить либо thumbOffsetX в случае горизонтального перетаскивания, либо thumbOffsetY в случае вертикального перетаскивания, чтобы ползунок вернулся в исходное положение. Благодаря этим изменениям мы получили вертикальное перетаскивание, которое сбрасывает счетчик в ноль: Теперь мы хотим еще и отображать иконку сброса значения при вертикальном перетаскивании ползунка. Мы начнем с определения точки, после которой должна появиться иконка, чтобы не показывать ее в случае незначительного перетаскивания. И нам нужно установить аргумент ClearButtonVisible в ButtonContainer. Благодаря этим изменениям иконка сброса счетчика становится видимой при перетаскивании ползунка по вертикали и остается скрытой при перетаскивании по горизонтали. Далее, давайте делать эту иконку более яркой по мере перетаскивания ползунка. Для этого нам нужно знать значение thumbOffsetY внутри ButtonContainer, чтобы мы могли рассчитать прогресс. Кроме того, мы определим новое свойство verticalHightlightLimitPx и будем использовать его вместе с thumbOffsetY, чтобы вычислять свойство tintColor динамически. Иконка сброса счетчика теперь становится более яркой, по мере приближения ползунка к пределу. Подобно тому, как весь контейнер кнопки слегка сдвигается в направлении горизонтального перетаскивания, он также должен немного сдвигаться и в случае вертикального перетаскивания. Давайте добавим в модификатор .offset в ButtonContainer значение thumbOffsetY, чтобы вся кнопка двигалась вслед за ползунком при вертикальном перетаскивании. А также обновим модификатор .background, чтобы затемнять фон аналогично горизонтальному перетаскиванию. Мы также обновим здесь наши расчеты для горизонтального перетаскивания, чтобы не превышать значение новой константы CONTAINER_BACKGROUND_ALPHA_MAX. Вся кнопка теперь сдвигается вертикально вслед за ползунком. Нам не мешало бы скрывать кнопки уменьшения и увеличения в процессе вертикального перетаскивания. Нам нужно обновить расчет цвета для этих двух кнопок, чтобы делать их невидимыми, когда нужно. С этим изменением иконки увеличения и уменьшения становятся невидимыми во время вертикального перетаскивания. Хотя кнопки теперь пропадают, на них все еще можно нажимать. Итак, давайте определим новый аргумент enabled для IconControlButton и установим его в false для кнопки сброса, так как она никогда не должна быть кликабельной. А кнопки уменьшения и увеличения должны быть отключены только в случае вертикального перетаскивания. Теперь пользователь может увеличить значение, тапнув по ползунку, тапнув кнопку увеличения или перетащив ползунок к правому краю. Но что, если мы захотим увеличить значение быстрее и на большее количество? Ради интереса давайте добавим функцию быстрой накрутки счетчика, когда пользователь перетащит ползунок к правому или левому краю и подержит его там некоторое время. Мы сделаем это с помощью запуска корутины при первом перетаскивании ползунка и функции приостановки delay, чтобы продолжать проверять, находится ли ползунок в предельном положении. Мы также следим за объектом Job и отменяем его после того, как пользователь отпустит ползунок. Теперь, если мы перетащим ползунок к краю и задержим его там, значение должно начать быстро увеличиваться или уменьшаться. В качестве последнего шага мы заставим кнопки увеличения и уменьшения изменять при нажатии цвет иконки на белый. Для этого нам нужно определить наш interactionSource и получить из него состояние isPressed. Мы можем использовать это состояние для изменения tintColor иконки. Теперь при нажатии кнопок иконки отображаются белым цветом. Вот мы и создали нашу анимированную кнопку-счетчик, и окончательный результат должен выглядеть так: Это достаточно длинное руководство, поэтому спасибо, что выдержали его до конца, и, я надеюсь, что вы довольны нашим результатом. Не стесняйтесь экспериментировать со значениями и анимацией, чтобы еще больше приблизиться к исходному дизайну или создать свою собственную версию. Вы можете найти окончательную версию кода в этом гисте на GitHub. Над чем еще можно поработать Добавьте тактильную обратную связь, когда пользователь достигает предела перетаскивания, чтобы уведомить об изменении значения. Оптимизируйте производительность composable с помощью Layout Inspector. Примечание: В этом руководстве пропускал некоторые моменты, чтобы не делать его еще больше. Если вы планируете использовать результат в производственной среде, вам стоит слегка пройтись по этому коду напильником. Всех, кто прочитал материал до конца, приглашаем на бесплатные уроки, которые пройдут в рамках онлайн-курсов OTUS в ближайшее время: Фоновая работа в Android — Service и WorkManager: рассмотрим особенности фоновой работы в Android и научимся выбирать правильный инструмент для конкретной задачи. Регистрация на урок Архитектура приложения и модуль бизнес-логики. На занятии обсудим, как поддерживать чистую архитектуру приложения и контролируемо внедрять изменения. Исследуем библиотеку для реализации бизнес-процессов, написанную на Kotlin. Посмотрим пример модуля бизнес-логики, в котором сконцентрированы все требования заказчика. Регистрация на урок  Редактор