Прихований потенціал Layout composable в Jetpack Compose

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Привіт! Мене звати Олександр Котькорло, я Android Team Lead у Welltech. Наша компанія займається розробкою мобільних застосунків в категорії Health & Fitness.

Ми в команді використовуємо Jetpack Compose як основний інструмент з його стабільного релізу. Саме під цей реліз ми стартували розробку нового застосунку. Завдяки цьому і зʼявилась можливість спробувати «Compose only» підхід — коли весь UI зроблений на Compose. Ми жодного разу не пошкодували про це 😀

В своїй статті я планую розповісти про конкретну частину Jetpack Compose — Layout composable. Розберемось, як він працює, як описати свій кастомний лайаут. Для прикладу наведу кілька ситуацій, в яких Layout composable дуже допомагав в розробці нашого застосунку.

Ця стаття буде корисною для тих, хто тільки починає розбиратись з Jetpack Compose. Також вона буде корисною і для тих, хто вже з ним працює, але ще не познайомився з можливостями Layout composable.

Трохи передісторії

Як і з усіма новими технологіями, ми відкривали можливості Jetpack Compose для себе поступово. Був потрібен час, щоб відійти від підходів із попередньої View системи і навчитись «думати як Compose» 😆, випрацювати нові підходи. Власне до кастомних лайаутів ми дійшли досить пізно, про що потім пошкодували. Траплялися ситуації, які можна було вирішити більш ефективно за допомогою Layout composable, ніж тими рішеннями, які ми реалізовували на той момент.

Частково ми не звертали на нього увагу через те, що і так було багато нового (до того ж, навіть, в гайдах від Google їм приділили небагато уваги). Частково через зручність самого Compose, бо за допомогою вже наявних лайаутів можна «зібрати» що завгодно. Обмежень в використанні майже немає, особливо зважаючи на те, що вкладеність більше не є проблемою.

Але в основному це через минулий досвід з View системи, коли до кастомних лайаутів доходили лише в крайніх випадках. З ними було банально не зручно працювати. Ситуацій, в яких це видавалося оптимальним, було небагато. Частіше проблема вирішувалася за допомогою інших лайаутів.

В ситуації з Layout composable все зовсім інакше. Він легший для сприйняття, і зробити свій layout за допомогою нього значно простіше. І інші можливості Compose роблять його потужним інструментом, який може прийти на допомогу в різних ситуаціях.

GridLayout власними силами

Для прикладу пропоную реалізувати вертикальний GridLayout.

Але для початку коротко розберемось як він працює. Дублювати документацію немає сенсу, проте розглянемо основи.

Головне, що нам треба реалізувати, щоб вся «магія» запрацювала — описати MeasurePolicy для Layout. В ньому має бути логіка вимірів і розміщення дочірніх composables.

Абстрактно алгоритм дій виглядає дуже просто:

  1. Виміряти всі дочірні composable, використовуючи метод fun measure(constraints: Constraints): Placeable.
  2. Викликати метод MeasureScope.layout, в який параметрами передати розмір, який буде мати лайаут.
  3. Всередині trailing lambda-методу layout описати логіку розташування дочірніх composable, використовуючи Placeable, які отримали на першому кроці.

Також є досить важливе обмеження на першому етапі: викликати measure метод у Measurable можна тільки один раз за композицію. При спробі викликати його вдруге, ми отримуємо креш.

Це зроблено з ціллю оптимізації. Тому не вийде, напиклад, зробити measure для всіх дочірніх composables ➡️ зрозуміти, що вони не вміщуються в межі екрану ➡️ знову викликати для них measure вже з іншими обмеженнями. Але і це не вирок, є механізм, який дозволяє вирішити цю ситуацію, проте повернемось до нього пізніше 🙂

А зараз власне перейдемо до реалізації GridLayout.

Для прикладу, реалізуємо спрощений варіант вертикального GridLayout. З конфігурації у нас буде тільки можливість вказати кількість стовпців (елементи завжди мають займати максимальну ширину) і відступи між елементами (для прикладу, єдиний параметр для вертикального і горизонтального відступів).

Реалізацію VerticalGrid з коментарями для кожного етапу ви можете побачити нижче:

