Как стать автором
Обновить
308.95
AvitoTech
У нас живут ваши объявления

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

Уровень сложностиСредний
Время на прочтение21 мин
Количество просмотров2.5K

Всем привет! Я Александр Власюк, старший 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

Кастомный Layout. MultiMeasureLayout

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

Chips из дизайн-системы Авито
Chips из дизайн-системы Авито

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

Каждый 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 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.

miInstrinsicWidth в нашем случае — минимальная ширина для отображения Chip. С ее помощью можно найти final width
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. Вот, как отработает этот вызов.

Ширина строки — это суммарная ширина ее компонентов. 

  1. Вычисляется ширина Image. Поскольку изображение обернуто модификатором size(64.dp) — это значение и будет считаться шириной Image, поскольку другого размера изображение быть не может. Ширина: 64.

  2. Вычисляется ширину Column. 

    1. К столбцу применен padding(8.dp), то есть слева и справа будет по 8dp. Ширина паддингов: 8*2 = 16.

    2. Ширина столбца — максимальная ширина его компонентов: max(Text1.minInstrinsicWidth, Text2.minIntrinsicWidth) 

  3. Вычисляется ширина 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 из дизайн-системы Авито
TabGroup из дизайн-системы Авито

Сам Tab — это просто набор текстов: 

@Composable
fun Tab() {
   Row {
   	Text()
   	Text()
   }
}

А вот с индикатором интереснее. Так как мы хотим анимировать индикатор по ширине и координатам выбранного Tab, то позицию вкладки нужно передать аргументом в Composable-функцию. Получается, что мы должны узнать размеры одной ноды на этапе композиции другой. Для этого в Compose есть Subcomposition.

Subcomposition — это возможность отложить композицию элемента на фазу Layout. Это может быть полезно для улучшения производительности и гибкости работы с интерфейсами — как в нашем случае.

Subcomposition — часть фазы Layout. Это позволяет отложить композицию
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. 

Цепочка вызовов от SubcomposeLayout до LazyRow/Column
Цепочка вызовов от SubcomposeLayout до LazyRow/Column

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

Допустим, мы хотим сделать кастомный лейаут со списком колбасок (таймлайн), который скроллится во все стороны.

Кастомный LazyLayout со скроллом во все стороны
Кастомный LazyLayout со скроллом во все стороны

Для этого нам нужно написать кастомный LazyLayout.

@Composable
fun LazyLayout(
	itemProvider: LazyLayoutItemProvider,
	prefetchState: LazyLayoutPrefetchState?,
	measurePolicy: LazyLayoutMeasureScope.(Constraints) -> MeasureResult,
   ...
)

Внутрь LazyLayout согласно интерфейсу нужно передать ItemProvider, PrefetchState и MeasurePolicy, но на самом деле нам ещё понадобятся LazyLayoutScope и LazyLayoutState. 

Для LazyLayout нам понадобятся LazyLayoutScope, ItemProvider, LazyLayoutState, MeasurePolicy и PrefetchState
Для 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: 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. 

Допустим, пользователь скроллит лейаут вправо. Мы пониманием, что какой-то элемент вот-вот должен появиться на экране. Вот его мы и можем запрефетчить. 

PrefetchState позволяет заранее измерить элементы, которые скоро могут появиться на экране
PrefetchState позволяет заранее измерить элементы, которые скоро могут появиться на экране

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

В LazyLayoutState получаем коллбэк onScroll и планируем префетч ближайших элементов с помощью schedulePrefetch
В LazyLayoutState получаем коллбэк onScroll и планируем префетч ближайших элементов с помощью schedulePrefetch

В ответ от PrefetchState мы получим инстанс PrefetchHandle, с помощью которого можно отменить префетч, если пользователь начнет скроллить в другую стороны.

Что стоит запомнить

  • Modifier.layout  — модификатор, который позволяет изменять расположение и размеры layout отдельного компонента.

  • fun Layout() — позволяет создать кастомный Layout для нескольких компонентов.

  • @Deprecated fun MultiMeasureLayout — лучше использовать вместо него Intrinsics.

  • fun SubComposeLayout() — откладывает композицию контента, если мы хотим использовать композицию одних элементов при композиции других. 

  • fun LazyLayout() — позволяет создать кастомный Lazy Layout.

Спасибо за уделенное статье время! Для обсуждения вопросов встретимся в комментариях.

Кликни здесь и узнаешь

Подробнее о том, какие задачи решают инженеры Авито, — на нашем сайте и в телеграм-канале AvitoTech. А вот здесь — свежие вакансии в нашу команду.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Пробовали ли вы писать Custom Layout в Compose?
23.53% Да4
76.47% Нет13
0% Попробую после этой статьи!0
Проголосовали 17 пользователей. Воздержались 2 пользователя.
Теги:
Хабы:
+20
Комментарии5

Публикации

Информация

Сайт
avito.tech
Дата регистрации
Дата основания
2007
Численность
5 001–10 000 человек
Местоположение
Россия
Представитель
vvroschin