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

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

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

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

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

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

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

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

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

Теперь рассмотрим ситуацию с несколькими модификаторами size().
Первый модификатор — size(100) — получает constraints. Подменяет их на 100x100, передает дальше.
Второй модификатор уже не может выйти за рамки constraints, поэтому он просто передает их дальше — вложенному Composable Box.
Box измеряется в размерах 100x100.

Изменение 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 этапа:
Измерение дочерних нод (метод 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.
@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
Кастомный Layout. MultiMeasureLayout
В дизайн-системе Авито есть компонент Chips — горизонтальный список Chip, который можно скроллить.

Когда суммарная ширина Chips меньше, чем ширина экрана, каждый Chip растягивается. При этом пропорции между Chips сохраняются, то есть каждый Chip умножается на фиксированный коэффициент.

Стандартными лейаутами в Compose реализовать такую логику не получится. Нужно писать кастомный Layout.
Решение «в лоб»: измерить каждый Chip 2 раза, сложить их ширину и сравнить с шириной экрана. Если ширина меньше, высчитываем коэффицент. Проблема в том, что второй вызов measure() выкинет IllegalStateException, поскольку один и тот же Measurable можно измерить только один раз.
Layout { measurables, constraints ->
var placeables = measurables.map { it.measure(constraints) }
if (placeables.sumOf { it.width } < constraints.maxWidth) {
placeables = measurables.mapIndexed { i, measurable ->
val fixedWidthConstraints = calculateConstraints(...)
measurable.measure(fixedWidthConstraints) // IllegalStateException: measure() may not be called multiple times on the same Measurable
}
}
...
}
Это связано с важным ограничением, которое есть в Compose: каждая LayoutNode обходится всего один раз. Но есть и исключение — MultiMeasureLayout. Его можно измерять сколько угодно раз.
MultiMeasureLayout { measurables, constraints ->
var placeables = ... // first measure
if (placeables.sumOf { it.width } < constraints.maxWidth) {
// second measure
}
...
}
Использовать MultiMeasureLayout, тем не менее, не стоит. Он помечен аннотацией
@Deprecated("...This API should be avoided whenever possible.")
Дело в том, что при увеличении вложенности таких компонентов количество измерений будет расти экспоненциально.
Во View мы привыкли решать подобные задачи с помощью ConstraintLayout, который позволяет расположить весь лейаут в один слой. Однако в Compose ConstraintLayout под капотом использует как раз-таки деприкейтнутый MultiMeasureLayout. Поэтому такой вариант не подойдет.
Кастомный Layout. Intrinsic Measurements
Если необходимо измерить компоненты несколько раз, вместо MultiMeasureLayout лучше использовать Intrinsic Measurements.
Intrinsic Measurements позволяют опрашивать дочерние узлы до их измерения. Родительская нода получает Intrinsics от дочерней и отправляет в ответ Constraints для измерения. До этого измерение не происходит.

Родительская нода получает Instrics от дочерней. Дочерняя в ответ получает Constraints для измерения
Доступ к Instrisics осуществляется через интерфейс Measurable, который наследуется от IntrinsicMeasurable.
interface Measurable : IntrinsicMeasurable
interface IntrinsicMeasurable {
fun minIntrinsicWidth(height: Int): Int
fun maxIntrinsicWidth(height: Int): Int
fun minIntrinsicHeight(width: Int): Int
fun maxIntrinsicHeight(width: Int): Int
}
min/maxIntrinsicWidth определяют, какую минимальную/максимальную ширину может занять measurable при заданной высоте.
Это ровно то, что нам нужно. miInstrinsicWidth — это минимальная ширина, которая нужна каждому Chip, чтобы отобразить весь контент. А с помощью нее можно найти финальную ширину — с учетом коэффициента, на который нужно умножить ширину Chip.

По сути, мы первый вызов measure() просто заменяем на вызов minIntrinsicWidth. И с помощью него уже находим коэффициент.
Layout { measurables, constraints ->
val widths = measurables.map {
it.minIntrinsicWidth(constraints.maxHeight)
}
val widthCoef = constraints.maxWidth / widths.sumOf { it.width }
val placeables = measurables.mapIndexed { i, measurable ->
val fixedWidthConstraints = calculateConstraints(widths[i], widthCoef)
measurable.measure(fixedWidthConstraints)
}
...
}
Как это работает. Звучит как какая-то магия. Как это мы можем узнать ширину компонента, не измеряя его? Оказывается, в большинстве случае — можем. Разберем на примере.
Допустим, у нас есть строка Row, в которую вложено изображение Image и столбец Column с двумя Text. Попробуем вызвать Row.minIntrinsicWidth. Вот, как отработает этот вызов.

Ширина строки — это суммарная ширина ее компонентов.
Вычисляется ширина Image. Поскольку изображение обернуто модификатором size(64.dp) — это значение и будет считаться шириной Image, поскольку другого размера изображение быть не может. Ширина: 64.
Вычисляется ширину Column.
К столбцу применен padding(8.dp), то есть слева и справа будет по 8dp. Ширина паддингов: 8*2 = 16.
Ширина столбца — максимальная ширина его компонентов: max(Text1.minInstrinsicWidth, Text2.minIntrinsicWidth)
Вычисляется ширина Text. Там более сложная логика измерения, не будем останавливаться на ней в рамках этой статьи.
Итого: Row.minIntrinsicWidth = 64 + 8 * 2 + max(Text1.minIntrinsicWidth, Text2.minIntrinsicWidth)
Когда мы делаем кастомный лейаут, мы тоже можем переопределить Intrinsics в MeasurePolicy.
Layout(object: MeasurePolicy {
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
TODO("Not yet implemented")
}
...
})
Отложенная композиция
Давайте теперь посмотрим, как можно отложить композицию компонента.
У нас в Авито есть такой компонент TabGroup — горизонтальный список вкладок, который можно скроллить. При нажатии на таб лейаут анимируется таким образом: выбранная вкладка скроллится на центр экрана, за ней скроллится индикатор по ширине и координатам выбранного таба.

