Pull to refresh
0
Surf
Создаём веб- и мобильные приложения

Jetpack Compose: реализация меню Apple Watch

Reading time11 min
Views3.1K

Мне очень нравится меню Apple watch: плавность анимации, поведение иконок при перемещении, расположение элементов по необычной сетке. Я захотел повторить это меню на Android. Но делать это на старом подходе с помощью ViewGroup или кастомного Layout Manager для RecyclerView не очень хотелось: слишком уж затратно для работы «в стол». 

С появлением Compose идея стала более привлекательной и интересной для реализации. Большой плюс при работе с Сompose — разработчик сосредоточен на бизнес-логике задачи. Ему не нужно искать в недрах исходников и документации ViewGroup информацию, где лучше расположить логику: в onMeasure или в onLayout, и надо ли переопределять метод onInterceptTouchEvent.

Давайте вместе разберёмся, как создать собственный ViewGroup на Jetpack Compose. 

Что нужно для создания такого Layout: 

  1. Создать контейнер для отображения сетки элементов.

  2. Обработать drag-жест для правильного смещения контента.

  3. Реализовать OverScroll и анимацию для него.

  4. Реализовать Scale-анимацию, близкую к меню Apple watch.

  5. Сделать механизм, чтобы Layout умел переживать поворот экрана.

Шаг первый: создадим контейнер и разместим в нём элементы по сетке 

Для создания кастомных контейнеров в Compose используется Layout, лежащий в основе всех контейнеров в Jetpack Compose. Если провести аналогию, то Layout — это ViewGroup из привычной нам Android view-системы.

Напишем базовую Composable-функцию:

//1
@Composable
fun WatchGridLayout(
   modifier: Modifier = Modifier,
   rowItemsCount: Int,
   itemSize: Dp,
   content: @Composable () -> Unit,
) {


   //2
   check(rowItemsCount > 0) { "rowItemsCount must be positive" }
   check(itemSize > 0.dp) { "itemSize must be positive" }

   val itemSizePx = with(LocalDensity.current) { itemSize.roundToPx() }
   val itemConstraints = Constraints.fixed(width = itemSizePx, height = itemSizePx)


   //3
   Layout(
       modifier = modifier.clipToBounds(),
       content = content
   ) { measurables, layoutConstraints ->


       //4
       val placeables = measurables.map { measurable -> measurable.measure(itemConstraints) }
       //5
       val cells = placeables.mapIndexed { index, _ ->
           val x = index % rowItemsCount
           val y = (index - x) / rowItemsCount
           Cell(x, y)
       }


       //6
       layout(layoutConstraints.maxWidth, layoutConstraints.maxHeight) {
           placeables.forEachIndexed { index, placeable ->
               placeable.place(
                   x = cells[index].x * itemSizePx,
                   y = cells[index].y * itemSizePx
               )
           }
       }
   }
}
  1. Напишем функцию, пометим её @Composable-аннотацией и определим необходимые параметры.

    • modifier — один из важнейших атрибутов вёрстки на Compose. Нужен для определения размера контейнера, фона и так далее.

    • rowItemsCount — количество элементов в ряду сетки.

    • itemSize — размер элемента. Каждый элемент будет иметь одинаковую ширину и высоту.

    • content — composable-лямбда, которая будет предоставлять элементы для отображения.

  2. Сделаем пару проверок, чтобы в контейнере использовались только валидные значения. Также для дальнейшей работы надо перевести itemSize в пиксели. А значение в пикселях перевести в Constraints — объект для передачи желаемых размеров в контейнер. Мы точно знаем, какого размера будет каждый элемент, поэтому будем использовать Constraints.fixed(...)

  3. Переходим к важной части: Layout. Он принимает три ключевых параметра:

    • modifier — в него передадим modifier, который принимает как параметр WatchGridLayout. К нему необходимо добавить ещё clipToBounds(). Это важно, если контейнер будет использоваться внутри другого контейнера, — например Box. Тогда элементы контейнера будут рендериться за его пределами.

    • content — передаём сюда параметр, который передали в WatchGridLayout.

    • measurePolicy — интерфейс, который отвечает за размещение элементов в контейнере. В нашем случаем реализуем его как лямбду. 

    Лямбда measurePolicy предоставляет два параметра: measurables и layoutConstraints. Первый — элементы контейнера, второй — параметры контейнера: нам из него понадобится ширина и высота.

  4. Для работы с measurables надо перевести их в placeables: «измеряемые» — в «размещаемые», как бы странно это ни звучало. Для этого понадобится itemConstraints.

  5. Для каждого элемента контейнера необходимо посчитать x и y координаты. На входе получаем одномерный массив элементов [0, 1, 2, … N-1]. Для сетки необходим двумерный массив: он должен выглядеть так: [[0,0];[0,1];[0,2]; … [N-1, N-1]]. Для этого каждый index переведём в объект Cell, который будет содержать x и y для каждого элемента.

  6. Теперь есть всё, чтобы отобразить элементы правильно. В layout необходимо передать ширину и высоту из layoutConstraints. Проходим циклом по списку placeables и для каждого элемента вызываем метод place. В него передаём x и y из массива cells, предварително домножив на itemSizePx.

    Есть ещё несколько методов place*. Один из них нам пригодится дальше, а для базового понимания хватит этого.

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

