
Помните компонент MotionLayout
? С его помощью можно просто реализовывать сложные анимации, в том числе и основанные на жестах.
У нас в Дринките был компонент, сделанный на MotionLayout — слайдер быстрой оплаты в меню. Он появляется, когда пользователь добавляет продукты в корзину.
С помощью этого компонента можно быстро оплатить свой заказ или перейти в корзину, тапнув по нему. Для того, чтобы анимировать его, MotionLayout подходил отлично.

Но шло время, баги копились, поддерживать элемент становилось всё сложнее, да и слайдер нуждался в новом функционале:
-
В команде захотели, чтобы слайдер был спрятан, когда пользователь не авторизован или выбранная кофейня закрыта.
-
Для новой фичи «Подарок другу» нужно было заменить фон слайдера. Он должен был переливаться.
Эти две фичи было сложно реализовать, используя MotionLayout. Сложность состоит в сценах, где для каждой View прописаны её свойства. И если нужно поменять то, как ведёт себя View, нужно редактировать сцены. А сделать это не всегда просто. Тогда-то мы и решили переписать его на Compose.
Привет! Меня зовут Дима Максимов, я Android-разработчик в Dodo Engineering. Сегодня я расскажу вам, почему на Compose гораздо проще пилить масштабируемые и расширяемые компоненты.
В этой статье мы подробно разберём процесс создания компонента на Compose. Посмотрим на код и увидим, как он реализуется в интерфейсе.
Предпосылки переписать компонент заново
Напомню вводные, с которыми мы заходим в работу:
в приложении есть фича «Слайдер оплаты»;
её компонент написан на MotionLayout;
это крутой инструмент, но он уже не покрывает желаемый функционал, а мы — то количество, багов, что из-за него появляется;
нам стало проще написать компонент заново, чем исправлять и поддерживать его нынешнюю версию.

Для начала разберёмся с багами, которые надо было поправить. Спойлер: все они были связаны с ограничениями или особенностями MotionLayout. Какие были самыми критичными?
В MotionLayout есть проблема с изменением visibility вьюшек. Состояние View контролируется сценами, а потому, чтобы скрыть его, нужно пройтись по всем сценам MotionScene и спрятать на каждой. Работает это, кстати, не всегда ?
Сами сцены в виде XML и множества тегов путают. Transitions, ConstraintSets, Constraint и Layout выглядят громоздко, а разобраться в них с наскока не получится.

К тому же из последних версий Android Studio удалили MotionLayout Editor, а значит придётся пользоваться другими инструментами.
Все это приводило к багам UI, которые было сложно воспроизводить и править. Происходили вот такие наложения интерфейса и некорректные состояния:

Бывало и такое: слайдер справа и слева. Видим цену, а за ней просвечивается Payment.... И не очень понятно, в каком мы сейчас статусе:

Ситуация — безнадёжная. Долго мы не хотели переписывать компонент на Compose, но тут терпение лопнуло — имеющиеся проблемы стали хуже всех возможных рисков. Делаем!
Как было и что делаем
Наш слайдер оплаты поделён на три элемента: тянущийся слайдер слева, цена и время ожидания в центре, а справа — содержимое корзины.

А ещё есть стейт ошибки на случай, если оплата не пройдёт. Он выглядит так:

В идеале слайдер работает так:
Пользователь зажимает слайдер, а мы прячем контент с ценой и подсказываем: pull to pay.
Пользователь тянет слайдер, растягивая его. Мы прячем подсказку.
Пользователь доводит слайдер до конца и отпускает его, возвращая ему привычный размер. Начинается оплата: появляется надпись payment, включается шиммер — индикатор процесса оплаты.

Посмотрим, что сделали до нас
Год назад у «Контура» вышла статья, в которой они делают похожий слайдер, но чуть попроще. Реализовали они его двумя способами: через Compound (как я его называю по аналогии с XML) и через Layout Composable.

Возникает вопрос: зачем? Кажется, что через Compound это сделать проще, но это не так. Создать слайдер через Layout Composable не только легче, но и производительнее с точки зрения оптимизации рекомпозиций. Давайте разбираться, почему так происходит.
Спойлер: получился виджет, которому потребовался отдельный Sample App для проверки всех статусов.

Реализация
Подход с Compound Composable
В учебных целях мы тоже попробовали реализовать слайдер через Compound Composable. Я написал его, используя композицию контейнеров, но в процессе иерархия компонентов и связей настолько разрослась, что расширять её безболезненно уже не получалось.


