Мне очень нравится меню Apple watch: плавность анимации, поведение иконок при перемещении, расположение элементов по необычной сетке. Я захотел повторить это меню на Android. Но делать это на старом подходе с помощью ViewGroup или кастомного Layout Manager для RecyclerView не очень хотелось: слишком уж затратно для работы «в стол».
С появлением Compose идея стала более привлекательной и интересной для реализации. Большой плюс при работе с Сompose — разработчик сосредоточен на бизнес-логике задачи. Ему не нужно искать в недрах исходников и документации ViewGroup информацию, где лучше расположить логику: в onMeasure или в onLayout, и надо ли переопределять метод onInterceptTouchEvent.
Давайте вместе разберёмся, как создать собственный ViewGroup на Jetpack Compose.
![](https://habrastorage.org/getpro/habr/upload_files/53c/cf5/b98/53ccf5b98c91f69d1718fb9ccd8bd1c9.gif)
Что нужно для создания такого Layout:
Создать контейнер для отображения сетки элементов.
Обработать drag-жест для правильного смещения контента.
Реализовать OverScroll и анимацию для него.
Реализовать Scale-анимацию, близкую к меню Apple watch.
Сделать механизм, чтобы 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
)
}
}
}
}
-
Напишем функцию, пометим её @Composable-аннотацией и определим необходимые параметры.
modifier — один из важнейших атрибутов вёрстки на Compose. Нужен для определения размера контейнера, фона и так далее.
rowItemsCount — количество элементов в ряду сетки.
itemSize — размер элемента. Каждый элемент будет иметь одинаковую ширину и высоту.
content — composable-лямбда, которая будет предоставлять элементы для отображения.
Сделаем пару проверок, чтобы в контейнере использовались только валидные значения. Также для дальнейшей работы надо перевести itemSize в пиксели. А значение в пикселях перевести в Constraints — объект для передачи желаемых размеров в контейнер. Мы точно знаем, какого размера будет каждый элемент, поэтому будем использовать Constraints.fixed(...)
-
Переходим к важной части: Layout. Он принимает три ключевых параметра:
modifier — в него передадим modifier, который принимает как параметр WatchGridLayout. К нему необходимо добавить ещё clipToBounds(). Это важно, если контейнер будет использоваться внутри другого контейнера, — например Box. Тогда элементы контейнера будут рендериться за его пределами.
content — передаём сюда параметр, который передали в WatchGridLayout.
measurePolicy — интерфейс, который отвечает за размещение элементов в контейнере. В нашем случаем реализуем его как лямбду.
Лямбда measurePolicy предоставляет два параметра: measurables и layoutConstraints. Первый — элементы контейнера, второй — параметры контейнера: нам из него понадобится ширина и высота.
Для работы с measurables надо перевести их в placeables: «измеряемые» — в «размещаемые», как бы странно это ни звучало. Для этого понадобится itemConstraints.
Для каждого элемента контейнера необходимо посчитать x и y координаты. На входе получаем одномерный массив элементов [0, 1, 2, … N-1]. Для сетки необходим двумерный массив: он должен выглядеть так: [[0,0];[0,1];[0,2]; … [N-1, N-1]]. Для этого каждый index переведём в объект Cell, который будет содержать x и y для каждого элемента.
-
Теперь есть всё, чтобы отобразить элементы правильно. В layout необходимо передать ширину и высоту из layoutConstraints. Проходим циклом по списку placeables и для каждого элемента вызываем метод place. В него передаём x и y из массива cells, предварително домножив на itemSizePx.
Есть ещё несколько методов place*. Один из них нам пригодится дальше, а для базового понимания хватит этого.
В итоге получаем двумерную сетку из элементов. Два десятка строк, и уже можем отобразить элементы в кастомном контейнере: неплохо, Compose ????
![](https://habrastorage.org/getpro/habr/upload_files/b6e/650/e48/b6e650e482ef0f3c371d6b34f615de79.png)
Далее реализуем State, который будет отвечать за расположение элементов, scale, overscroll, анимацию и сохранение состояния при повороте экрана. А также конфиг, который будет содержать все необходимые константы.
Сначала разберёмся, как работает scale элементов. Если обратить внимание на движение элементов при скролле, видно, что элементы двигаются по сферической траектории.
![](https://habrastorage.org/getpro/habr/upload_files/3f9/7e2/855/3f97e28551550fb51c24da06737d3188.gif)
Однако элементы уменьшаются быстрее, когда приближаются к краю контейнера: можно сделать вывод, что это не совсем сферическая траектория, а эллиптическая. Тут понадобится формула эллиптического параболоида. Разберёмся, как её применить.
Формула выглядит так:
![](https://habrastorage.org/getpro/habr/upload_files/154/4cc/0e0/1544cc0e07c20b1788e592ad9f0723a6.png)
Но в таком виде она нам не поможет: параболоид надо перевернуть. Для этого необходимо сделать несложные преобразования:
![](https://habrastorage.org/getpro/habr/upload_files/fba/acb/b6d/fbaacbb6d0fdfe9d3d7ac09374eb000b.png)
z — это величина scale. Чтобы понять, как это поможет в вычислении scale, надо построить преобразованный график. Для этого можно использовать, например, утилиту Grapher, которая идёт вместе с macOs.
![](https://habrastorage.org/getpro/habr/upload_files/3a5/5c4/357/3a55c43572b1b85f3a1fc09633c1c4cb.png)
Шаг второй: Создадим 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())
}
Создадим класс конфига и пропишем в конструкторе все параметры, которые вычислили при создании WatchGridLayout.
a, b, c — параметры, необходимые для вычисления scale.
Координаты центра контейнера и половина размера элемента.
Вычисляем ширину и высоту контента. Находим максимальные x и y в массиве cells и умножаем на размер элемента.
Параметры для скролла: должны быть такие, чтобы можно было полностью проскроллить контент.
![](https://habrastorage.org/getpro/habr/upload_files/663/04c/3f5/66304c3f5c5357543c34c2e3f8ec18da.gif)
Параметры для оверскролла. Величины, на которые можно перемещать контент.
![](https://habrastorage.org/getpro/habr/upload_files/c78/8d4/a01/c788d4a014bad16a6ec146ba943d5c6e.gif)
Параметры для оверскролла. Используются для анимации bounce-эффекта, как на Apple watch.
![](https://habrastorage.org/getpro/habr/upload_files/acb/52a/2db/acb52a2db4809fb1f834a74073d9923e.gif)
Шаг третий. Перейдём к реализации 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
}
}
snapTo — перемещает контент к заданному отступу.
animateTo — перемещает контент с анимацией к заданному отступу.
stop — останавливает текущую анимацию контента.
getPositionFor — вычисляет позицию элемента по его индексу.
getScaleFor — вычисляет scale элемента по его позиции.
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 для размещения элементов.
Получаем сетку с правильным сдвигом рядов и уже рассчитанным scale элементов.
![](https://habrastorage.org/getpro/habr/upload_files/1c0/a58/320/1c0a58320fc1a863ce534eb202789c6c.png)
Осталось самое интересное: обработать 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,
)
}
}
}
}
Создадим расширение Modifier.drag с параметром state: WatchGridState.
Параметры decay и tracker понадобятся для вычисления параметра velocity, который необходим для метода animateTo.
Поскольку необходимо будет работать с suspend-функциями, понадобится coroutineScope.
Работа с жестами начинается с блока forEachGesture. Логика этого блока проста: после поднятия последнего пальца он запускает код внутри себя заново.
Блок awaitPointerEventScope нужен непосредственно для обработки жестов.
Ожидаем, когда произойдет касание.
Останавливаем текущую анимацию и прекращаем отслеживание у velocity tracker.
В цикле do…while.. необходимо убедиться, что произошел drag-жест: это нужно, чтобы различать типы жестов. Например, если элемент Layout сделать clickable, он будет перекрывать drag-жест. Поэтому, перед тем как отслеживать перемещение пальца, необходимо понять, что это точно drag, а не случайное нажатие или тап по элементу Layout.
Теперь, когда мы точно знаем, что опущенный палец перемещается по экрану непрерывно, можно обработать его как drag-жест. Передаем id пальца в специальный метод drag, который будет через callback передавать наружу изменение положение пальца. Его передадим в метод snapTo, и контент начнёт перемещаться по Layout. Также не забываем передавать это изменение в tracker, чтобы посчитать velocity.
Как только палец поднят с области Layout, блок awaitPointerEventScope прекращает работу. Происходит вычисление параметров для работы анимации. Все вычисленные параметры, соответственно, передаются в метод animateTo. Если при быстром скролле контента вы поднимете палец, увидите fling-анимацию. Если будете скролить контент до края экрана, увидите debounce-эффект, и контент отскроллится до середины Layout — прямо как на Apple watch.
На этом всё. Исходники можно посмотреть на Github. Удачи в написании своих кастомных Layout ;)
debug45
Получилось как-то не особо похоже на оригинал
ozh-dev Автор
Ничего страшного.
Это делалось не продакшена ради, а учебных целей для.