Далее реализуем State, который будет отвечать за расположение элементов, scale, overscroll, анимацию и сохранение состояния при повороте экрана. А также конфиг, который будет содержать все необходимые константы.

Сначала разберёмся, как работает scale элементов. Если обратить внимание на движение элементов при скролле, видно, что элементы двигаются по сферической траектории.

Однако элементы уменьшаются быстрее, когда приближаются к краю контейнера: можно сделать вывод, что это не совсем сферическая траектория, а эллиптическая. Тут понадобится формула эллиптического параболоида. Разберёмся, как её применить.

Формула выглядит так:

Но в таком виде она нам не поможет: параболоид надо перевернуть. Для этого необходимо сделать несложные преобразования:

z — это величина scale. Чтобы понять, как это поможет в вычислении scale, надо построить преобразованный график. Для этого можно использовать, например, утилиту Grapher, которая идёт вместе с macOs.

Шаг второй: Создадим WatchGridConfig и пропишем все необходимые параметры

//1
class WatchGridConfig(
   val itemSizePx: Int = 0,
   val layoutHeightPx: Int = 0,
   val layoutWidthPx: Int = 0,
   val cells: List<Cell> = emptyList()
) {
   //2
   val a = 3f * layoutWidthPx
   val b = 3f * layoutHeightPx
   val c = 20.0f
   //3
   val layoutCenter = IntOffset(
       x = layoutWidthPx / 2,
       y = layoutHeightPx / 2
   )
   val halfItemSizePx = itemSizePx / 2

   //4
   val contentHeight = 
       ((cells.maxByOrNull { it.y }?.y?.let { y -> y + 1 }) ?: 0).times(itemSizePx)
   val contentWidth = 
       ((cells.maxByOrNull { it.x }?.x?.let { x -> x + 1 }) ?: 0).times(itemSizePx)
   //5
   val maxOffsetHorizontal = contentWidth - layoutWidthPx
   val maxOffsetVertical = contentHeight - layoutHeightPx
   //6
   val overScrollDragDistanceHorizontal = layoutWidthPx - itemSizePx
   val overScrollDragDistanceVertical = layoutHeightPx - itemSizePx
   //7
   val overScrollDistanceHorizontal = layoutWidthPx / 2 - halfItemSizePx
   val overScrollDistanceVertical = layoutHeightPx / 2 - halfItemSizePx
   //8
   val overScrollDragRangeVertical =
       (-maxOffsetVertical.toFloat() - overScrollDragDistanceVertical)
           .rangeTo(overScrollDragDistanceVertical.toFloat())
   val overScrollDragRangeHorizontal =
       (-maxOffsetHorizontal.toFloat() - overScrollDragDistanceHorizontal)
           .rangeTo(overScrollDragDistanceHorizontal.toFloat())

   val overScrollRangeVertical =
       (-maxOffsetVertical.toFloat() - overScrollDistanceVertical)
           .rangeTo(overScrollDistanceVertical.toFloat())
   val overScrollRangeHorizontal =
       (-maxOffsetHorizontal.toFloat() - overScrollDistanceHorizontal)
           .rangeTo(overScrollDistanceHorizontal.toFloat())
}

  1. Создадим класс конфига и пропишем в конструкторе все параметры, которые вычислили при создании WatchGridLayout.

  2. a, b, c — параметры, необходимые для вычисления scale.

  3. Координаты центра контейнера и половина размера элемента.

  4. Вычисляем ширину и высоту контента. Находим максимальные x и y в массиве cells и умножаем на размер элемента.

  5. Параметры для скролла: должны быть такие, чтобы можно было полностью проскроллить контент.

  1. Параметры для оверскролла. Величины, на которые можно перемещать контент. 

  1. Параметры для оверскролла. Используются для анимации bounce-эффекта, как на Apple watch.