@Composable
fun VerticalGrid(
    columns: Int,
    itemsSpacing: Dp,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        content = content,
        modifier = modifier
    ) { measurables, constraints ->
        // Розрахунок доступної ширини айтема базуючись на загальній ширині екрану, кількості стовпчиків та заданому відступі між елементами
        val spacingPx = itemsSpacing.roundToPx()
        val totalHorizontalSpacing = spacingPx * (columns - 1)
        val itemWidth = (constraints.maxWidth - totalHorizontalSpacing) / columns
        // Створюємо Constraints з фіксованою шириною щоб заміряти айтеми. Використовуємо щойно пораховану ширину як мінімальне і максимальне значення, щоб впевнитись що айтеми завжди будуть займати відведений під них простір.
        val itemConstraints = Constraints(
            minWidth = itemWidth,
            maxWidth = itemWidth,
        )

        // Заміряємо айтеми з щойно створеними constraints з фіксованою шириною
        val placeables = measurables.map { it.measure(itemConstraints) }

        // Для спрощення розділяємо список placeables на список списків, де кожен вкладений список репрезентує окремий рядок в сітці
        val placeablesByRows = placeables.chunked(columns)
        // Для майбутніх розрахунків нам треба знати висоту найвищого айтема в кожному рядку. Для цього ми готуємо Map<Індекс рядка, Максимальна висота>
        val heightByRow = placeablesByRows.mapIndexed { rowIndex, rowPlaceables ->
            rowIndex to rowPlaceables.maxOf { it.height }
        }.toMap()

        // Рахуємо загальну висоту нашого layout використовуючи значення максимальної висоти з кожного рядка (які ми щойно порахували) та порахувавши скільки місця потрібно на відступи між айтемами
        val totalSpacingVertical = spacingPx * (placeablesByRows.size - 1)
        val totalHeight = heightByRow.values.sum() + totalSpacingVertical

        // Змінна в якій буде зберігатись поточне значення 'y' координати для розміщення айтемів
        var y = 0
        // Використовуємо максимальну ширину з вхідних constraints та пораховану загальну висоту щоб задати розміри layout
        layout(constraints.maxWidth, totalHeight) {
            // проходимось по кожному рядку
            placeablesByRows.forEachIndexed { rowIndex, rowPlaceables ->
                // Змінна в якій буде зберігатись поточне значення 'x' координати для розміщення айтемів в межах одного рядка
                var x = 0
                // Для кожного айтема в рядку
                rowPlaceables.forEach { placeable ->
                    // розташовуємо айтем використовуючі поточні координати x та y
                    placeable.placeRelative(x, y)
                    // і додаємо ширину айтема та відступ до поточного значення координати 'x'
                    x += itemWidth + spacingPx
                }

                // коли всі айтеми в рядку розміщені додаємо до поточного значення координати 'y' висоту поточного рядка та відступ
                y += requireNotNull(heightByRow[rowIndex]) + spacingPx
            }
        }
    }
}

Використаємо його для прикладу наступним чином:

Surface(color = Color(0xFFF8F8F8)) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .systemBarsPadding()
    ) {
        VerticalGrid(
            columns = 3,
            itemsSpacing = 8.dp,
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 40.dp)
        ) {
            (1..9).forEach {
                Card {
                    Box(
                        modifier = Modifier
                            .padding(horizontal = 8.dp, vertical = 12.dp)
                    ) {
                        Text(
                            text = it.toString(),
                            modifier = Modifier.align(Alignment.Center)
                        )
                    }
                }
            }
        }
    }
}

Отримуємо бажаний результат:

Виглядає все як треба, і можна було б на цьому етапі розслабитись та піти обідати. Але світ не ідеальний, і вхідні дані на відображення рідко бувають «зручними».

Для прикладу, змінимо текст, який буде відображатись на картках таким чином, щоб всюди він був різної довжини:

Surface(color = Color(0xFFF8F8F8)) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .systemBarsPadding()
    ) {
        val text = "Lorem ipsum "
        VerticalGrid(
            columns = 3,
            itemsSpacing = 8.dp,
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 40.dp)
        ) {
            (1..9).forEach { item ->
                Card {
                    Box(
                        modifier = Modifier
                            .padding(horizontal = 8.dp, vertical = 12.dp)
                    ) {
                        Text(
                            text = text.repeat(item),
                            modifier = Modifier.align(Alignment.Center)
                        )
                    }
                }
            }
        }
    }
}

