Все

Как работать с Custom Layout в Jetpack Compose. Часть 1

Статьи android mobile
Всем привет! Я Александр Власюк, старший Android-инженер в Авито, разрабатываю Авито Кошелек и веду телеграм-канал «​​Записки инженера».
Сегодня я расскажу про кастомные Layout в Jetpack Compose. Они пригодятся, например, если вы разрабатываете свою дизайн-систему или хотите использовать сложные лейауты, которые есть во View-системах, но ещё не появились в вашей версии Jetpack Compose.
Если вы привыкли к кастомным View, то работа с кастомными Layout в Compose может показаться немного непривычной, но на самом деле, как мы увидим далее — там больше общего, чем кажется на первый взгляд.
В этой статье вспоминаем, как вообще работают лейауты в Compose, обсуждаем изменение лейаута отдельного компонента, смотрим, как создавать кастомные Layout и LazyLayout и учимся откладывать композицию. И всё это на примерах, в том числе из дизайн-системы Авито.

Контекст. Зачем нам кастомные Layout

Мы в Авито активно внедряли Jetpack Compose в наши экраны и подготавливали на нем дизайн-систему на 50+ компонентов. При этом мы не используем Material Design, потому что она заточена под Material Theme, а у нас в приложениях ее нет. Да и компоненты там не самые гибкие.
Стандартные лейауты не покрывают все кейсы — в таких случаях мы писали кастомные. Проблема в том, что к концепции Custom View все привыкли и написали кучу материалов по тому, как их создавать, а вот в случае Compose этого не хватает. Более того, часто в ответах на Stack Overflow и публикациях на Medium по теме встречаются в корне неправильные подходы и решения. В этой статье постараемся исправить данную ситуацию.

Как не стоит изменять Layout в Compose

Допустим, нам надо разместить два элемента в столбик: изображение и текст под ним. А Composable Column у нас почему-то нет.
Попробуем сделать такой лейаут:
Попробуем сделать такой лейаут
Самый простой вариант — повесить Modifier.onSizeChanged. Туда придет коллбэк, когда изображение будет измерено. При изменении размера мы будем сохранять высоту в стейт.
var imageHeightPx by remember { mutableStateOf(0) }
Image(
modifier = Modifier.onSizeChanged { size ->
imageHeightPx = size.height
}
)
Text()
А затем значение этого стейта установим в оффсет для элемента Text, то есть опустим текст на высоту изображения.
Text(
modifier = Modifier.offset(
y = imageHeightPx.toDp(),
)
)
Такой подход, конечно, сработает, но вот отрисовываться лейаут будет за 2 фрейма из-за смены состояния и рекомпозиции.
В первом фрейме произойдет фаза лейаута, измерится изображение, придет коллбэк, высота сохранится в стейт. Когда мы считаем стейт высоты в тексте, произойдет рекомпозиция, и в итоге лейаут отрисуется только во втором фрейме.
Считывание высоты из стейта вызовет рекомпозицию. Окончательно layout отрисуется только во втором фрейме:
Считывание высоты из стейта вызовет рекомпозицию. Окончательно layout отрисуется только во втором фрейме 

Layout в Jetpack Compose. Фазы фрейма

Давайте разберем чуть подробнее, как работает Layout в Jetpack Compose.
Для начала разберемся, что из себя представляет фрейм в Compose. Как мы увидели выше, он состоит из 3 фаз:
  1. Composition — отвечает на вопрос «Что нужно отрисовать?»
  2. Layout — отвечает на вопрос «Где отрисовать?»
  3. Drawing — отвечает на вопрос «Как отрисовать?»
Три фазы фрейма:
Три фазы фрейма
Остановимся на каждой фазе подробнее:
1. Фаза Composition принимает на вход composable-функцию и получает UI-дерево, состоящее из LayoutNode.
Фаза Composition
Фаза Composition
2. Фаза Layout принимает на вход UI-дерево и возвращает координаты каждого узла.
Фаза Layout
Фаза Layout
3. Фаза Drawing отрисовывает UI-дерево по координатам, заданным в фазе Layout.
Фаза Drawing
Фаза Drawing
На разных фазах можно использовать определенные модификаторы для изменения поведения composable-функций. Поговорим о них ниже.
Layout в Jetpack Compose. Modifier
Modifier в Compose — это объект, который используется для изменения или дополнения поведения и внешнего вида UI-элемента — composable-функции.
Модификатор позволяет изменять размеры, отступы, обводки, фоны, обработку событий и т.д. Он служит для декларативного определения того, как компонент должен быть нарисован или размещен в пользовательском интерфейсе.
Каждый модификатор совершает свою работу на определенной фазе . Например:
  • size, offset, padding — на фазе Layout;
  • drawBehind, clip — на фазе Drawing.