Шаг третий. Перейдём к реализации State 

В State будет реализована функциональность, отвечающая за: 

  • вычисление координат элементов, 

  • хранение текущего смещения контента, 

  • анимацию скролла, оверскролла и fling-анимации. 

Определим его интерфейс и реализацию по умолчанию.

interface WatchGridState {

   val currentOffset: Offset
   val animatable: Animatable<Offset, AnimationVector2D>
   var config: WatchGridConfig

   suspend fun snapTo(offset: Offset)
   suspend fun animateTo(offset: Offset, velocity: Offset)
   suspend fun stop()

   fun getPositionFor(index: Int): IntOffset
   fun getScaleFor(position: IntOffset): Float
   fun setup(config: WatchGridConfig) {
       this.config = config
   }
}
  1. snapTo — перемещает контент к заданному отступу.

  2. animateTo — перемещает контент с анимацией к заданному отступу.

  3. stop — останавливает текущую анимацию контента.

  4. getPositionFor — вычисляет позицию элемента по его индексу.

  5. getScaleFor — вычисляет scale элемента по его позиции.

  6. setup — инициализирует конфиг.

Рассмотрим реализацию подробно.

animatable — содержит текущий отступ контента, отвечает за его перемещение и анимацию.

override val animatable = Animatable(
   initialValue = initialOffset,
   typeConverter = Offset.VectorConverter
)

В snapTo необходимо предварительно ограничить x и y параметрами из конфига, а потом передать их в animatable. snapTo будет вызываться в момент перемещения пальца по Layout.

override suspend fun snapTo(offset: Offset) {
   val x = offset.x.coerceIn(config.overScrollDragRangeHorizontal)
   val y = offset.y.coerceIn(config.overScrollDragRangeVertical)
   animatable.snapTo(Offset(x, y))
}

Логика animateTo аналогична snapTo. Метод вызывается в момент, когда палец будет поднят, чтобы запустить анимацию оверскролла или fling.

private val decayAnimationSpec = SpringSpec<Offset>(
   dampingRatio = Spring.DampingRatioLowBouncy,
   stiffness = Spring.StiffnessLow,
)

override suspend fun animateTo(offset: Offset, velocity: Offset) {
   val x = offset.x.coerceIn(config.overScrollRangeHorizontal)
   val y = offset.y.coerceIn(config.overScrollRangeVertical)
   animatable.animateTo(
       initialVelocity = velocity,
       animationSpec = decayAnimationSpec,
       targetValue = Offset(x, y)
   )
}

Логика getPositionFor уже знакома. Часть сделали, когда писали базовую реализацию WatchGridLayout, только теперь все параметры содержатся в WatchGridConfig. Необходимо умножить координаты x и y этого элемента на размер элемента и добавить текущий отступ контента. И не забыть про дополнительный отступ для каждого второго ряда.

