Помните компонент MotionLayout? С его помощью можно просто реализовывать сложные анимации, в том числе и основанные на жестах.

У нас в Дринките был компонент, сделанный на MotionLayout — слайдер быстрой оплаты в меню. Он появляется, когда пользователь добавляет продукты в корзину.

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

Но шло время, баги копились, поддерживать элемент становилось всё сложнее, да и слайдер нуждался в новом функционале:

  1. В команде захотели, чтобы слайдер был спрятан, когда пользователь не авторизован или выбранная кофейня закрыта.

  2. Для новой фичи «Подарок другу» нужно было заменить фон слайдера. Он должен был переливаться.

Эти две фичи было сложно реализовать, используя 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, но тут терпение лопнуло — имеющиеся проблемы стали хуже всех возможных рисков. Делаем!

Как было и что делаем

Наш слайдер оплаты поделён на три элемента: тянущийся слайдер слева, цена и время ожидания в центре, а справа — содержимое корзины.

Слайдер в статичном состоянии
Слайдер в статичном состоянии

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

Стейт ошибки из Figma
Стейт ошибки из Figma

В идеале слайдер работает так:

  1. Пользователь зажимает слайдер, а мы прячем контент с ценой и подсказываем: pull to pay.

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

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

Пример взаимодействия со слайдером. Оплачиваем корзину
Пример взаимодействия со слайдером. Оплачиваем корзину

Посмотрим, что сделали до нас

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

Примеры слайдера из статьи «Контура». Первое — Compound, а второе — Custom Layout
Примеры слайдера из статьи «Контура». Первое — Compound, а второе — Custom Layout

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

Спойлер: получился виджет, которому потребовался отдельный Sample App для проверки всех статусов.

Реализация

Подход с Compound Composable

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

Иерархия в LayoutInspector для Compound Composable
Иерархия в LayoutInspector для Compound Composable
И у каждого такого Content есть кастомный лейаутинг
И у каждого такого Content есть кастомный лейаутинг

Мы используем стандартные контейнеры, а потому для сложного компонента появились кастомные Modifier.layout {}, разбросанные по разным местам. Так почему бы не сделать слайдер сразу через свой Layout?

Что стоит запомнить?

  1. Если вы пишите Composable со множеством связей по размерам и позиционированию элементов, сразу используйте Layout.

  2. Используйте плоский Layout, если у компонента большая вложенность — тогда лишних групп в Slot Table не будет.

  3. Большую структуру сложнее дебажить и оптимизировать.

Делаем слайдер через Layout

А теперь переходим ко вкусному. Разберём подробнее, как сделан слайдер на Layout.

Требования к дизайну и поведению

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

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

  2. Слайдер тянется жестом, для которого есть порог «успешности»: если он прошёл 80% компонента, то до предела «доезжает» сам, а если нет — возвращается в idle состояние.

  3. Текущий стейт слайдера — idle, paying, syncing — может прийти извне в независимости от жеста. Тогда нам придётся изменить стейт слайдера на требуемый.

  4. В слайдере должна отображаться следующая информация:

    • 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 рисуется — не очень важно. Визуализация может быть любой в зависимости от ваших задач. Я не буду останавливаться тут надолго, а просто покажу, из каких компонентов состоит наш слайдер:

Все варианты того, как может выглядеть слайдер
Все варианты того, как может выглядеть слайдер

Правила измерения таковы:

  1. Ширина Thumb — неизменна. Если его нет, в измерениях его не учитываем.

  2. Набор продуктов. Есть всегда. Для конкретного количества продуктов его ширина неизменна.

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

Поместим контент в структуру 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 {}. Сделать это надо в порядке их видимости:

  1. Сначала рисуем центральный контент с ценой, чтобы Thumb перекрывал его при сдвиге.

  2. После него располагаем список продуктов, чтобы Thumb также перекрывал при быстром сдвиге, если анимация была очень быстрая.

  3. Поверх всего рисуем 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 за ним шла белая полоса. Она схлопнется в конечную точку, когда начнётся оплата:

Притягивание прогресса вслед за Thumb в разных состояниях
Притягивание прогресса вслед за 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)

Получаем то, что хотели:

Прогресс следует за Thumb и магнитится к началу, когда начинается оплата
Прогресс следует за Thumb и магнитится к началу, когда начинается оплата

Анимирование надписей при вытягивании слайдера

Во время движения слайдера анимируются надписи 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’а слайдера.

Рекомпозиции, перформанс

А что у слайдера по рекомпозициям? Я запустил приложение, покликал разные стейты, потаскал слайдер жестом:

 Что показывает LayoutInspector в Android Studio при таскании слайдера
Что показывает LayoutInspector в Android Studio при таскании слайдера

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

В нашем кейсе в первом столбце в Layout Inspector (ну или на девайсе) можно увидеть, как появляется новая рекомпозиция, когда происходит триггер «показать/спрятать pull to pay подсказку» и «спрятать/показать весь контент в слайдере».

Почему это происходит? Дело в том, что у нас меняется стейт и анимация запускается заново.

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

Но с шиммером всё не так хорошо — возникает много рекомпозиций. Однако так устроен его модифаер. Пока придётся с этим жить.

Ссылки

  1. Репозиторий с исходным кодом компонента и песочницей для экспериментов.

  2. В своём Telegram-канале я рассказал про недостатки MotionLayout, о которых упоминал в статье.

  3. Пост из моего Telegram-канала про подход looseConstraints для измерения дочерних вьюшек в Layout.

Выводы

Написать кастомный компонент на Layout не так уж и сложно. Закидываете в него контент, измеряете, сколько места ему нужно, и размещаете в нужном порядке.

Используя Compose со стандартными инструментами анимации, мы контролируем весь компонент и можем в любое время изменять его. А благодаря декларативному UI мы ещё и существенно снижаем количество возможных ошибок.

Тут надо напомнить, что у компонента на MotionLayout этих ошибок было более 15. А исправить их было практически нереально из-за сложностей в отладке и ограничений самого MotionLayout.

А вы пробовали переписывать компоненты с MotionLayout на Compose? Или сразу писали на Compose? Делитесь своим опытом в комментариях!


Спасибо, что дочитали статью! Если вам интересно узнать про работу MotionLayout и другие тонкости Android-разработки, подписывайтесь на мой Telegram-канал «Android в тесте и маленький капучино».

О том, как мы развиваем IT в Додо в целом, читайте в Telegram-канале Dodo Engineering. Там мы рассказываем о жизни нашей команды, культуре и последних разработках.

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