Тепер наш екран виглядає наступним чином:

Виглядає не дуже жахливо, але вже й не так гарно, як було. Припустимо, що ми показали цей екран команді дизайну і їм не дуже це сподобалось. Вони попросили зробити, щоб усі картки були однакової висоти (за висотою найбільшої картки). Не дуже раціональне використання простору екрану, але з вигаданими дизайнерами не посперечаєшся 🤷 Доведеться реалізувати.

Як нам вийти з такої ситуації? У Measurable немає інформації про висоту (логічно, бо ми його ще не заміряли). Для того, щоб знати висоту, нам треба зробити measure та отримати Placeable. А тут вже згадуємо за обмеження в тільки 1 measure на композицію. Тобто заміряти всі measurables, знайти найвищий і перезаміряти їх з іншою мінімальною висотою вже не вийде.

Звісно, можна їх спочатку заміряти без обмеження висоти, а потім знайти висоту найвищого і записати це в стейт, щоб на наступну композицію використати це значення як мінімальну висоту. Але це призведе до зайвої рекомпозиції, чого нам би не хотілось.

Тут нам на допомогу прийде Intrinsic measurements. За допомогою цих методів ми можемо запитати у composable, скільки йому потрібно мінімально/максимально простору, щоб коректно себе відмалювати. Для того, щоб дізнатись, наприклад, скільки треба мінімально висоти для коректного відображення, ми маємо вказати Measurable, скільки ширини він зможе зайняти. І навпаки: для того, щоб дізнатись, скільки йому треба ширини, ми маємо передати, скільки висоти він може займати.

Можливо, на перший погляд звучить заплутано, але насправді все досить просто. Повертаючись до нашого прикладу: нам потрібно дізнатись висоту найбільшого Measurables в нашому лайауті, щоб потім заміряти всі інші з такою мінімальною висотою.

Для цього можна скористатись методом minIntrinsicHeight, передавши в нього ширину, яку буде займати наш Measurable. На щастя, оскільки у нас GridLayout, то у всіх елементів ширина однакова і ми рахуємо її на самому початку.

Тому якщо додатково знайти максимальну необхідну висоту серед всіх measurables, які ми маємо відобразити в нашому лайауті, і використати її в Constraints, ми зможемо заміряти всі composables з однаковою мінімальною висотою.

Оновлений VerticalGrid буде виглядати так:

@Composable
fun VerticalGrid(
    columns: Int,
    itemsSpacing: Dp,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        content = content,
        modifier = modifier
    ) { measurables, constraints ->
        // Розрахунок доступної ширини айтема базуючись на загальній ширині екрану, кількості стовпчиків та заданому відступі між елементами
        val spacingPx = itemsSpacing.roundToPx()
        val totalSpacingHorizontal = spacingPx * (columns - 1)
        val itemWidth = (constraints.maxWidth - totalSpacingHorizontal) / columns

        // Тепер використовуємо minIntrinsicHeight щоб дізнатись мінімальну необхідну висоту для кожного з measurables, і взяти максимальне значення для використання в якості висоти айтема.
        val itemHeight = measurables.maxOf { it.minIntrinsicHeight(itemWidth) }
        // І використовуємо пораховані висоту та ширину для того щоб створити Constraints для замірів айтемів
        // Ми задаємо і мінімальну і максимальну ширину/висоту однаковим значенням щоб впевнитись що айтеми будуть займати завжди весь доступний їм простір
        val itemConstraints = Constraints(
            minWidth = itemWidth,
            maxWidth = itemWidth,
            minHeight = itemHeight,
            maxHeight = itemHeight
        )

        // Заміряємо measurables з щойно створеними Constraints
        val placeables = measurables.map { it.measure(itemConstraints) }
        // Для спрощення розділяємо список placeables на список списків, де кожен вкладений список репрезентує окремий рядок в сітці
        val placeablesByRows = placeables.chunked(columns)

        // Рахуємо загальну висоту layout 
        val totalSpacingVertical = spacingPx * (placeablesByRows.size - 1)
        val totalHeight = placeablesByRows.size * itemHeight + totalSpacingVertical

        // Змінна в якій буде зберігатись поточне значення 'y' координати для розміщення айтемів
        var y = 0
        // Використовуємо максимальну ширину з вхідних constraints та пораховану загальну висоту щоб задати розміри layout
        layout(constraints.maxWidth, totalHeight) {
            // проходимось по кожному рядку
            placeablesByRows.forEach { rowPlaceables ->
                // Змінна в якій буде зберігатись поточне значення 'x' координати для розміщення айтемів в межах одного рядка
                var x = 0
                // Для кожного айтема в рядку
                rowPlaceables.forEach { placeable ->
                    // розташовуємо айтем використовуючі поточні координати x та y
                    placeable.placeRelative(x, y)
                    // і додаємо ширину айтема та відступ до поточного значення координати 'x'
                    x += itemWidth + spacingPx
                }

                // коли всі айтеми в рядку розміщені додаємо до поточного значення координати 'y' висоту одного рядка та відступ
                y += itemHeight + spacingPx
            }
        }
    }
}