override fun getPositionFor(index: Int): IntOffset {
   val (offsetX, offsetY) = currentOffset
   val (cellX, cellY) = config.cells[index]
   val rowOffset = if (cellY % 2 != 0) {
       config.halfItemSizePx
   } else {
       0
   }
   val x = (cellX * config.itemSizePx) + offsetX.toInt() + rowOffset
   val y = (cellY * config.itemSizePx) + offsetY.toInt()

   return IntOffset(x, y)
}

В getScaleFor содержится вся логика вычисления scale элемента по графику функции эллиптического параболоида. Пара нюансов: scale надо считать для центра элемента и относительно центра Layout, а не его точки [0, 0]. 

Итоговый результат стоит ограничить, чтобы не получить отрицательные значения и значения больше 1.0. Я сделал, чтобы scale изменялся от 0.5 до 1.0. Обратите внимание: в конце добавил 1.1, а не 1, как в формуле выше. На мой взгляд, так работает визуально лучше.

override fun getScaleFor(position: IntOffset): Float {
   val (centerX, centerY) = position.plus(
       IntOffset(
           config.halfItemSizePx,
           config.halfItemSizePx
       )
   )
   val offsetX = centerX - config.layoutCenter.x
   val offsetY = centerY - config.layoutCenter.y
   val x = (offsetX * offsetX) / (config.a * config.a)
   val y = (offsetY * offsetY) / (config.b * config.b)
   val z = (-config.c * (x + y) + 1.1f)
       .coerceIn(minimumValue = 0.5f, maximumValue = 1f)
   return z
}

С логикой state всё. Осталось самое маленькое, но не менее важное: научить Layout переживать поворот экрана. Для этого внутри WatchGridStateImpl создадим companion object и напишем реализацию для Saver. Всё, что надо сохранять, — это текущий отступ контента и потом передавать его как параметр initialOffset в конструктор WatchGridStateImpl

Saver поддерживает только сериализуемые объекты. Offset таковым не является, поэтому приходится сохранять его параметры x и y отдельно.

companion object {
   val Saver = Saver<WatchGridStateImpl, List<Float>>(
       save = {
           val (x, y) = it.currentOffset
           listOf(x, y)
       },
       restore = {
           WatchGridStateImpl(initialOffset = Offset(it[0], it[1]))
       }
   )
}

Обернём Saver в remember-обёртку. Всё, можно пользоваться.

@Composable
fun rememberWatchGridState(): WatchGridState {
   return rememberSaveable(saver = WatchGridStateImpl.Saver) {
       WatchGridStateImpl()
   }
}

Шаг четвертый. Подключим реализованный state в Layout

Пропишем state в параметры.

