Приложение Libby обладает отличным UI/UX. Один из компонентов, который мне особенно нравится, — то, как в нём раскладываются книги. Такой способ отображения используется во всех их приложениях: на Android и iOS, в версии для iPad и даже на сайте. Я понятия не имею, как именно они это реализовали, но раскладка при этом интерактивная. Можно нажать на любую книгу и перейти на экран с информацией о ней. Было бы интересно воссоздать такой эффект в Jetpack Compose.
Первый подход
Первым делом я попробовал самый простой вариант — жёстко заданную реализацию. Количество книг, ширина и размеры книг были фиксированными. Я сделал собственную сетку с помощью LazyRow и LazyColumn и управлял положением каждой книги модификаторами вроде offset и rotate.
Это работало, но решение не было универсальным и масштабируемым, и я уверен, что оно бы сломалось при другом количестве книг или другой ширине экрана. Вот что получилось с первой попытки. Довольно неказисто.
Второй подход
Пора снова вспомнить школьную математику. Прежде чем что-либо раскладывать, нужно понять, сколько места занимает наклонённая книга. Представьте, что вы поворачиваете прямоугольник, а рамка, полностью охватывающая его, — это bounding box. При повороте на 45 градусов bounding box квадрата или прямоугольника сам становится квадратом.
Чтобы определить размеры этого bounding box, можно воспользоваться старой доброй теоремой Пифагора. Допустим, размер книги — 120×180 px. Тогда bounding box будет примерно 212×212 px.
Теперь к самой раскладке. Если внимательно посмотреть на расположение книг в Libby, становятся заметны три вещи. В чётных строках книги наклонены в одну сторону, а в нечётных — в другую. Нечётные строки начинаются немного правее. И первая книга каждой строки соприкасается краем с первой книгой следующей строки.
Наклоните голову на 45°, и окажется, что это узор «ёлочка». Тот самый зигзагообразный рисунок, который можно увидеть на кирпичных дорожках и деревянных полах, где каждый элемент расположен под прямым углом к следующему.
Замените кирпичи книгами, а раствор — на gap, и получите раскладку Libby.
Итак, интервалы вычисляются отдельно через две стороны книги:
diamond = (cellW + cellH) / √2 -> 212 сторона bounding box rowShift = (cellH + gap) / √2 -> 136 насколько нечётные строки сдвигаются вправо pitch = 2 × rowShift -> 272 расстояние между центрами книг в одной строке rowPitch = (cellW + gap) / √2 -> 93 насколько каждая следующая строка расположена ниже предыдущей Значение gap выбираем сами — для начала пусть будет 12.
Теперь можно перейти к строкам: как они располагаются и сколько книг помещается в каждой.
Сколько книг помещается в строке
Первая книга занимает целый bounding box, а каждая следующая добавляет один pitch:
columns = max(1, floor((availableWidth − diamond) / pitch) + 1)
Нечётные строки начинаются со сдвигом на половину pitch, поэтому в них помещается на одну книгу меньше, чем в чётных.
чётная строка → columns книг нечётная строка → columns − 1
Куда попадает книга
Два шага: сначала превращаем индекс в номер строки и столбца, затем — в координату.
Строки идут парами: сначала полная строка из columns книг, затем короткая строка, в которой на одну книгу меньше. Поэтому одна пара содержит 2 × columns − 1 книг. Делим индекс книги на размер пары: частное показывает номер пары, а остаток — позицию внутри неё.
perPair = 2 × columns − 1 pair = index / perPair within = index % perPair row = within < columns ? 2×pair : 2×pair + 1 col = within < columns ? within : within − columns
Теперь координаты. Позиции считаются по центрам. Один столбец вправо добавляет pitch, одна строка вниз — rowPitch, а нечётные строки дополнительно получают rowShift. diamond/2 нужен потому, что край первого bounding box находится в точке 0, а его центр — на половине ширины этого квадрата.
x = diamond/2 + col × pitch + (row odd ? rowShift : 0) y = diamond/2 + row × rowPitch rotation = row even ? 45° : −45°
Собственный Layout в Compose
Compose сначала выполняет composition — определяет, какие элементы существуют, затем layout — определяет их размеры и расположение, а потом drawing — рисует пиксели. Всё происходит в одном направлении, назад вернуться нельзя. Во время layout-прохода каждый элемент измеряет дочерние элементы, определяет собственный размер и затем размещает их. Row, Column и Box работают на том же механизме и отличаются только правилами размещения, поэтому Layout используется тогда, когда вам нужны собственные правила. Например, вот фрагменты исходного кода Row и Column:
return with(measureScope) {
layout(crossAxisLayoutSize, mainAxisLayoutSize) {
placeables.forEachIndexed { i, placeable ->
val crossAxisPosition = getCrossAxisPosition(/* … */)
//Column.kt:163
placeable.place(crossAxisPosition, mainAxisPositions[i])
}
}
}
// Row.kt:194 placeable.place(mainAxisPositions[i], crossAxisPosition)
Column передаёт накопленную позицию в аргумент y, а выравнивание — в x. Row делает наоборот. Именно поэтому элементы в Column идут один под другим, а в Row — один рядом с другим. Ту же логику можно применить и к нашему случаю.
Наше правило звучит так: «разместить книгу N в точке N нашей решётки, повернув её на 45° в одну сторону для чётных строк и в другую — для нечётных». Ничего встроенного в Compose такого правила не знает. Поэтому мы измеряем каждую книгу с одинаковым размером ячейки, смотрим через constraints.maxWidth, сколько ширины реально доступно, а затем пропускаем каждый индекс через формулы выше, чтобы получить строку, столбец и центр.
place позиционирует левый верхний угол, а поворот выполняется вокруг центра, поэтому передаём в него координату центра минус половину ширины и высоты дочернего элемента, а сам поворот задаём в слое.
Вот как выглядит часть с layout:
layout(width, height) {
placeables.forEachIndexed { index, placeable ->
val p = spec.placementOf(index, columns)
placeable.placeWithLayer(
x = p.x - placeable.width / 2,
y = p.y - placeable.height / 2,
) { rotationZ = p.rotation }
}
}
Первый подход работал ровно для 27 книг на одном экране — уберите одну, и всё ломалось. Эта версия принимает любое количество книг при любой ширине, а сам layout вообще ничего не знает о книгах.
Оказалось, что большая часть работы — это геометрия: как только стало понятно, что перед нами узор «ёлочка», реализация на Compose фактически свелась к одному вызову placeWithLayer на каждую книгу.
Вот итоговый результат:
Полный код доступен на: Github
Живая демоверсия здесь — во всей красе Kotlin Multiplatform: https://kevindesai777.github.io/compose-experiments/#home