Результат:

Тепер екран виглядає так як треба, і всі вигадані дизайнери залишились задоволеними 😂

Але, на жаль, і тут не обійшлось без обмежень. В доці з Intrinsic measurements це не згадується, але ми не можемо просити Intrinsic height/width у composable, які використовують контейнери що базуються на SubcomposeLayout. Якщо ми спробуємо, то отримаємо наступну помилку:

java.lang.IllegalStateException: Asking for intrinsic measurements of SubcomposeLayout layouts is not supported. This includes components that are built on top of SubcomposeLayout, such as lazy lists, BoxWithConstraints, TabRow, etc.

Це, звісно, неприємно, але з цим вже можна жити. Майже завжди можна спробувати по-іншому влаштувати лайаут таким чином, щоб не використовувати контейнери, які вказані в помилці вище. Ну або можна додати модифікатор, що буде чітко вказувати розмір такого контейнеру, що теж дозволить уникнути exception. Але це вже треба вирішувати, виходячи з конкретної ситуації.

Ну а щодо цього прикладу, я розумію, що реалізований вище GridLayout сильно спрощений і нежиттєздатний. Але на його прикладі можна побачити, що Layout composable є досить зручним та гнучким інструментом. І він може стати в пригоді в різноманітних ситуаціях.

Для демонстрації наведу приклад, який хоч і з деякими спрощеннями, але взятий з реального компоненту, який ми реалізовували в нашому проєкті.

Адаптація екрану під появу клавіатури

Перед нами поставили звичайну задачу розробити екран авторизації. Екран сам по собі нескладний, але як це завжди буває з екранами, на яких зʼявляється клавіатура, їх не так просто адаптувати під невеликі девайси.

Додатковою була обовʼязкова вимога, що вся форма авторизації має залишатись над відкритою клавіатурою (а не тільки поле вводу в фокусі), і треба спробувати уникнути появи скролу всюди, крім вже зовсім «коротких» девайсів. Адаптувати екран до появи клавіатури вирішили за рахунок зменшення верхнього відступу. Дизайн екрану виглядає наступним чином:

Спочатку була реалізація, де ми зменшували цей відступ до мінімального при відкритті клавіатури. Але це виглядало неорганічно на високих пристроях, де навіть при відкритій клавіатурі можна було б нічого не змінювати. Всі компоненти вміщалися на екрані.

Хотілось зробити кращу реалізацію і ми згадали про Layout composable.

За допомогою Intrinsic measurements можна дізнатися, скільки мінімально висоти треба формі авторизації для корректного відображення. Маючи також висоту клавіатури (завдяки WindowInsets), є можливість вирахувати, чи треба зменшувати відступ згори в поточній композиції.

Ймовірний і випадок, коли доведеться повністю прибрати відступ і не буде вистачати простору для нормального відображення форми авторизації. Рішення — обмежувати її висоту, роблячи measure з меншими Constraints. А в середині форми буде скрол, щоб обробити такий сценарій.

Таким чином, покриваються всі кейси:

  • Високий девайс — екран буде залишатись без змін.
  • Середній девайс — відступ згори екрану буде зменшуватись по ходу відкриття клавіатури.
  • Малий девайс — відступ згори екрану теж буде зменшуватись, але коли він повністю зникне, ми ще й зменшимо простір під саму форму. В такому випадку вже зʼявляється скрол.

Реалізація такого лайаута:

@Composable
private fun SignUpFormLayout(
    // Передаємо максимальне можливе значення відступу згори екрану як аргумент
    maxTopPadding: Dp,
    content: @Composable () -> Unit
) {
    // Отримуємо поточну висоту клавіатури використовуючи WindowInsets
    val imeHeight = WindowInsets.ime.getBottom(LocalDensity.current)
    Layout(
        content = content,
        measurePolicy = { measurables, constraints ->
            // Цей layout підтримує тільки 1 дочірній компонент. Весь екран має бути "загорнутий" в цей layout
            require(measurables.size == 1)
            val contentMeasurable = measurables[0]

            // Отримуємо мінімальну висоту необхідну для коректного відображення контенту нашого layout
            val minContentHeight = contentMeasurable.minIntrinsicHeight(constraints.maxWidth)

            // To calculate top padding we need to find out how much space we have left on screen using minContentHeight and imeHeight, and then converting it to our padding value using coerceIn method to ensure that padding be in range of 0 to maxTopPadding
            // Порахувавши скільки вільного простору залишається в межах екрану (використовуючи minContentHeight та imeHeight) ми можемо отримати значення відступу використовуючи coerceIn метод, щоб впевнитись що відступ буде в допустимих межах (не меньше 0 і не більше maxTopPadding) 
            val topPadding = (constraints.maxHeight - minContentHeight - imeHeight)
                .coerceIn(0, maxTopPadding.toPx().toInt())
            // And now we need to find out how much space there are for the content by subtracting the height of the keyboard and top padding from the height of the screen.
            // Тепер треба порахувати скільки насправді залишається простору під контент віднявши висоту клавіатури та отриманий розмір відступу від максимальної висоти layout
            // Ми робимо це через те що на невеликих девайсах з відкритою клавіатурою простору може залишитись меньше ніж необхідно (minContentHeight)
            val maxContentHeight = constraints.maxHeight - topPadding - imeHeight

            // Використовуємо отриманий maxContentHeight щоб створити Constraints та заміряти вміст layout
            val contentConstraints = constraints
                .copy(minHeight = 0, maxHeight = maxContentHeight)
            val contentPlaceable = contentMeasurable.measure(contentConstraints)

            // Layout завжди займає максимальний можливий простір
            layout(constraints.maxWidth, constraints.maxHeight) {
                // Розміщюємо контент використовуючи значення відступу порахованого раніше в якості 'y' координати
                contentPlaceable.placeRelative(x = 0, y = topPadding)
            }
        },
        modifier = Modifier.fillMaxSize()
    )
}

Приклад використання:

Surface() {
    BoxWithConstraints(
        modifier = Modifier.fillMaxSize()
    ) {
        val maxTopPadding = remember(maxHeight) { maxHeight * 0.15f }
        SignUpFormLayout(maxTopPadding) {
            SignUpForm()
        }
    }
}

Та результат на трьох розмірах екрану:

Замість висновку

Звісно, приклади, розглянуті в цій статті, не розкривають всього потенціалу Layout Composable. Проте вже на їх основі видно, що за допомогою нього можна вирішити складну, на перший погляд, проблему відносно невеликими зусиллями.

Для прикладу, у нас в проєкті частіше за все ми використовуємо Layout Composable для трьох цілей:

  • Адаптація екрану під клавіатуру (як в прикладі вище).
  • Адаптація екрану під невеликі телефони у випадку, коли бажано уникати скролу.
  • Кастомні віджети.

Як ви могли б здогадатись — без custom layout у цих випадках довелося б пожертвувати лаконічністю та перфомансом, а таким краще не зловживати 😉

Тому сподіваюсь, що я зміг надихнути вас на самостійне ознайомлення з Layout Composable та його можливостями. Адже він здатний значно спростити і пришвидшити реалізацію складного UI, якщо знати його можливості і розуміти, коли саме краще його застосувати.

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Оце читаю статтю, а в голові крутиться — андроїду вже 13 років а зробити адекватне апі взаємодії лейауту додатку з екранною клавіатурою не змогли навіть викинувши ui тулкіт повністю і переписавши з нуля... Про яке захоплення світу штучним інтелектом говорять?

Топ стаття 🔥

Дякую за статтю. Jetpack Compose потихеньку переходить з розділу nice to have в a must, як це було з корутинами років три тому.

Підписатись на коментарі