@Composable
fun WatchGridLayout(
   modifier: Modifier = Modifier,
   rowItemsCount: Int,
   itemSize: Dp,
   state: WatchGridState = rememberWatchGridState(),
   content: @Composable () -> Unit,
) {// . . .}

Перед вызовом layout(...) передадим конфиг в state.

state.setup(
   WatchGridConfig(
       layoutWidthPx = layoutConstraints.maxWidth,
       layoutHeightPx = layoutConstraints.maxHeight,
       itemSizePx = itemSizePx,
       cells = cells
   )
)

layout(layoutConstraints.maxWidth, layoutConstraints.maxHeight) {...}

А внутри layout(...) заменим старую логику на вызовы методов из state. Воспользуемся методом placeWithLayer для размещения элементов.

layout(layoutConstraints.maxWidth, layoutConstraints.maxHeight) {
   placeables.forEachIndexed { index, placeable ->

       val position = state.getPositionFor(index)
       val scale = state.getScaleFor(position)
       placeable.placeWithLayer(
           position = position,
           layerBlock = {
               this.scaleX = scale
               this.scaleY = scale
           }
       )
   }
}

Получаем сетку с правильным сдвигом рядов и уже рассчитанным scale элементов. 

Осталось самое интересное: обработать drag-жест, чтобы двигать контент внутри layout. 

Сompose под капотом содержит много coroutine-кода, и нас корутины тоже не обойдут стороной. Но оно и к лучшему: проще работы с жестами в Android я ещё не встречал.

Обработка drag-жеста будет производиться через кастомный Modifier. Чтобы не засорять код WatchGridLayout, создадим класс WatchGridKtx и в нём напишем реализацию.

//1
fun Modifier.drag(state: WatchGridState) = pointerInput(Unit) {
   //2
   val decay = splineBasedDecay<Offset>(this)
   val tracker = VelocityTracker()
   //3
   coroutineScope {
       //4
       forEachGesture {
           //5
           awaitPointerEventScope {
               //6
               val pointerId = awaitFirstDown(requireUnconsumed = false).id
               //7
               launch {
                   state.stop()
               }
               tracker.resetTracking()
               //8
               var dragPointerInput: PointerInputChange?
               var overSlop = Offset.Zero
               do {
                   dragPointerInput = awaitTouchSlopOrCancellation(
                       pointerId
                   ) { change, over ->
                       change.consumePositionChange()
                       overSlop = over
                   }
               } while (
                 dragPointerInput != null && !dragPointerInput.positionChangeConsumed()
                       )
               //9
               dragPointerInput?.let {

                   launch {
                       state.snapTo(state.currentOffset.plus(overSlop))
                   }
                   drag(dragPointerInput.id) { change ->
                       val dragAmount = change.positionChange()
                       launch {
                           state.snapTo(state.currentOffset.plus(dragAmount))
                       }
                       change.consumePositionChange()
                       tracker.addPointerInputChange(change)
                   }
               }
           }
           //10
           val (velX, velY) = tracker.calculateVelocity()
           val velocity = Offset(velX, velY)
           val targetOffset = decay.calculateTargetValue(
               typeConverter = Offset.VectorConverter,
               initialValue = state.currentOffset,
               initialVelocity = velocity
           )
           launch {
               state.animateTo(
                   offset = targetOffset,
                   velocity = velocity,
               )
           }
       }
   }
}
  1. Создадим расширение Modifier.drag с параметром state: WatchGridState.

  2. Параметры decay и tracker понадобятся для вычисления параметра velocity, который необходим для метода animateTo.

  3. Поскольку необходимо будет работать с suspend-функциями, понадобится coroutineScope.

  4. Работа с жестами начинается с блока forEachGesture. Логика этого блока проста: после поднятия последнего пальца он запускает код внутри себя заново. 

  5. Блок awaitPointerEventScope нужен непосредственно для обработки жестов.

  6. Ожидаем, когда произойдет касание.

  7. Останавливаем текущую анимацию и прекращаем отслеживание у velocity tracker.

  8. В цикле do…while.. необходимо убедиться, что произошел drag-жест: это нужно, чтобы различать типы жестов. Например, если элемент Layout сделать clickable, он будет перекрывать drag-жест. Поэтому, перед тем как отслеживать перемещение пальца, необходимо понять, что это точно drag, а не случайное нажатие или тап по элементу Layout.

  9. Теперь, когда мы точно знаем, что опущенный палец перемещается по экрану непрерывно, можно обработать его как drag-жест. Передаем id пальца в специальный метод drag, который будет через callback передавать наружу изменение положение пальца. Его передадим в метод snapTo, и контент начнёт перемещаться по Layout. Также не забываем передавать это изменение в tracker, чтобы посчитать velocity.

  10. Как только палец поднят с области Layout, блок awaitPointerEventScope прекращает работу. Происходит вычисление параметров для работы анимации. Все вычисленные параметры, соответственно, передаются в метод animateTo. Если при быстром скролле контента вы поднимете палец, увидите fling-анимацию. Если будете скролить контент до края экрана, увидите debounce-эффект, и контент отскроллится до середины Layout — прямо как на Apple watch.

На этом всё. Исходники можно посмотреть на Github. Удачи в написании своих кастомных Layout ;) 

Больше полезного про Android — в нашем телеграм-канале Surf Android Team. Здесь мы публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!

Больше кейсов команды Surf ищите на сайте >>

Tags:
Hubs:
Total votes 11: ↑10 and ↓1+9
Comments4

Articles

Information

Website
surf.ru
Registered
Founded
Employees
201–500 employees
Location
Россия