Сам Tab — это просто набор текстов:
@Composable
fun Tab() {
Row {
Text()
Text()
}
}
А вот с индикатором интереснее. Так как мы хотим анимировать индикатор по ширине и координатам выбранного Tab, то позицию вкладки нужно передать аргументом в Composable-функцию. Получается, что мы должны узнать размеры одной ноды на этапе композиции другой. Для этого в Compose есть Subcomposition.
Subcomposition — это возможность отложить композицию элемента на фазу Layout. Это может быть полезно для улучшения производительности и гибкости работы с интерфейсами — как в нашем случае.

Отложенная композиция в SubcomposeLayout
Допустим, Subcomposition происходит через SubcomposeLayout. Он отличается от обычного @Composable Layout, который мы использовали: у SubcomposeLayout measurePolicy является ресивером от SubcomposeMeasureScope.
@Composable
fun SubcomposeLayout(
measurePolicy: SubcomposeMeasureScope.(Constraints) -> MeasureResult
)
SubcomposeMeasureScope — это тот же MeasureScope, но с ещё одним методом — subcompose(), который и выполняет Subcomposition.
interface SubcomposeMeasureScope : MeasureScope {
fun subcompose(
slotId: Any?,
content: @Composable () -> Unit,
):List<Measurable>
}
slotId — это уникальный ID, используемый для идентификации слота при рекомпозиции. Вариантов slotId в нашем случае — 2: Tabs и Indicator.
Реализуем SubcomposeLayout
1. Композируем и измеряем табы.
val tabMeasurables = subcompose(TabSlots.Tabs) { tabs() }
val tabPlaceables = tabMeasurables.map { it.measure(constraints)
}
2. Размещаем табы.
tabPlaceables.forEachIndexed { index, placeable ->
placeable.placeRelative(x, y)
3. Размещаем табы и находим позицию выбранного таба.
var selectedTabPosition = SelectedTabPosition(0, 0)
…
if (index == selectedTabIndex) selectedTabPosition = getTabPosition(...)
4. Композируем, измеряем и размещаем индикатор.
subcompose(TabSlots.Indicator) { indicator(selectedTabPosition) }.first()
.measure(constraints).placeRelative(0, 0)
Нам нужно получить не всю ширину таба, а ширину только названия вкладки — текста, который лежит в табе. Сделаем это с помощью TextMeasurer. Его можно получить через rememberTextMeasurer(), а затем измерить с помощью measure(). В measure() нужно передать сам текст с вкладки и его стиль.
val textMeasurer = rememberTextMeasurer()
...
val tabTextWidth = textMeasurer.measure(
text = tabText,
style = textStyle,
).size.width
Вот, что у нас в итоге получилось:
SubcomposeLayout { constraints ->
val tabMeasurables = subcompose(TabSlots.Tabs) { tabs() }
val tabPlaceables = tabMeasurables.map { it.measure(constraints) }
...
layout(width, height) {
var selectedTabPosition = SelectedTabPosition(0, 0)
tabPlaceables.forEachIndexed { index, placeable ->
placeable.placeRelative(x, y)
if (index == selectedTabIndex) selectedTabPosition = getTabPosition(...)
}
subcompose(TabSlots.Indicator) { indicator(selectedTabPosition) }.first()
.measure(constraints).placeRelative(0, 0)
}
}
В итоге полученный Layout измеряется в один проход.
Что ещё умеет SubcomposeLayout. В SubcomposeLayout есть опциональный компонент SubcomposeSlotReusePolicy, который определяет логику сохранения слотов для переиспользования.
Допустим, какие-то компоненты перестали быть видны на экране. Вместо того, чтобы уничтожить их, мы сохраним эти компоненты и отобразим вновь, когда они понадобятся.
interface SubcomposeSlotReusePolicy {
fun getSlotsToRetain(slotIds: SlotIdsSet)
fun areCompatible(slotId: Any?, reusableSlotId: Any?): Boolean
}
SubcomposeSlotReusePolicy — это аналог RecycledViewPool из мира View. API у них очень похожи, но логика работы под капотом, конечно, отличается.
Кастомный LazyLayout
Логично предположить, что SubcomposeLayout должен как-то использоваться для LazyRow/Column в Compose. Так и есть, но цепочка там чуть длиннее:
LazyRow/Column использует LazyList.
LazyList вызывает LazyLayout.
А вот LazyLayout уже использует SubcomposeLayout.

В этой цепочке нас особенно интересует LazyLayout, поскольку он используется для кастомизации ленивых Layout.
Допустим, мы хотим сделать кастомный лейаут со списком колбасок (таймлайн), который скроллится во все стороны.

Для этого нам нужно написать кастомный LazyLayout.
@Composable
fun LazyLayout(
itemProvider: LazyLayoutItemProvider,
prefetchState: LazyLayoutPrefetchState?,
measurePolicy: LazyLayoutMeasureScope.(Constraints) -> MeasureResult,
...
)
Внутрь LazyLayout согласно интерфейсу нужно передать ItemProvider, PrefetchState и MeasurePolicy, но на самом деле нам ещё понадобятся LazyLayoutScope и LazyLayoutState.

1. LazyLayoutScope. Определим класс, который будет представлять одну колбаску — item. Для простоты он просто будет хранить координаты и цвет.
data class ItemState(
val x: Int,
val y: Int,
val color: Color,
...
)
Чтобы добавлять элементы в лейаут, как это делается в стандартном LazyRow/Column, понадобится LazyLayoutScope.
items(state.items) { item: ItemState ->
ItemComposable(item)
}
LazyLayoutScope будет хранить элементы, а мы будем добавлять их туда.
class CustomLazyLayoutScope {
val items: List
fun items(items: List, itemContent: (ItemState) -> Unit) {
items.forEach { _items.add(Item(it, itemContent)) }
}
}
2. ItemProvider инкапсулирует отображение элементов. Это интерфейс LazyLayoutProvider, который напоминает RecyclerViewAdapter из мира View.
interface LazyLayoutItemProvider {
val itemCount: Int // обязательно для переопределения
@Composable
fun Item(index: Int, key: Any) // обязательно для переопределения
fun getContentType(index: Int): Any?
fun getKey(index: Int): Any
fun getIndex(key: Any): Int
}
Для переопределения обязательными являются поля itemCount и @Composable Item(). Остальные поля — опциональные, но их необходимо переопределить для переиспользования слотов.
Для простоты переопределим только обязательные поля.
class ItemProvider(
private val itemsState: List<Item>,
) : LazyLayoutItemProvider {
override val itemCount
get() = itemsState.size
@Composable
override fun Item(index: Int, key: Any) {
val item = itemsState.getOrNull(index)
item?.content?.invoke(item.item)
}
}
Получать ItemProvider будем с помощью rememberItemProvider:
val itemProvider = rememberItemProvider(content)
LazyLayout(
itemProvider = itemProvider,
)
}
3. LazyLayoutState используем для сохранения оффсета, на который пользователь сдвинул пальцем — это нужно для реализации скроллы. При движении пальцем вернется коллбэк detectDragGestures, который нужно передать в Modifier.pointerInput().
modifier = Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
...
}
},
Запоминаем стейт и передаем в него оффсет с помощью onDrag()
val state = rememberLazyLayoutState()
state.onDrag(dragAmount)
Вот, что у нас получилось:
@Composable
fun CustomLazyLayout(
content: CustomLazyListScope.() -> Unit,
) {
val itemProvider = rememberItemProvider(content)
val state = rememberLazyLayoutState()
LazyLayout(
modifier = Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
state.onDrag(dragAmount)
}
},
itemProvider = itemProvider,
)
}
Кастомный LazyLayout. MeasurePolicy
Осталось определить, какие из элементов видны на экране, и отобразить их. Для этого используем Measure Policy. В случае LazyLayout Measure Policy является ресивером от LazyLayoutMeasureScope.
@Stable
sealed interface LazyLayoutMeasureScope : MeasureScope {
fun measure(index: Int, constraints: Constraints): List<Placeable>
}
Метод measure() в этом случае получает на вход не Measurable, а индекс элемента в лейауте.
1. Находим видимые элементы. Достаем offesetState и получаем элементы в пределах boundaries.
val boundaries = getBoundaries(constraints,state.offsetState.value)
val indexes = itemProvider.getItemIndexesInRange(boundaries)
2. Измеряем элементы. Под капотом вызывается subcompose().
val indexesWithPlaceables = indexes.associateWith {
measure(it, Constraints())
}
3. Размещаем элементы.
layout(constraints.maxWidth, constraints.maxHeight) {
indexesWithPlaceables.forEach { (index, placeables) ->
val item = itemProvider.getItem(index)
placeable.placeRelative(item.x, item.y)
}
}
Вот, что у нас в итоге получилось:
LazyLayout(...) { constraints ->
val boundaries = getBoundaries(constraints, state.offsetState.value)
val indexes = itemProvider.getItemIndexesInRange(boundaries)
val indexesWithPlaceables = indexes.associateWith {
measure(it, Constraints())
}
layout(constraints.maxWidth, constraints.maxHeight) {
indexesWithPlaceables.forEach { (index, placeables) ->
val item = itemProvider.getItem(index)
placeable.placeRelative(item.x, item.y)
}
}
}
Кастомный LazyLayout. PrefetchState
PrefetchState является опциональным и помечен аннотацией Experimental, то есть его API может меняться.
@ExperimentalFoundationApi
class LazyLayoutPrefetchState {
fun schedulePrefetch(index: Int, constraints: Constraints): PrefetchHandle
interface PrefetchHandle {
fun cancel()
}
}
PrefetchState позволяет заранее измерить элементы, которые пока что не видны на экране. Аналог в мире View — RecyclerView.LayoutPrefetchRegistry.
Допустим, пользователь скроллит лейаут вправо. Мы пониманием, что какой-то элемент вот-вот должен появиться на экране. Вот его мы и можем запрефетчить.

Когда мы получаем коллбэк на скролл, мы можем найти, какие ближайшие элементы мы хотим запрефетчить и вызвать schedulePrefetch() с индексами этих элементов.

В ответ от PrefetchState мы получим инстанс PrefetchHandle, с помощью которого можно отменить префетч, если пользователь начнет скроллить в другую стороны.
Что стоит запомнить
Modifier.layout — модификатор, который позволяет изменять расположение и размеры layout отдельного компонента.
fun Layout() — позволяет создать кастомный Layout для нескольких компонентов.
@Deprecated fun MultiMeasureLayout — лучше использовать вместо него Intrinsics.
fun SubComposeLayout() — откладывает композицию контента, если мы хотим использовать композицию одних элементов при композиции других.
fun LazyLayout() — позволяет создать кастомный Lazy Layout.
Спасибо за уделенное статье время! Для обсуждения вопросов встретимся в комментариях.
Подробнее о том, какие задачи решают инженеры Авито, — на нашем сайте и в телеграм-канале AvitoTech. А вот здесь — свежие вакансии в нашу команду.