Всем привет! Я Александр Власюк, старший 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 в Jetpack Compose. Фазы фрейма
Давайте разберем чуть подробнее, как работает Layout в Jetpack Compose.
Для начала разберемся, что из себя представляет фрейм в Compose. Как мы увидели выше, он состоит из 3 фаз:
- Composition — отвечает на вопрос «Что нужно отрисовать?»
- Layout — отвечает на вопрос «Где отрисовать?»
- Drawing — отвечает на вопрос «Как отрисовать?»
Три фазы фрейма:

Остановимся на каждой фазе подробнее:
1. Фаза Composition принимает на вход composable-функцию и получает UI-дерево, состоящее из LayoutNode.

Фаза Composition
2. Фаза Layout принимает на вход UI-дерево и возвращает координаты каждого узла.

Фаза Layout
3. Фаза Drawing отрисовывает UI-дерево по координатам, заданным в фазе Layout.

Фаза Drawing
На разных фазах можно использовать определенные модификаторы для изменения поведения composable-функций. Поговорим о них ниже.
Layout в Jetpack Compose. Modifier
Modifier в Compose — это объект, который используется для изменения или дополнения поведения и внешнего вида UI-элемента — composable-функции.
Модификатор позволяет изменять размеры, отступы, обводки, фоны, обработку событий и т.д. Он служит для декларативного определения того, как компонент должен быть нарисован или размещен в пользовательском интерфейсе.
Каждый модификатор совершает свою работу на определенной фазе . Например:
- size, offset, padding — на фазе Layout;
- drawBehind, clip — на фазе Drawing.
Давайте посмотрим, что происходит с модификаторами при композиции.
Модификаторы оборачивают composable-функцию и друг друга:

Modifier на самом деле — это тоже LayoutNode, то есть часть UI-дерева. Модификаторы оборачивают те composable-функции, к которым их применяют. На первом уровне ноды находится тот Modifier, который был применен первым. Он оборачивает следующий за ним модификатор и так далее. Composable-функция находится в самом низу.
Layout в Jetpack Compose. Constraints
Теперь, когда мы понимаем, как модификаторы образуют дополнительные уровни в UI-дереве, важно также уделить внимание тому, как происходит распределение места и определение размеров для каждого LayoutNode в этом дереве. Здесь на сцену выходят constraints, которые представляют собой ограничения размеров для LayoutNode.
Constraints — границы размера LayoutNode, то есть минимальное и максимальное значения, которое может принять LayoutNode. Constraints передаются от родителя к дочерним элементам, а размеры возвращаются наверх по мере их измерения.
Constraints передают вниз по дереву. Наверх возвращаются размеры:

Типы Constraints. Ограничения бывают 3 типов:
- Bound Constraints — указывают диапазон значения (например, 100–300x100–200).
- Unbound Constraints — указывают открытый диапазон значений (например, от 0 до +∞).
- Fixed Constraints — указывает конкретные значения (например 300x200).
Три типа Constrainst:

Можно смешивать несколько типов constraints по разным измерениям. Например, на картинке ниже — Fixed Constraint по высоте и Unbound Constraint по ширине. Это, условно, четвертый тип – Mixed Constraints.
Mixed Constraints — разные ограничения по нескольким измерениям:

Измерение с учетом Constraints. Допустим, у нас есть Composable Box, к которому применен модификатор size(150).
- Модификатор первый получает Constraints, потому что он оборачивает composable-функцию в UI-дереве, как мы выяснили выше.
- Модификатор подменяет значение constraint на 150x150 и передает далее по UI-дереву — дочерней composable-функции Box.
- Box принимает единственные возможные размеры в рамках данных constraints — 150x150.
Работа модификатора с учетом constraints:

Теперь рассмотрим ситуацию с несколькими модификаторами size().
- Первый модификатор — size(100) — получает constraints. Подменяет их на 100x100, передает дальше.
- Второй модификатор уже не может выйти за рамки constraints, поэтому он просто передает их дальше — вложенному Composable Box.
- Box измеряется в размерах 100x100.
Работа нескольких модификаторов с учетом 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: Intvar 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) {// PlacementScopeplaceable.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 этапа:
- Измерение дочерних нод (метод measure).
- Определение собственных размером (метод layout).
- Расположение дочерних нод (метод 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.
@Composablefun 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)}}
Вот, что у нас в итоге получилось:
@Composablefun Column() {Layout( // вызываем кастомный Layoutcontent = { // передаем набор composable-функций, которые будут отображаться в лейаутеImage()Text()},measurePolicy = { measurables: List<Measurable>, constraints: Constraints -> ...val placeables = measurables.map { // измеряем каждый Measurableit.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
Продолжение статьи можно прочитать здесь.