Jetpack Compose: реализация меню Apple Watch
Мне очень нравится меню Apple watch: плавность анимации, поведение иконок при перемещении, расположение элементов по необычной сетке. Я захотел повторить это меню на Android. Но делать это на старом подходе с помощью ViewGroup или кастомного Layout Manager для RecyclerView не очень хотелось: слишком уж затратно для работы «в стол».
С появлением Compose идея стала более привлекательной и интересной для реализации. Большой плюс при работе с Сompose — разработчик сосредоточен на бизнес-логике задачи. Ему не нужно искать в недрах исходников и документации ViewGroup информацию, где лучше расположить логику: в onMeasure или в onLayout, и надо ли переопределять метод onInterceptTouchEvent.
Давайте вместе разберёмся, как создать собственный ViewGroup на Jetpack Compose.
Что нужно для создания такого 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