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

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

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

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

53ccf5b98c91f69d1718fb9ccd8bd1c9.gif

Что нужно для создания такого 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. Тогда элементы контейнера будут рендериться за его пределами.

    6a2b33dccc32b002f38ba4622f4d6f0e.png
    • 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

© Habrahabr.ru