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

Кастомный Layout. MultiMeasureLayout
В дизайн-системе Авито есть компонент Chips — горизонтальный список Chip, который можно скроллить.
Chips из дизайн-системы Авито:

Когда суммарная ширина Chips меньше, чем ширина экрана, каждый Chip растягивается. При этом пропорции между Chips сохраняются, то есть каждый Chip умножается на фиксированный коэффициент.
Каждый 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 measureif (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 : IntrinsicMeasurableinterface IntrinsicMeasurable {fun minIntrinsicWidth(height: Int): Intfun maxIntrinsicWidth(height: Int): Intfun minIntrinsicHeight(width: Int): Intfun maxIntrinsicHeight(width: Int): Int}
min/maxIntrinsicWidth определяют, какую минимальную/максимальную ширину может занять measurable при заданной высоте.
Это ровно то, что нам нужно. miInstrinsicWidth — это минимальная ширина, которая нужна каждому Chip, чтобы отобразить весь контент. А с помощью нее можно найти финальную ширину — с учетом коэффициента, на который нужно умножить ширину Chip.
miInstrinsicWidth в нашем случае — минимальная ширина для отображения Chip. С ее помощью можно найти final width:

По сути, мы первый вызов 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 — горизонтальный список вкладок, который можно скроллить. При нажатии на таб лейаут анимируется таким образом: выбранная вкладка скроллится на центр экрана, за ней скроллится индикатор по ширине и координатам выбранного таба.
TabGroup из дизайн-системы Авито:

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

Subcomposition — часть фазы Layout. Это позволяет отложить композицию.
Отложенная композиция в SubcomposeLayout
Допустим, Subcomposition происходит через SubcomposeLayout. Он отличается от обычного @Composable Layout, который мы использовали: у SubcomposeLayout measurePolicy является ресивером от SubcomposeMeasureScope.
@Composablefun 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.
Цепочка вызовов от SubcomposeLayout до LazyRow/Column:

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

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

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: Listfun items(items: List, itemContent: (ItemState) -> Unit) {items.forEach { _items.add(Item(it, itemContent)) }}}
2. ItemProvider инкапсулирует отображение элементов. Это интерфейс LazyLayoutProvider, который напоминает RecyclerViewAdapter из мира View.
interface LazyLayoutItemProvider {val itemCount: Int // обязательно для переопределения@Composablefun Item(index: Int, key: Any) // обязательно для переопределенияfun getContentType(index: Int): Any?fun getKey(index: Int): Anyfun getIndex(key: Any): Int}
Для переопределения обязательными являются поля itemCount и @Composable Item(). Остальные поля — опциональные, но их необходимо переопределить для переиспользования слотов.
Для простоты переопределим только обязательные поля.
class ItemProvider(private val itemsState: List<Item>,) : LazyLayoutItemProvider {override val itemCountget() = itemsState.size@Composableoverride 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)
Вот, что у нас получилось:
@Composablefun 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.
@Stablesealed 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 может меняться.
@ExperimentalFoundationApiclass LazyLayoutPrefetchState {fun schedulePrefetch(index: Int, constraints: Constraints): PrefetchHandleinterface PrefetchHandle {fun cancel()}}
PrefetchState позволяет заранее измерить элементы, которые пока что не видны на экране. Аналог в мире View — RecyclerView.LayoutPrefetchRegistry.
Допустим, пользователь скроллит лейаут вправо. Мы пониманием, что какой-то элемент вот-вот должен появиться на экране. Вот его мы и можем запрефетчить.
PrefetchState позволяет заранее измерить элементы, которые скоро могут появиться на экране:

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

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