Мы используем стандартные контейнеры, а потому для сложного компонента появились кастомные Modifier.layout {}
, разбросанные по разным местам. Так почему бы не сделать слайдер сразу через свой Layout
?
Что стоит запомнить?
Если вы пишите Composable со множеством связей по размерам и позиционированию элементов, сразу используйте Layout.
Используйте плоский Layout, если у компонента большая вложенность — тогда лишних групп в Slot Table не будет.
Большую структуру сложнее дебажить и оптимизировать.
Делаем слайдер через Layout
А теперь переходим ко вкусному. Разберём подробнее, как сделан слайдер на Layout
.
Требования к дизайну и поведению
У слайдера есть множество ограничений и требований, как и у любого элемента дизайна. Учитывать их лучше с самого начала работы. Минимальные требования такие:
Слайдер должен быть динамическим по ширине. Она зависит от того, сколько места занимает контент с продуктами и их ценой, и ограничивается значением «ширина экрана - паддинг».
Слайдер тянется жестом, для которого есть порог «успешности»: если он прошёл 80% компонента, то до предела «доезжает» сам, а если нет — возвращается в idle состояние.
Текущий стейт слайдера — idle, paying, syncing — может прийти извне в независимости от жеста. Тогда нам придётся изменить стейт слайдера на требуемый.
-
В слайдере должна отображаться следующая информация:
thumb — текущий способ оплаты. Он может не отображаться, если кофейня закрыта или гость не авторизован;
центральный контент: цена, бейдж лояльности, время приготовления. Он должен помещаться по возможности целиком;
продукты в корзине — от 1 до 3 штук в порядке добавления в корзину. Выглядят они как стек с наслоением;
cтейты
Error
,Paying
,Syncing
.
На каждом шагу я буду повторять конкретные пункты и давать пояснения. А также иногда пополнять список, если потребуется. Иначе этот раздел может значительно вырасти.
Нарисуем каркас
Первое, что мы видим в компоненте, — Thumb и фон слайдера. Их и нарисуем. Поскольку компонент мы реализуем через Layout, сразу заложим его фундамент. Создадим Composable для компонента.
К такому слайдеру и концепции кастомного компонента отлично применяется принцип Slot API, который активно используется в Compose. Сделаем что-то похожее:
@Composable
fun FastPaymentButton(
fastPaymentState: FastPaymentState,
modifier: Modifier = Modifier,
thumbContent: @Composable BoxScope.() -> Unit = {},
background: @Composable () -> Unit = DefaultFastPaymentButtonBackground,
// Параметры будут добавляться по мере обогащения слайдера фичами
onClick: () -> Unit = {},
onSwiped: () -> Unit = {},
) {
// Content
}
Заводим внутри FastPaymentButton
стандартный Layout
. В методе content
рисуем два Composable: фон и Thumb.
Layout(
modifier = modifier
// Временное решение ограничить ширину
// Оставляем, пока не нарисуем динамический контент
.width(400.dp),
content = {
Background(
modifier = Modifier
// Ставим LayoutId, чтобы найти его в Measurer
.layoutId(Background),
background = background,
)
Thumb(
modifier = Modifier
// Ставим LayoutId, чтобы найти его в Measurer
.layoutId(Thumb)
.width(96.dp)
.height(60.dp),
thumbContent = thumbContent,
)
},
measurePolicy = fastPaymentMeasurer(),
)
У нас Thumb и Background содержат Box и Image. У вас их содержание может отличаться.
Всё, что мы передаём в наш Layout, попадает в блок MeasurePolicy в качестве списка List<Measurable>
, где контент надо измерить и разместить.
Сама реализация measurer выглядит так:
@Composable
private fun fastPaymentMeasurer(
): MeasurePolicy = MeasurePolicy { measurables, constraints ->
// Without loosing minWidth and minHeight,
// widths of measured composables will be max because of the parent
// https://stackoverflow.com/a/68606264
val looseConstraints = constraints.copy(
minWidth = 0,
minHeight = 0
)
// firstOrNull {} потому что по условию дизайна его может не быть
val thumbPlaceable = measurables.firstOrNull { it.layoutId == FastPaymentLayoutId.Thumb }
?.measure(looseConstraints)
val backgroundPlaceable = measurables.first { it.layoutId == FastPaymentLayoutId.Background }
.measure(looseConstraints)
layout(constraints.maxWidth, constraints.maxHeight) {
backgroundPlaceable.placeRelative(0, 0)
thumbPlaceable?.placeRelative(0, 0)
}
}
Здесь мы только измеряем приходящие нам Measurables
, получая тем самым экземпляры Placeables
. Эти самые Placeables
нам надо расположить в родительском контейнере.
Перед measure
и layout
есть блок looseConstraints
. Сами constraints
приходят от родителя. Если у него явно задана ширина, элементы будут измеряться с той же самой шириной, а не по размеру своего контента.
Например, у Thumb указан Modifier.*width*(96.*dp*).*height*(60.*dp*)
, а у родителя —Modifier.width(400.dp)
. Если мы уберём looseConstraints
, то получим такой результат:

Чтобы вьюха могла измериться по своим размерам, мы обнуляем приходящие констрейнты. Далее будем использовать looseConstraints
везде:

Уже красиво, но пока не очень функционально. Чтобы оживить компонент, надо добавить слайд жестом.
Учимся тащить слайдер пальцем
Научим Thumb реагировать на жест «слайда». Для него отлично подойдёт стандартный Modifier.anchoredDraggable()
.
Если версия вашего Compose ниже 1.6.0-alpha01, используйте альтернативный
Modifier.swipeable
. А если планируете обновляться, гляньте гайд.
Добавим модифаер на компонент Thumb:
Thumb(
modifier = Modifier
// Other modifiers
.anchoredDraggable<SwipeButtonAnchor>(
state = anchoredDraggableState,
orientation = Horizontal,
startDragImmediately = true,
),
)
С помощью startDragImmediately = true
можно перетащить Draggable элемент по касанию. Без этой настройки жест начинался бы после прохождения пальцем некоторого расстояния.
Стейт anchoredDraggableState
мы объявили заранее перед использованием. В нём задаются якоря — начальное и конечное положение, различные threshold’ы и анимации:
val anchoredDraggableState by remember {
AnchoredDraggableState(
initialValue = Start,
anchors = DraggableAnchors {
Start at 0f
// Заменить Float.MAX_VALUE на `ширина - ширина thumb`
End at Float.MAX_VALUE
},
positionalThreshold = 0.8f,
velocityThreshold = 10000f,
snapAnimationSpec = tween(),
decayAnimationSpec = rememberSplineBasedDecay()
)
}
val anchoredDraggableState by remember {
AnchoredDraggableState(
initialValue = Start,
anchors = DraggableAnchors {
Start at 0f
// Заменить Float.MAX_VALUE на `ширина - ширина thumb`
End at Float.MAX_VALUE
},
velocityThreshold = 10000f,
snapAnimationSpec = tween(),
decayAnimationSpec = rememberSplineBasedDecay()
)
}
Посередине свайп остановиться не может — у него всего два состояния: старт и конец. Значит, и якорей будет два:
enum class SwipeButtonAnchor {
Start,
End,
}
Чтобы установить конечный якорь, нужно явно знать расстояние для него в пикселях. Чтобы вместо выражения End at Float.MAX_VALUE
было указано значение полная ширина - ширина thumb
.
anchors = DraggableAnchors {
Start at 0f
// Заменим Float.MAX_VALUE на `полная ширина - ширина thumb`
End at Float.MAX_VALUE
}
Для этого мы обычно используем Modifier.onSizeChanged {}
. Применим модифаер на Layout
и Thumb
, получим их размеры.
var allWidth by remember { mutableIntStateOf(0) }
var thumbWidth by remember { mutableIntStateOf(0) }
val endOfTrackWidth = remember(allWidth, thumbWidth) {
allWidth - thumbWidth
}
// В сайд эффекте на каждом изменении endOfTrackWidth обновляются якори
// Так как ширина по условиям задачи динамическая, происходить это может много раз
LaunchedEffect(endOfTrackWidth) {
fastPaymentDraggableState.updateAnchors(
newAnchors = DraggableAnchors {
Start at 0f
End at endOfTrackWidth.toFloat()
}
)
}
Layout(
modifier = Modifier
.onSizeChanged {
allWidth = it.width
}
) {
Thumb(
modifier = Modifier
.onSizeChanged {
thumbWidth = it.width
}
)
}
Также напомню, что при достижении 80% компонента пальцем, остальные 20% слайдер «доезжает» сам, а в противном случае — возвращается в idle состояние.
У AnchoredDraggableState
есть такой параметр postionalThreshold
, в документации про него сказано следующее:
The positional threshold, in px, to be used when calculating the target state while a drag is in progress and when settling after the drag ends. This is the distance from the start of a transition.
В целом, по описанию я предполагал, что оно будет работать из коробки, но нет. Возможно я что-то делаю не так, когда инициализирую postionalThreshold
с расстоянием 0.8f
:
AnchoredDraggableState(
// ..
positionalThreshold: (totalDistance: Float) -> Float =
{ distance -> distance * 0.8f },
...
)
Поэтому решим это, вычислив прогресс самостоятельно, когда пользователь заканчивает жест. В тот момент, когда пользователь отпускает слайдер, вычислим, насколько далеко он провёл слайдер из начального положения через метод anchoredDraggableState.progress()
. Если прогресс больше 80%, то дотянем слайдер автоматически до конца и начнём оплату:
/**
* The payment confirmation is called when swiped the slider to the end and pull gesture is over
*/
@Composable
private fun ConfirmPaymentBySwipe(
fastPaymentDraggableState: FastPaymentDraggableState,
onSwiped: () -> Unit,
) {
val draggableIsDragged by fastPaymentDraggableState.interactionSource.collectIsDraggedAsState()
LaunchedEffect(draggableIsDragged) {
if (draggableIsDragged) return@LaunchedEffect
val anchoredDraggableState = fastPaymentDraggableState.anchoredDraggableState
val swipeProgress = anchoredDraggableState.progress(from = Start, to = End)
if (swipeProgress > 0.8f) {
onSwiped()
} else {
anchoredDraggableState.animateTo(Start)
}
}
}
Теперь, когда мы «тащим» слайдер, изменяется параметр offset
у AnchoredDraggable
, но движения нет. Чтобы оно появилось, нужно применить offset
во время layout
фазы. Передадим в measurer
стейт и применим offset
в thumbPlaceable.place
()
:
layout(constraints.maxWidth, constraints.maxHeight) {
thumbPlaceable?.placeRelative(draggableState.offset.toInt(), 0)
}
Получаем слайдер, который двигается за пальцем. Он «прилипает» к началу или к концу, меняя сторону, когда преодолевает более 80% компонента:

Реагируем на проведённый слайдер
Жест готов. Подвязываем к нему функции, которые он вызывает. Чтобы оплата прошла, пользователь должен провести слайдер на более чем 80% ширины компонента и отпустить палец.
Для реализации первого условия берём текущий якорь слайдера через draggableState.currentValue
.
Второе условие реализуем через InteractionSource
. Он позволит нам наблюдать за drag жестом. Для этого определим InteractionSource
:
val interactionSource = remember {
MutableInteractionSource()
}
Затем передаём его в anchoredDraggable:
.anchoredDraggable<SwipeButtonAnchor>(
// Other parameters
interactionSource = interactionSource
)
AnchoredDraggable
сам сообщает о производимых жестах — нам остаётся только прочитать их. Для этого используем LaunchedEffect
с двумя ключами. При изменении любого ключа и выполнении условия начинается оплата.
// Нам нужен жест isDragged. False - когда жест завершен. True – drag в процессе
val draggableIsDragged by interactionSource.collectIsDraggedAsState()
LaunchedEffect(fastPaymentDraggableState.currentValue, draggableIsDragged) {
if (draggableIsDragged) return@LaunchedEffect
if (fastPaymentDraggableState.currentValue == End) {
onSwiped()
}
}
Вуаля! Оплата начинается на окончании жеста при достижении 80% ширины компонента — всё как мы и задумывали:

(Не) делаем остальные вьюшки
Теперь заполним слайдер полезным контентом о продуктах и цене, а также разными индикаторами. В общем, всем, о чём нужно знать гостю, чтобы совершить заказ:

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

Правила измерения таковы:
Ширина Thumb — неизменна. Если его нет, в измерениях его не учитываем.
Набор продуктов. Есть всегда. Для конкретного количества продуктов его ширина неизменна.
Центральный контент: цена, время ожидания и т.д. Он должен быть минимально возможной ширины, чтобы отображаться без артефактов. При этом больше допустимого максимума для слайдера он быть не может.
Поместим контент в структуру Layout через content:
Layout(
content = {
// layoutId = FastPaymentLayoutId.Center
CenterContent()
// layoutId = FastPaymentLayoutId.End
EndContentContainer()
if (draggableSliderVisible) {
// layoutId = FastPaymentLayoutId.Thumb
Thumb()
}
}
)
Найдём в Measure Thumb
и измерим его:
val thumbPlaceable = measurables.firstOrNull { it.layoutId == FastPaymentLayoutId.Thumb }
?.measure(looseConstraints)
val endContentPlaceable = measurables.first { it.layoutId == FastPaymentLayoutId.End }
.measure(looseConstraints)
val thumbWidth = thumbPlaceable?.width ?: 0
val endWidth = endContentPlaceable.width
Измерим Center
. Согласно дизайну, ширина слайдера должна быть динамической. При этом сам он должен быть не очень большим и влезать в экран.
Найдём размер минимально необходимого места для центрального контента. Поместим его между Thumb и End с небольшими отступами по обе стороны:

Измерим центральный Measurable
, ограничив его максимальную ширину:
// Вычислим максимальную ширину для центральной части
val centerMaxWidth = getCenterMaxWidth()
// Измерим контент по центру
val centerPlaceable = measurables
.first { it.layoutId == FastPaymentLayoutId.Center }
.measure(
looseConstraints.copy(
maxWidth = centerMaxWidth,
)
)
centerMaxWidth
вычисляется по формуле:
centerMaxWidth = maxWidth - thumbWidth - endWidth - paddings слева/справа
private fun MeasureScope.getCenterMaxWidth(
looseConstraints: Constraints,
thumbWidth: Int,
endWidth: Int,
): Int {
val alreadyOccupiedWidth = thumbWidth + endWidth
val centerOffsetFromThumb = getCenterOffsetFromThumb(thumbWidth)
return looseConstraints.maxWidth - alreadyOccupiedWidth - centerOffsetFromThumb
}
Измерив компоненты, получаем объекты Placeable
, которые надо разместить в блоке layout {}
. Сделать это надо в порядке их видимости:
Сначала рисуем центральный контент с ценой, чтобы Thumb перекрывал его при сдвиге.
После него располагаем список продуктов, чтобы Thumb также перекрывал при быстром сдвиге, если анимация была очень быстрая.
Поверх всего рисуем Thumb — компонент, перекрывающий другой контент.
layout(occupiedWidth, constraints.maxHeight) {
// background, который мы уже разместили на предыдущем этапе
// backgroundPlaceable.placeRelative(0, 0)
centerPlaceable.placeRelative(centerStartX, 0)
endContentPlaceable.placeRelative(occupiedWidth - endWidth, 0)
// Thumb рисуем последним, чтобы он мог перекрывать весь контент позади
thumbPlaceable?.placeRelative(draggableState.offset.toInt(), 0)
}
Видим пару новых полей. Первое — occupiedWidth
, ширина всех элементов. Ограничим её снизу и сверху на всякий случай:
val occupiedWidth =
(thumbWidth + endWidth + centerWidth)
.coerceIn(constraints.minWidth, looseConstraints.maxWidth)
Второе — centerStartX
. Старт по X. Из него нужно нарисовать центральный блок:
centerStartX = ширина Thumb + некоторый offset
А для чего нам тут offset
? Он нужен, чтобы блок встал чётко по центру — между Thumb и End.
Дело в том, что мы ограничиваем слайдер и по ширине. Расстояние между Thumb и End может оказаться больше, чем Center. Так или иначе нам нужно поместить его в центр свободного пространства.
Чтобы найти offset, вычислим ширину пустого пространства по центру. Вычтем из неё реальный размер CenterContent
и поделим результат на 2:
private fun MeasureScope.getCenterStartX(
thumbWidth: Int,
occupiedCenter: Int,
centerWidth: Int,
): Int {
val offsetInsideCenter = (occupiedCenter - centerWidth) / 2
return thumbWidth + offsetInsideCenter
}
Все готово! Запускаем и радуемся результату:


Наводим красоту
Осталось навести красоту: доделать отдельные стейты, добавить ещё больше анимаций, хаптик и шиммер. Поехали!
Растягивающийся след за слайдером
Хотим, чтобы при вытягивании Thumb за ним шла белая полоса. Она схлопнется в конечную точку, когда начнётся оплата:

Для наглядности вынесу полосу, которая тянется за слайдером, за пределы компонента. К чему мы стремимся:

Предположим, что мы нарисовали полосу и положили её внутрь Layout
:
Layout() {
// Stretching progress
Box(
modifier = Modifier
.layoutId(Progress)
.fillMaxHeight()
.padding(4.dp)
.shadow(elevation = 2.dp, shape = CircleShape)
.background(DrinkitTheme.drinkitColors.backgroundPrimary, CircleShape)
.testTag(PAYMENT_ICON)
)
}
Теперь нам нужно, во-первых, растягивать вьюшку, пока мы тянем Thumb к конечному положению. Для этого правильно измерим ширину. В нашем случае ширина — это draggable.offset + thumbWidth
.
// Высчитаем ширину и ограничим для предотвращения неожиданных крашей
val progressWidth = (offset.roundToInt() + thumbWidth)
.coerceIn(thumbWidth, occupiedWidth)
val progressPlaceable = measurables.first { it.layoutId == FastPaymentLayoutId.Progress }
.measure(
looseConstraints.copy(
minWidth = progressWidth,
maxWidth = progressWidth
)
)
...
layout() {
progressPlaceable.placeRelative(dragOffsetProvider(), 0)
}
Во-вторых, сделать так, чтобы левый край полоски притянулся к левому положению Thumb, когда слайдер пройдёт 80% компонента.
Для этого проанимируем значение от 0 до fullWidth-thumbWidth
. Анимацию запустим, когда поменяется стейт.
val progressStartPosition by animateIntAsState(
when (fastPaymentState.cartState) {
PAYING -> endOfTrackWidth
else -> 0
},
animationSpec = spring(stiffness = 500f)
)
Этот сдвиг влияет на ширину линии, тянущейся за слайдером. Нужно просто вычесть полученное значение. Передадим его в MeasurePolicy
и вычтем:
val progressWidth =
(offset.roundToInt() + thumbWidth - progressStartPosition)
.coerceIn(thumbWidth, occupiedWidth)
Получаем то, что хотели:

Анимирование надписей при вытягивании слайдера
Во время движения слайдера анимируются надписи Pull to pay и Paying. У надписи Pull To pay меняется прозрачность при движении слайдера, а текст Paying выезжает слева, когда происходит переход в стейт оплаты.

Измерим оба компонента в нашем Layout
:
val captionWidth = occupiedWidth - thumbWidth
val pullToPayPlaceable = measurables[FastPaymentLayoutId.PullToPay]!!
.measure(
looseConstraints.copy(
maxWidth = captionsWidth
)
)
val payingPlaceable = measurables[FastPaymentLayoutId.Paying]!!
.measure(
looseConstraints.copy(
maxWidth = captionsWidth
)
)
Оба текста имеют одинаковую ширину: ширина слайдера минус ширина Thumb. Чтобы расположить их, надпись Pull to pay мы ставим сразу после Thumb, а надпись Paying нужно сдвигать с анимацией.
Для анимации я использовал значение progressStartPosition
, которое мы ввели на прошлом шаге для растягивания прогресса. Берём его, и сдвигаем надпись Paying налево за пределы слайдера, чтобы получить эффект выпрыгивания:
layout() {
pullToPayPlaceable.placeRelative(thumbWidth, 0)
payingPlaceable.placeRelative(
x = progressStartPosition - captionWidth,
y = 0
)
}
Также проанимируем прозрачность для этих надписей. Как это сделать эффективно? Заведём класс, в котором будут лежать нужные стейты с анимацией:
class FastPaymentContentAlpha(
val pullToPayAlpha: State<Float>,
val payingAlpha: State<Float>,
val endContentAlpha: State<Float>,
val centerContentAlpha: State<Float>,
)
Инициализация анимации будет проходить через отдельный Composable метод. Передадим нужные стейты и проследим за изменением слайдера. Так мы сможем перенести весь код, отвечающий за анимации, в отдельное место. Выделим часто изменяющиеся значения в отдельный скоуп:
@Composable
internal fun updateContentAlphaTransitions(
fastPaymentState: FastPaymentState,
fastPaymentDraggableState: FastPaymentDraggableState,
): FastPaymentContentAlpha {
// Какие-то вычисления, которые нужны для анимаций
val pullToPayAlpha = animateFloatAsState(/* Some value */)
val payingAlpha = animateFloatAsState(/* Some value */)
val endContentAlpha = animateFloatAsState(/* Some value */)
val centerContentAlpha = animateFloatAsState(/* Some value */)
return remember(
fastPaymentState,
fastPaymentDraggableState,
) {
FastPaymentContentAlpha(
pullToPayAlpha = pullToPayAlpha,
payingAlpha = payingAlpha,
endContentAlpha = endContentAlpha,
centerContentAlpha = centerContentAlpha,
)
}
}
Этот подход я взял из великолепного блока статей Ozon про оптимизацию Compose. Его полную реализацию оставлю в исходниках.
Применим эти значения альфы на компоненты. В примере ниже я применяю прозрачность для PullToPay
через Modifier.alpha {}
PullToPay(
modifier = Modifier
.layoutId(FastPaymentLayoutId.PullToPay)
.alpha { containersAlpha.pullToPayAlpha.value },
)
Получаем проанимированные надписи в слайдере, которые реагируют на жест и на стейт:

Управление слайдером через стейт
Слайдер должен реагировать и на приходящий извне стейт оплаты и корзины — показать ошибку, индикатор загрузки, процесс оплаты.
Для того, чтобы сменить стейт, нужно проанимировать DraggableState
в конкретное положение. Сделаем анимацию через LaunchedEffect
с ключом fastPaymentState.cartState
.
// Эффект для обновления DraggableState на изменение стейта оплаты
LaunchedEffect(fastPaymentState.cartState) {
setPaymentState(fastPaymentState.cartState)
}
Внутри setPaymentState()
мы только делаем анимацию. draggableState
должен быть в конечной точке в статусе Paying, а в остальных статусах — сбрасываем его в начало.
suspend fun setPaymentState(cartState: CartState) {
// CartState бывает Idle, Paying, Error, Sync.
// Только в Paying слайдер должен быть «свайпнутым»
when (cartState) {
PAYING -> dragAnimatedTo(End)
else -> dragAnimatedTo(Start)
}
}
private suspend fun dragAnimatedTo(
anchor: SwipeButtonAnchor,
) = supervisorScope {
draggableState.animateTo(
targetValue = anchor,
animationSpec = spring(stiffness = 500f)
)
}
Шиммер
Слайдер оплаты украшен шиммером. Это переливающийся градиент с конкретной функцией — отображение некоего процесса, например, процесса оплаты.

Реализаций шиммеров в Compose много. Мы взяли шиммер из библиотеки Accompanist — сейчас её уже не существует. Шиммер инициализируется с помощью Modifier.placeholder()
:
import com.google.accompanist.placeholder.placeholder
val shimmerVisible = isShimmerVisible()
Box(
Modifier
.fillMaxSize()
.placeholder(
visible = shimmerVisible,
shape = CircleShape,
color = Color.Transparent,
highlight = PayingHighlight(DrinkitTheme.drinkitColors.textIcon10),
)
)
Анимация градиента настраивается в объекте PlaceholderHighLight.shimmer()
. В нём нам нужен animationSpec
для детальной настройки:
private val PayingHighlight: (Color) -> PlaceholderHighlight = { highlightColor ->
PlaceholderHighlight.shimmer(
highlightColor = highlightColor,
animationSpec = infiniteRepeatable<Float>(
animation = tween(
durationMillis = SHIMMER_PAYING_DURATION.toInt(MILLISECONDS),
delayMillis = 200
),
repeatMode = Restart
)
)
}
Показать или спрятать градиент — вычислим в методе isShimmerVisible()
. Сделаем так, чтобы шиммер включался, когда происходит оплата:
private fun isShimmerVisible(
fastPaymentState: FastPaymentState,
): Boolean {
return fastPaymentState.cartState == PAYING
}
Остаётся передать шиммер для слайдера в Layout {}
, измерить и расположить:

Кстати, шиммер можно добавить и в статичное состояние в качестве онбординг-подсказки. Для статичного состояния сделаем анимацию чуть медленнее:
private val IdleHighlight: (Color) -> PlaceholderHighlight = { highlightColor ->
PlaceholderHighlight.shimmer(
highlightColor = highlightColor,
animationSpec = infiniteRepeatable<Float>(
animation = tween(
durationMillis = SHIMMER_IDLE_DURATION.toInt(MILLISECONDS),
delayMillis = 200
),
repeatMode = Restart
)
)
}
Немного изменим метод isShimmerVisible()
, чтобы показывать шиммер в статичном состоянии — когда пользователь не тянет слайдер, и текущий стейт — Idle
private fun isShimmerVisible(
isDragging: Boolean,
fastPaymentState: FastPaymentState,
): Boolean {
val canShowShimmer = fastPaymentState.cartState == PAYING || fastPaymentState.idleHintVisible
return canShowShimmer && !isDragging
}
За статичную подсказку отвечает поле idleHintVisible
. Его значение меняется просто по интервалу:
while (true) {
delay(IDLE_HINT_DELAY)
paymentState = fastPaymentStateUpdated.copy(
idleHintVisible = !fastPaymentStateUpdated.idleHintVisible
)
}
Получаем подсказку в начальном положении, которая иногда «напоминает о себе» и подсвечивает, что с компонентом можно взаимодействовать:

Хаптик
И тут мы срезали углы. Да, хаптик важен как фидбэк на действия пользователя, но сильно запариваться над ним на Android мы не стали, хотя на iOS и сделали такой тактильно приятный хаптик, что даже статью про это написали. Рекомендую!
Но совсем без отдачи мы Android-пользователей не оставили. Я решил не встраивать хаптик в компонент, а положил его в виде отдельного сайд-эффекта.
@Composable
private fun FastPaymentVibrationHandler(
fastPaymentState: FastPaymentState,
fastPaymentDraggableState: FastPaymentDraggableState,
) {
// Effect to control vibration enabled or disabled
// Enable it when slider is right at the start
// Disable it, when the state is either not IDLE or when returning from end to start
LaunchedEffect(fastPaymentState, fastPaymentDraggableState) {
snapshotFlow { fastPaymentDraggableState.progress }
.collect { progress ->
handleVibrationEnabled(
progress = progress,
cartState = fastPaymentState.cartState,
targetValue = fastPaymentDraggableState.targetValue
)
}
}
// Effect to vibrate on progress change
LaunchedEffect(fastPaymentState, fastPaymentDraggableState) {
if (fastPaymentState.cartState != IDLE) return@LaunchedEffect
snapshotFlow { fastPaymentDraggableState.progress }
.collect(progressVibrator::changeProgress)
}
}
Первый сайд-эффект отвечает за включение и выключение вибрации через метод handleVibrationEnabled
. Тактильный отклик нужен только в случае, когда пользователь сам тянет слайдер.
Второй сайд-эффект отвечает за передачу прогресса в сервис вибрации. Внутри changeProgress
метод заставляет телефон вибрировать каждые 10% drag’а слайдера.
Рекомпозиции, перформанс
А что у слайдера по рекомпозициям? Я запустил приложение, покликал разные стейты, потаскал слайдер жестом:

Не стоит ожидать, что рекомпозиций не будет вообще. Пытаться искоренить их полностью тоже бессмысленно — иногда сделать это просто невозможно, так как контент меняется.
В нашем кейсе в первом столбце в Layout Inspector (ну или на девайсе) можно увидеть, как появляется новая рекомпозиция, когда происходит триггер «показать/спрятать pull to pay подсказку» и «спрятать/показать весь контент в слайдере».
Почему это происходит? Дело в том, что у нас меняется стейт и анимация запускается заново.
Но одновременно с этим во втором столбце, который отображает количество пропущенных рекомпозиций, счётчики увеличиваются. Это означает, что рекомпозиция из родительского скоупа не проходит дальше по дочерним компонентам. А это хорошо для производительности.
Но с шиммером всё не так хорошо — возникает много рекомпозиций. Однако так устроен его модифаер. Пока придётся с этим жить.
Ссылки
Репозиторий с исходным кодом компонента и песочницей для экспериментов.
В своём Telegram-канале я рассказал про недостатки MotionLayout, о которых упоминал в статье.
Пост из моего Telegram-канала про подход looseConstraints для измерения дочерних вьюшек в Layout.
Выводы
Написать кастомный компонент на Layout не так уж и сложно. Закидываете в него контент, измеряете, сколько места ему нужно, и размещаете в нужном порядке.
Используя Compose со стандартными инструментами анимации, мы контролируем весь компонент и можем в любое время изменять его. А благодаря декларативному UI мы ещё и существенно снижаем количество возможных ошибок.
Тут надо напомнить, что у компонента на MotionLayout этих ошибок было более 15. А исправить их было практически нереально из-за сложностей в отладке и ограничений самого MotionLayout.
А вы пробовали переписывать компоненты с MotionLayout на Compose? Или сразу писали на Compose? Делитесь своим опытом в комментариях!
Спасибо, что дочитали статью! Если вам интересно узнать про работу MotionLayout и другие тонкости Android-разработки, подписывайтесь на мой Telegram-канал «Android в тесте и маленький капучино».
О том, как мы развиваем IT в Додо в целом, читайте в Telegram-канале Dodo Engineering. Там мы рассказываем о жизни нашей команды, культуре и последних разработках.