Давайте посмотрим, что происходит с модификаторами при композиции.
Модификаторы оборачивают composable-функцию и друг друга:
Модификаторы оборачивают composable-функцию и друг друга
Modifier на самом деле — это тоже LayoutNode, то есть часть UI-дерева. Модификаторы оборачивают те composable-функции, к которым их применяют. На первом уровне ноды находится тот Modifier, который был применен первым. Он оборачивает следующий за ним модификатор и так далее. Composable-функция находится в самом низу.
Layout в Jetpack Compose. Constraints
Теперь, когда мы понимаем, как модификаторы образуют дополнительные уровни в UI-дереве, важно также уделить внимание тому, как происходит распределение места и определение размеров для каждого LayoutNode в этом дереве. Здесь на сцену выходят constraints, которые представляют собой ограничения размеров для LayoutNode.
Constraints — границы размера LayoutNode, то есть минимальное и максимальное значения, которое может принять LayoutNode. Constraints передаются от родителя к дочерним элементам, а размеры возвращаются наверх по мере их измерения.
Constraints передают вниз по дереву. Наверх возвращаются размеры:
Constraints передают вниз по дереву. Наверх возвращаются размеры
Типы Constraints. Ограничения бывают 3 типов:
  1. Bound Constraints — указывают диапазон значения (например, 100–300x100–200).
  2. Unbound Constraints — указывают открытый диапазон значений (например, от 0 до +∞).
  3. Fixed Constraints — указывает конкретные значения (например 300x200).
Три типа Constrainst:
Три типа Constrainst
Можно смешивать несколько типов constraints по разным измерениям. Например, на картинке ниже — Fixed Constraint по высоте и Unbound Constraint по ширине. Это, условно, четвертый тип – Mixed Constraints.
Mixed Constraints — разные ограничения по нескольким измерениям:
Mixed Constraints — разные ограничения по нескольким измерениям 
Измерение с учетом Constraints. Допустим, у нас есть Composable Box, к которому применен модификатор size(150).
  1. Модификатор первый получает Constraints, потому что он оборачивает composable-функцию в UI-дереве, как мы выяснили выше.
  2. Модификатор подменяет значение constraint на 150x150 и передает далее по UI-дереву — дочерней composable-функции Box.
  3. Box принимает единственные возможные размеры в рамках данных constraints — 150x150.
Работа модификатора с учетом constraints:
Работа модификатора с учетом constraints
Теперь рассмотрим ситуацию с несколькими модификаторами size().
  1. Первый модификатор — size(100) — получает constraints. Подменяет их на 100x100, передает дальше.
  2. Второй модификатор уже не может выйти за рамки constraints, поэтому он просто передает их дальше — вложенному Composable Box.
  3. Box измеряется в размерах 100x100.
Работа нескольких модификаторов с учетом constraints:
Работа нескольких модификаторов с учетом constraints

Изменение layout отдельного компонента

Давайте от теории перейдем к практике. Допустим, мы хотим изменить ширину компонента с помощью Modifier.width. Для этого можно использовать Modifier.layout — модификатор, который позволяет изменить layout конкретного компонента.
Изменим ширину компонента с помощью Modifier.width и Modifier.layout:
  • Передадим в Modifier.layout лямбду с аргументами Measurable и Constraints.
fun Modifier.width(width: Dp) = this.then(
Modifier.layout { measurable: Measurable, constraints: Constraints ->
}
)
Про Constraints мы говорили выше, Measurable — это LayoutNode, которую можно измерить.
  • Подменяем constraints с помощью constraints.copy().
val newConstraints = constraints.copy(
minWidth = width,
maxWidth = width,
)
  • Меряем constraints с помощью measurable.measure().
