Привет, Хабр! Меня зовут Артем и я автор и ведущий YouTube канала Android Insights
Сегодня я расскажу о том, как создавать Custom Layout в Jetpack Compose, а также мы решим одну связанную с данной темой практическую задачку. Создание Custom Layout может показаться чем-то сложным на первый взгляд, но я постараюсь все объяснить максимально понятно и подробно. Итак, приступим!
Введение
Решать различные проблемы интереснее и нагляднее всего на реальных примерах, поэтому давайте поставим для себя задачу.
У нас есть список объектов, все объекты имеют разную высоту. Нам необходимо сделать так, чтобы высота всех элементов в списке была одинаковой. На скриншоте ниже можно посмотреть то, как выглядят наши вводные.
Исходные данные
Теперь, когда задача поставлена, самое время перейти к ее решению!
Как создать свой собственный Layout?
Для начала создадим @Composable
функцию EqualHeightColumn
, в которой реализуем все, что нам необходимо.
EqualHeightColumn
@Composable
fun EqualHeightColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
)
Для того, чтобы можно было создавать свои кастомные Layout'ы, Jetpack Compose предоставляет функцию Layout
.
Layout
inline fun Layout(
content: @Composable @UiComposable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy,
)
Сигнатура данной функции довольно простая, необходимо передать два обязательных параметра и один, Modifier
, является опциональным. Если с параметрами content
и modifier
все понятно. Это контент, который будет находиться внутри layout и модификаторы, которые будут применены, соответственно, то MeasurePolicy
стоит рассмотреть подробнее.
MeasurePolicy
fun interface MeasurePolicy {
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult
fun IntrinsicMeasureScope.minIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
val mapped = measurables.fastMap {
DefaultIntrinsicMeasurable(it, IntrinsicMinMax.Min, IntrinsicWidthHeight.Width)
}
val constraints = Constraints(maxHeight = height)
val layoutReceiver = IntrinsicsMeasureScope(this, layoutDirection)
val layoutResult = layoutReceiver.measure(mapped, constraints)
return layoutResult.width
}
fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
val mapped = measurables.fastMap {
DefaultIntrinsicMeasurable(it, IntrinsicMinMax.Min, IntrinsicWidthHeight.Height)
}
val constraints = Constraints(maxWidth = width)
val layoutReceiver = IntrinsicsMeasureScope(this, layoutDirection)
val layoutResult = layoutReceiver.measure(mapped, constraints)
return layoutResult.height
}
fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
val mapped = measurables.fastMap {
DefaultIntrinsicMeasurable(it, IntrinsicMinMax.Max, IntrinsicWidthHeight.Width)
}
val constraints = Constraints(maxHeight = height)
val layoutReceiver = IntrinsicsMeasureScope(this, layoutDirection)
val layoutResult = layoutReceiver.measure(mapped, constraints)
return layoutResult.width
}
fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int {
val mapped = measurables.fastMap {
DefaultIntrinsicMeasurable(it, IntrinsicMinMax.Max, IntrinsicWidthHeight.Height)
}
val constraints = Constraints(maxWidth = width)
val layoutReceiver = IntrinsicsMeasureScope(this, layoutDirection)
val layoutResult = layoutReceiver.measure(mapped, constraints)
return layoutResult.height
}
}
MeasurePolicy
представляет собой интерфейс, который отвечает за измерение всех дочерних объектов и их расположение.
measure
- главная функция интерфейса MeasurePolicy
. Она отвечает за:
Измерение дочерних элементов
Определение размеров родительского элемента на основе дочерних
Размещение дочерних элементов
MeasurePolicy
также содержит методы для внутренних измерений(Intrinsic Measurements):
minIntrinsicWidth
иminIntrinsicHeight
- минимальные размеры, чтобы контент корректно отобразилсяmaxIntrinsicWidth
иmaxIntrinsicHeight
- минимальные размеры, при которых дальнейшее увеличение не повлияет на измерения
Когда это нужно?
Для модификаторов
width(IntrinsicSize.Min)
илиheight(IntrinsicSize.Max)
, которые адаптируют размеры на основе внутренних размеров контента.
Вот так, например, выглядит MeasurePolicy
для Box
BoxMeasurePolicy
private data class BoxMeasurePolicy(
private val alignment: Alignment,
private val propagateMinConstraints: Boolean
) : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
if (measurables.isEmpty()) {
return layout(
constraints.minWidth,
constraints.minHeight
) {}
}
val contentConstraints = if (propagateMinConstraints) {
constraints
} else {
constraints.copy(minWidth = 0, minHeight = 0)
}
if (measurables.size == 1) {
val measurable = measurables[0]
val boxWidth: Int
val boxHeight: Int
val placeable: Placeable
if (!measurable.matchesParentSize) {
placeable = measurable.measure(contentConstraints)
boxWidth = max(constraints.minWidth, placeable.width)
boxHeight = max(constraints.minHeight, placeable.height)
} else {
boxWidth = constraints.minWidth
boxHeight = constraints.minHeight
placeable = measurable.measure(
Constraints.fixed(constraints.minWidth, constraints.minHeight)
)
}
return layout(boxWidth, boxHeight) {
placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
}
}
val placeables = arrayOfNulls<Placeable>(measurables.size)
// First measure non match parent size children to get the size of the Box.
var hasMatchParentSizeChildren = false
var boxWidth = constraints.minWidth
var boxHeight = constraints.minHeight
measurables.fastForEachIndexed { index, measurable ->
if (!measurable.matchesParentSize) {
val placeable = measurable.measure(contentConstraints)
placeables[index] = placeable
boxWidth = max(boxWidth, placeable.width)
boxHeight = max(boxHeight, placeable.height)
} else {
hasMatchParentSizeChildren = true
}
}
// Now measure match parent size children, if any.
if (hasMatchParentSizeChildren) {
// The infinity check is needed for default intrinsic measurements.
val matchParentSizeConstraints = Constraints(
minWidth = if (boxWidth != Constraints.Infinity) boxWidth else 0,
minHeight = if (boxHeight != Constraints.Infinity) boxHeight else 0,
maxWidth = boxWidth,
maxHeight = boxHeight
)
measurables.fastForEachIndexed { index, measurable ->
if (measurable.matchesParentSize) {
placeables[index] = measurable.measure(matchParentSizeConstraints)
}
}
}
// Specify the size of the Box and position its children.
return layout(boxWidth, boxHeight) {
placeables.forEachIndexed { index, placeable ->
placeable as Placeable
val measurable = measurables[index]
placeInBox(placeable, measurable, layoutDirection, boxWidth, boxHeight, alignment)
}
}
}
}
Теперь допишем функцию EqualHeightColumn
EqualHeightColumn
@Composable
fun EqualHeightColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val measurePolicy = rememberEqualHeightColumnMeasurePolicy()
Layout(
modifier = modifier,
content = content,
measurePolicy = measurePolicy,
)
}
@Composable
private fun rememberEqualHeightColumnMeasurePolicy(): MeasurePolicy {
return remember {
MeasurePolicy { measurables, constraints ->
layout(0, 0) {}
}
}
}
Рекомендуется кешировать MeasurePolicy
и не создавать его заново во время рекомпозиции, поэтому была добавлена функция rememberEqualHeightColumnMeasurePolicy
, которая эффективно его сохраняет, используя всем знакомый remember
.
Давайте скомпилируем проект и посмотрим на результат
Скриншот
Как видите, на экране ничего не отображается, потому что я не реализовать измерение и расположение объектов на экране. Давайте это исправим!
Обновленная функция rememberEqualHeightColumnMeasurePolicy
выглядим следующим образом:
rememberEqualHeightColumnMeasurePolicy
@Composable
private fun rememberEqualHeightColumnMeasurePolicy(): MeasurePolicy {
return remember {
MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(0, 0)
}
}
}
}
}
Еще раз скомпилируем проект и посмотрим на изменения
Скриншот
Теперь контент отображается на экране, но элементы рисуются поверх друг друга. Можно сказать, что мы написали самый примитивный вариант контейнера Box
Давайте подробнее изучим параметры функции measure
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult
Начнем с первого параметра - List<Measurable>
, если с List
все должно быть понятно, то на Measurable
предлагаю немного задержаться.
Что такое Measurable
?
Интерфейс Measurable
является одним из ключевых компонентов системы layouting в Jetpack Compose. Он представляет собой часть композиции, которая может быть измерена.
В своей основе Measurable
определяет единственный метод measure
, который принимает ограничения (constraints) и возвращает объект типа Placeable
, о котором я расскажу немного позже. Этот метод позволяет измерить объект с учетом переданных ограничений, в результате чего мы получаем Placeable
с новыми размерами компонента.
Одной из важных особенностей Measurable
является то, что измерить компонент можно только один раз во время прохода layout. Это ограничение связано с тем, что система компоновки Compose оптимизирована для однократного измерения компонентов, что помогает избежать излишних пересчетов и улучшает производительность. Если пренебречь этим правилом и написать, например, следующий код:
Двойной вызов measure
@Composable
private fun rememberEqualHeightColumnMeasurePolicy(): MeasurePolicy {
return remember {
MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
// вызываем measure второй раз
measurable.measure(constraints)
}
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(0, 0)
}
}
}
}
}
то получим следующую ошибку в рантайме
Текст ошибки
java.lang.IllegalStateException: measure() may not be called multiple times on the same Measurable. If you want to get the content size of the Measurable before calculating the final constraints, please use methods like minIntrinsicWidth()/maxIntrinsicWidth() and minIntrinsicHeight()/maxIntrinsicHeight()
Но иногда все таки нужно измерять объекты несколько раз для реализации сложной логики размещения и Jetpack Compose предоставляет возможность обойти это правило. Ниже я расскажу, как это можно сделать.
Теперь, когда с Measurable
мы разобрались, посмотрим на следующий параметр - Constraints
Constraints
представляет собой неизменяемый класс, который определяет ограничения для измерения Measurable
объектов. Когда родительский компонент измеряет своих детей, он передает им Constraints
, определяющий диапазон в пикселях, в пределах которого Measurable
должен выбрать свой размер.
Основная задача Constraints
- установить границы для размеров компонента через четыре ключевых параметра:
minWidth - минимальная допустимая ширина
maxWidth - максимальная допустимая ширина
minHeight - минимальная допустимая высота
maxHeight - максимальная допустимая высота
При этом выбранные размеры компонента должны удовлетворять условиям:
minWidth ≤ выбранная ширина ≤ maxWidth
minHeight ≤ выбранная высота ≤ maxHeight
Интересной особенностью Constraints
является возможность установки бесконечных значений для maxWidth и maxHeight через специальную константу Infinity
. Этот прием часто используется родительскими компонентами, чтобы узнать предпочтительный размер дочерних элементов. Когда дочерний компонент получает неограниченные Constraints
, он обычно выбирает размер, основываясь на своем содержимом, вместо того чтобы расширяться на все доступное пространство.
Мы разобрались, что такое Measurable
и Constraints
, даже успешно измерили все наши Measurable
и получили на выходе список из объектов Placeable
. Давайте же разберемся, что он из себя представляет.
Placeable
- это абстрактный класс, который описывает измеренный дочерний layout, готовый к позиционированию родительским компонентом. Обычно объекты Placeable
являются результатом вызова метода measure
у Measurable
.
Размеры компонента представлены двумя парами значений:
width и height - размеры layout, видимые родительскому компоненту. Эти значения всегда находятся в пределах ограничений, переданных через
Constraints
measuredWidth и measuredHeight - реальные измеренные размеры layout, которые могут выходить за пределы ограничений
Ну хорошо, скажете вы, мы получили список измеренных объектов, а что с ними делать дальше? Как разместить их на экране?
Здесь на помощь приходят MeasureScope
и его единственная функция layout
. Если вы не забыли, а если забыли, то я напомню. Функция measure
у MeasurePolicy
является расширением для MeasureScope
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints,
): MeasureResult
Давайте еще раз посмотрим на код размещения элементов из начального примера
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(0, 0)
}
}
В функцию layout
обязательно необходимо передать ширину и высоту, которую будет занимать Custom Layout. В данном примере, для простоты, я занимаю все доступное пространство.
Теперь давайте обсудим, как можно размещать объекты. Для наглядности, я размещу объекты один под другим
Примитивный Column
@Composable
private fun rememberEqualHeightColumnMeasurePolicy(): MeasurePolicy {
return remember {
MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
var y = 0
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(0, y)
y += placeable.height
}
}
}
}
}
Как видно в коде, для реализации такого поведения я просто завел переменную y
, которую увеличиваю на высоту расположенного элемента.
Внутри layout
вы можете реализовывать любую логику, которая решает вашу задачу по построению пользовательского интефейса. Для примера, я добавлю отступы между дочерними элементами
Отступы между объектами
@Composable
private fun rememberEqualHeightColumnMeasurePolicy(): MeasurePolicy {
return remember {
MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
var y = 0
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
placeable.placeRelative(0, y)
// добавляю дополнительные 10.dp между объектами
y += placeable.height + 10.dp.roundToPx()
}
}
}
}
}
Теперь пара слов о том, что нужно использовать для непосредственно размещения объектов. В примере я использовал функцию placeRelative
, но она является не единственным возможным вариантом.
Для размещения Placeable
предоставляет PlacementScope
- специальную область видимости, которая содержит методы для позиционирования:
place() - размещает компонент в указанной позиции без учета направления layout
placeRelative() - размещает компонент с учетом направления layout (поддержка RTL)
placeWithLayer() - размещает компонент с дополнительным графическим слоем
placeRelativeWithLayer() - комбинация относительного размещения с графическим слоем
При размещении можно указать:
position или координаты x, y - позиция в системе координат родителя
zIndex - управляет порядком отрисовки (компоненты с большим zIndex отрисовываются поверх компонентов с меньшим значением)
layerBlock - блок для настройки графического слоя для соответствующих функций
place*WithLayer
Решаем первоначальную задачу
Итак, мы разобрались с основами и готовы приступить к решению изначальной задачи. Напомню, что нам необходимо реализовать такой Layout
, у которого все дочерние элементы имеют одинаковую высоту, равную максимальной высоте дочерних элементов.
Давайте я напишу код, а затем мы вместе его разберем.
Вот, что у меня получилось
Код
@Composable
private fun rememberEqualHeightColumnMeasurePolicy(): MeasurePolicy {
return remember {
MeasurePolicy { measurables, constraints ->
val maxHeight = measurables.maxOf { measurable ->
measurable.minIntrinsicHeight(constraints.maxWidth)
}
val updatedConstraints = constraints.copy(
minHeight = maxHeight,
maxHeight = maxHeight,
)
var width = constraints.minWidth
var height = 0
val placeables = measurables.mapIndexed { index, measurable ->
val placeable = measurable.measure(updatedConstraints)
width = maxOf(width, placeable.width)
val padding = if (index == measurables.size - 1) 0 else 8.dp.roundToPx()
height += placeable.height + padding
placeable
}
var y = 0
layout(width, height) {
placeables.forEach {
it.placeRelative(0, y)
y += it.height + 8.dp.roundToPx()
}
}
}
}
}
Для начала нам необходимо найти максимальную высоту дочерних элементов. Это можно сделать следующим образом:
val maxHeight = measurables.maxOf { measurable ->
measurable.minIntrinsicHeight(constraints.maxWidth)
}
Ничего сложного, просто проходимся по списку элементов и находим максимальное значение из всех минимальных значений правильной отрисовки объектов, используя функцию minIntrinsicHeight
, которую я упоминал ранее.
Затем необходимо обновить Constraints
, чтобы все дочерние объекты имели одинаковую высоту
val updatedConstraints = constraints.copy(
minHeight = maxHeight,
maxHeight = maxHeight,
)
После этого измеряем все имеющиеся Measurable
, одновременно с этим считаем финальные размеры Layout
var width = constraints.minWidth
var height = 0
val placeables = measurables.mapIndexed { index, measurable ->
val placeable = measurable.measure(updatedConstraints)
width = maxOf(width, placeable.width)
val padding = if (index == measurables.size - 1) 0 else 8.dp.roundToPx()
height += placeable.height + padding
placeable
}
Ширина определяется максимальным значением ширины, а высота суммой высот дочерних элементов плюс небольшой отступ между ними.
Размещение выглядит следующим образом:
var y = 0
layout(width, height) {
placeables.forEach {
it.placeRelative(0, y)
y += it.height + 8.dp.roundToPx()
}
}
Ничего нового. Проходимся по каждому Placeable
и прибавляем его высоту к переменной y
, чтобы правильно располагать объекты один под другим. Также я добавляю отступ 8.dp
, чтобы избежать эффекта "слипания".
Давайте посмотрим на результат работы этого кода
Финальный результат
Все выглядит ровно так, как и было нужно!
One more thing
Выше я упомянул, что у нас есть возможность обойти ограничение на одно измерение размера Measurable
.
Обойти это ограничение можно, если использовать SubcomposeLayout
.
SubcomposeLayout
— интересный и мощный инструмент для создания сложных пользовательских интерфейсов.
В чем же его особенность? SubcomposeLayout
позволяет создавать несколько дочерних композиций. Простыми словами, мы можем имитировать размещение объектов и использовать результаты вычислений. Но, как говорил Дядя Бен: "Чем больше сила, тем больше и ответственность". Jetpack Compose работает довольно быстро в том числе из-за явного запрета на множественные измерения объектов, если пренебрегать этим правилом и создавать множество sub-композиций, то вряд ли стоит ожидать чего-то хорошего.
Например, так будет выглядеть реализация SubcomposeEqualHeightColumn
SubcomposeEqualHeightColumn
@Composable
fun SubcomposeEqualHeightColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
SubcomposeLayout(modifier) { constraints ->
// Сначала измеряем все элементы, чтобы найти максимальную высоту
val measurables = subcompose("measure") {
content()
}
// Находим максимальную высоту среди всех элементов
val maxHeight = measurables.maxOf { measurable ->
measurable.measure(constraints).height
}
// Создаем новые ограничения с фиксированной высотой
val updatedConstraints = constraints.copy(
minHeight = maxHeight,
maxHeight = maxHeight,
)
// Теперь создаем финальную композицию с обновленными ограничениями
val finalMeasurables = subcompose("final") {
content()
}
// Измеряем элементы и вычисляем финальные размеры
var width = constraints.minWidth
var height = 0
val placeables = finalMeasurables.mapIndexed { index, measurable ->
val placeable = measurable.measure(updatedConstraints)
width = maxOf(width, placeable.width)
val padding = if (index == finalMeasurables.size - 1) 0 else 8.dp.roundToPx()
height += placeable.height + padding
placeable
}
// Размещаем элементы
layout(width, height) {
var y = 0
placeables.forEach { placeable ->
placeable.placeRelative(0, y)
y += placeable.height + 8.dp.roundToPx()
}
}
}
}
Результат работы данного кода выглядит идентично работе EqualHeightColumn
, но является менее эффективным решением.
Итоги
Вот мы и разобрались с основами создания кастомных layout в Jetpack Compose!
Конечно, я рассмотрел далеко не все тонкости, детали и особенности, но этого вполне достаточно, чтобы продолжить самостоятельное изучение и эксперименты.
Надеюсь, эта статья помогла вам лучше понять, как работают layout в Jetpack Compose и как создавать свои собственные решения. Если у вас остались вопросы или вы хотите поделиться своим опытом создания кастомных layout, буду рад обсудить это в комментариях!
До новых встреч!
P.S.
Буду рад всех видеть в своем Telegram канале
nast1x
хорошая статья. планируете обзорную статью на более сложные кастомные лэйауты?
arttttt Автор
спасибо!
Насчет более сложный лейаутов пока что не знаю т.к. на данный момент нет идей как и о чем именно можно было бы рассказать