val placeable = measurable.measure(newConstraints)
Это метод из интерфейса Measurable, который измеряет LayoutNode.
interface Measurable {
fun measure(constraints: Constraints): Placeable
Мы получили Placeable — LayoutNode в измеренном состоянии, то есть с заданными шириной и высотой.
abstract class Placeable : Measured {
var width: Int
var height: Int
}
Лямбда, которую мы передаем, — ресивер над MeasureScope, то есть мы сейчас находимся в области видимости Measure, в которой можно только измерять ноды. Чтобы перейти к размещению нод, нужно вызвать метод layout(), передать туда конечную ширину и высоту нашего layout, а также лямбду PlacementScope.
interface MeasureScope {
fun layout(
width: Int,
height: Int,
alignmentLines: Map<AlignmentLine, Int> = emptyMap(),
placementBlock: Placeable.PlacementScope.() -> Unit
): MeasureResult
}
  • Вызываем метод layout(), передаем в него ширину и высоту placeable. Внутри — размещаем элемент с помощью placeable.place(0, 0).
layout(placeable.width, placeable.height) {
// PlacementScope
placeable.place(0, 0)
}
Вот, что у нас в итоге получилось:
fun Modifier.width(width: Dp) = this.then(
Modifier.layout { measurable: Measurable, constraints: Constraints ->
val newConstraints = constraints.copy( minWidth = width,
maxWidth = width,
)
val placeable = measurable.measure(newConstraints) // (1) измеряем дочерние ноды
layout(placeable.width, placeable.height) { // (2) определяем собственный размер
placeable.place(0, 0) // (3) располагаем дочерние ноды
}
}
)
Как мы видим, фаза Layout в Compose проходит 3 этапа:
  1. Измерение дочерних нод (метод measure).
  2. Определение собственных размером (метод layout).
  3. Расположение дочерних нод (метод place).
LayoutModifier, который мы только что использовали. Он имплементирует интерфейс LayoutModifierNode и переопределяет в нем метод measure(). В переопределенном measure() просто вызывается measureBlock.
class LayoutModifierImpl(
var measureBlock: MeasureScope.(Measurable, Constraints) -> MeasureResult
) : LayoutModifierNode {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
) = measureBlock(measurable, constraints)
}
Это может быть полезно, например, если мы хотим сделать модификатор, который влияет сразу на две фазы: Layout и Drawing. Пример такого модификатора — TextStringSimpleNode, который используется в стандартном @Composable Text для измерения и отображения текста на экране.
class TextStringSimpleNode(...) : LayoutModifierNode, DrawModifierNode {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
) { ... }
override fun ContentDrawScope.draw() { ... }
}

Кастомный Layout

Вернемся к исходной задаче: сделать кастомный Layout — столбец из изображения и текста под ним. Для этого можно воспользоваться стандартным @Composable Layout.
1. Вызываем кастомный Layout.
@Composable
fun Column() {
Layout(...)
}
2. Передаем в Layout контент — набор composable-функций, которые будут отображаться в лейауте.
Layout(
content = {
Image()
Text()
},
)
3. Задаем measurePolicy — этот тот же measureBlock, который мы передавали в Modifier.layout, но со списком Measurable-объектов. Список нужен, поскольку каждая composable-функция из контента маппится в конкретный Measurable.
measurePolicy = { measurables: List<Measurable>, constraints: Constraints ->
...
}
4. Измеряем каждый Measurable.
val placeables = measurables.map {
it.measure(constraints)
}
5. Размещаем каждый placable на экране.
layout(width = width, height = height) {
placeables.forEach { placeable ->
placeable.placeRelative(x, y)
}
}
Вот, что у нас в итоге получилось:
@Composable
fun Column() {
Layout( // вызываем кастомный Layout
content = { // передаем набор composable-функций, которые будут отображаться в лейауте
Image()
Text()
},
measurePolicy = { measurables: List<Measurable>, constraints: Constraints -> ...
val placeables = measurables.map { // измеряем каждый Measurable
it.measure(constraints)
}
...
layout(width = width, height = height) {
placeables.forEach { placeable ->
placeable.placeRelative(x, y) // размещаем на экране
}
}
},
)
}

Кастомный Layout. Работа с LayoutNode

При работе с LayoutNode может возникнуть две основные задачи: идентифицировать каждый узел и получить данные об узле.
1. Идентифицируем LayoutNode — то есть определим, какой Measurable соответствует какой функции: кто из них — текст, а кто — изображение.
Для этого можно воспользоваться Modifier.layoutId(). layoutId будет доступен в measureBlock из соответствующей проперти.
Layout(
content = {
Image(Modifier.layoutId(ImageId))
Text(Modifier.layoutId(TextId))
}
) { measurables, constraints ->
val imageMeasurable = measurables.firstOrNull { it.layoutId == ImageId }
...
}
2. Получаем данные от LayoutNode. О LayoutNode можно получить вообще любую информацию, поскольку в интерфейсе Measurable есть поле parentData, а оно может принимать любой тип данных.
interface Measurable {
val parentData: Any?
...
}
Установить значение этого поля можно с помощью кастомного ParentDataModifier с переопределенным в нем методом modifyParentData().
class ParentDataModifierImpl() : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?) { ... }
}
parentData затем можно считать в лейауте:
Layout(
content = content,
) { measurables, constraints ->
measurables.forEach {
it.parentData?.let { ... }
}
}
Это может пригодиться, если мы хотим менять логику лейаута в зависимости от контента, который туда передали.
layoutId, о котором мы говорили выше, — это на самом деле тоже parentData определенного типа.
val Measurable.layoutId: Any?
get() = (parentData as? LayoutIdParentData)?.layoutId
Продолжение статьи можно прочитать здесь.