Перейти к основному содержимому
Мобилка13ГЛАВА 13Вёрстка по макету

Читаем макет: dp, sp и три контейнера

Прочитано0%
Квизы0/6
Тренажёры0/3
Уровень 1 · Новичок

Зачем это нужно​

На чемпионате «Профессионалы» задание почти никогда не звучит как «сделай красивый экран, на своё усмотрение». Судьи выдают макет — картинку или ссылку на Figma с готовым дизайном — и критерии, по которым экран сверяют с макетом буквально попиксельно: не тот отступ, не тот размер шрифта, не тот цвет — минус баллы, даже если на глаз всё красиво.

Прошлые главы дали строительные блоки — Column, Row, Modifier, Text, Button, .dp — но использовали их для простых, придуманных с нуля экранов. Эта глава переворачивает задачу: не «придумай экран», а «вот готовый макет с конкретными числами — прочитай его правильно».

Здесь разберём первую половину работы: как читать числа и цвета на макете, чем dp отличается от sp, как выбрать между Box, Column и Row и как понять, какое именно свойство Arrangement или Alignment нужно. Вторая половина — сборка настоящей карточки по шагам — в следующей главе.

Сам Jetpack Compose в браузере не запускается — нужен Android-проект, поэтому Compose-код здесь даётся статичными блоками с построчным разбором. Но статичный не значит непроверенный: весь код этой главы собран настоящим компилятором в библиотечном модуле Android, и все сообщения об ошибках ниже — его подлинный вывод. А там, где под капотом обычный Kotlin — числа, строки, функции, объекты, — глава даёт исполняемые примеры, которые можно запустить прямо здесь.

Как читать макет: отступы, размеры, цвета​

Независимо от инструмента, которым пользовался дизайнер (Figma, Sketch, Zeplin, Avocode — на разных чемпионатах встречаются разные), готовый макет почти всегда сопровождают одни и те же три вида пометок поверх картинки:

  • Стрелки с числом — расстояние в dp: от края экрана до элемента (отступ, padding), между двумя элементами (промежуток, spacing) или размер самого элемента (ширина/высота).
  • Подпись у текста вида 16sp / Medium — размер шрифта в sp и его насыщенность (Regular, Medium, Bold).
  • Цветной прямоугольник с подписанным HEX-кодом вроде #E53935 или #FFFFFF — точный цвет заливки, обводки или текста.

Дальше в этой главе разберём карточку товара с такими же пометками — только текстом, в виде таблицы, раз картинку в MDX-файл не вставить. Вот сама спецификация — она будет цитироваться до конца главы:

ЭлементЧто показано в макетеЗначениеВо что превратится в коде
Карточка целикомстрелки от края до содержимого со всех сторон, подпись «12»; сама карточка залита цветом с подписью #FFFFFF и растянута от края до края экранаотступ 12 (в dp), фон #FFFFFF, ширина — на весь доступный экранModifier.fillMaxWidth().background(Color(0xFFFFFFFF)).padding(Dimens.CardPadding), где CardPadding = 12.dp
Изображение товарапрямоугольник высотой «120», залитый цветом с подписью #EEEEEEвысота 120dp, фон #EEEEEEModifier.height(Dimens.ImageHeight).background(Color(0xFFEEEEEE))
Бейдж скидкималенький прямоугольник в правом верхнем углу картинки, заливка #E53935, вокруг текста отступ «4»фон #E53935, отступ 4dpModifier.align(Alignment.TopEnd).background(Color(0xFFE53935)).padding(Dimens.BadgePadding)
Заголовок товараподпись рядом с текстом «16sp / Medium»размер шрифта 16spfontSize = Dimens.TitleFontSize, где TitleFontSize = 16.sp
Ценаподпись «18sp / Bold»размер шрифта 18spfontSize = Dimens.PriceFontSize
Ряд «цена + кнопка»стрелка от левого края до правого с засечками на обоих концах, подпись «space-between»распределить по краям, ничего не оставляя посерединеhorizontalArrangement = Arrangement.SpaceBetween

А теперь потренируй сам навык чтения чисел с макета — на глаз, как это приходится делать, когда стрелки с подписями на картинке видны, а точное значение нужно назвать самому.

Карточка нарисована в масштабе 1px = 1dp. Кликни по маркеру и прикинь размер на глаз — допуск ±4.
-20%Кроссовки беговые1 990 ₽В корзину1234
Найдено размеров: 0 из 4

Цвет как число: hex-запись​

HEX-код вроде #E53935 — это то же самое, что число 0xE53935, только записанное в шестнадцатеричной системе счисления (hexadecimal — «по основанию 16», отсюда и hex): каждая пара символов — это один канал цвета от 00 до FF (0–255 в десятичной системе). У Color в Compose есть конструктор, принимающий такое число целиком, только с ЧЕТЫРЬМЯ парами вместо трёх — в начало добавляется канал прозрачности (alpha):

Нажми на часть выражения
Интересный факт

Порядок каналов — alpha, red, green, blue — часто сокращают до аббревиатуры ARGB. Именно в этом порядке дизайнерские инструменты обычно и подписывают цвет под прямоугольником на макете, только без alpha-канала, если фон непрозрачный: #E53935 без пары FF в начале — то же самое, что 0xFFE53935 с добавленной непрозрачностью по умолчанию.

Прежде чем видеть цвет как единое число, полезно самому разложить его руками — ровно так, как это делает конструктор Color внутри себя: побитовым сдвигом (shr — shift right, «сдвинуть вправо») и маской and 0xFF (оставить только младшие 8 бит, то есть одну пару шестнадцатеричных цифр).

fun main() { val badgeColor = 0xFFE53935 val alpha = (badgeColor shr 24) and 0xFF val red = (badgeColor shr 16) and 0xFF val green = (badgeColor shr 8) and 0xFF val blue = badgeColor and 0xFF println("alpha=$alpha red=$red green=$green blue=$blue") }

Разбор по строкам​

  • Строка 2: val badgeColor = 0xFFE53935 — то же самое число, что стоит внутри Color(...) на макете; тип у него — Long, а не Int: значение 0xFFE53935 — это 4 293 212 469 в десятичной записи, в Int (максимум 2 147 483 647) оно не помещается, и компилятор сам берёт для такого литерала Long. Именно поэтому и настоящий конструктор Color(...) принимает Long.
  • Строка 3: val alpha = (badgeColor shr 24) and 0xFF — shr 24 сдвигает все биты числа на 24 позиции вправо, из-за чего самая первая пара цифр (FF) оказывается там, где раньше были младшие 8 бит; and 0xFF после сдвига «обрезает» всё, кроме этих восьми бит, оставляя чистое значение канала.
  • Строки 4–5: red/green — та же операция, но со сдвигом на 16 и на 8: чем ближе канал к началу записи числа, тем на большее число бит его приходится сдвинуть, чтобы «дотянуть» до самого конца.
  • Строка 6: val blue = badgeColor and 0xFF — синий канал уже стоит в самом конце числа, поэтому сдвиг вообще не нужен — сразу маска and 0xFF.

Что выведет: alpha=255 red=229 green=57 blue=53 — те же самые числа, что дают шестнадцатеричные пары FF, E5, 39, 35 из макета, только в привычной десятичной записи. Настоящий класс Color внутри себя делает ровно эти же четыре строки, просто один раз, при создании объекта — а не при каждом обращении к цвету.

COMPOSE · Color(0xFFE53935) — ЧЕТЫРЕ КАНАЛА0x FF E5 39 35alphaFF255 из 255redE5229 из 255green3957 из 255blue3553 из 255каждая пара цифр после 0x — один канал: alpha, red, green, blue по порядку
Четыре канала цвета читаются парами шестнадцатеричных цифр: alpha, red, green, blue — именно в этом порядке.
Отвечено вопросов: 0 из 4
Что означает четвёртая, последняя пара цифр в записи Color(0xFFE53935)?
На макете под прямоугольником подписано #EEEEEE, без явной пары для alpha. Что это значит?
Почему в коде для канала alpha используется shr 24, а для blue — вообще без сдвига?
Подпись «16sp / Medium» рядом с текстом на макете — про что говорит slash часть, «Medium»?
Отвечено верно: 0 из 4

dp и sp: чем отличаются единицы измерения​

В прошлых главах .dp уже применялся для отступов и размеров — но макет требует ещё одну единицу, sp, специально для текста, и путать их — одна из самых частых ошибок при переносе макета в код.

dp (density-independent pixels, «пиксели, независимые от плотности экрана») — единица для всего, что НЕ текст: отступы, ширина, высота, размер иконки. Официальная документация Android определяет dp как виртуальный пиксель, примерно равный одному физическому пикселю на экране с базовой плотностью 160 dpi (точек на дюйм); на экране с другой плотностью Android сам пересчитывает dp в нужное количество настоящих пикселей, поэтому один и тот же 16.dp выглядит одинакового физического размера что на дешёвом, что на дорогом телефоне.

sp (scale-independent pixels, «пиксели, независимые от масштаба») — единица только для размера текста. По умолчанию sp равен dp один в один, но с одним важным отличием: sp дополнительно учитывает системную настройку размера шрифта, которую пользователь выставляет в настройках телефона для себя (например, человеку с плохим зрением). Если для текста использовать dp вместо sp, эта настройка перестанет работать именно для этого текста — он останется мелким, даже если пользователь специально увеличил шрифт во всей системе.

COMPOSE · dp ПРОТИВ spнастройка «Крупный шрифт» в системе включена →16.dp16.dpодинаковый размер — dp настройку шрифта игнорирует16.sp16.spтот же код — sp вырос вместе с системной настройкой
dp игнорирует системную настройку размера шрифта — отступ и иконка остаются прежними. sp растёт вместе с ней — так и должен вести себя текст.
Нажми на часть выражения
Важно

Правило простое: если число со макета относится к тексту (размер шрифта, межстрочный интервал) — .sp. Если к чему угодно ещё (отступ, ширина, высота, радиус скругления) — .dp. Перепутать их — не опечатка, а ошибка типа, которую компилятор поймает сразу; разберём её точно в разделе «Типичные ошибки».

Прежде чем смотреть на настоящие Dp и TextUnit (которым Compose оборачивает .sp), стоит увидеть сам принцип «два разных типа для двух разных единиц» на самодельных классах — они устроены так же, как настоящие: тонкая обёртка вокруг числа, которая не даёт компилятору перепутать одну единицу измерения с другой.

@JvmInline value class Dp(val value: Float) @JvmInline value class Sp(val value: Float) val Int.dp: Dp get() = Dp(this.toFloat()) val Int.sp: Sp get() = Sp(this.toFloat()) fun cardPadding(padding: Dp) { println("Отступ карточки: ${padding.value}dp") } fun titleFontSize(fontSize: Sp) { println("Размер шрифта заголовка: ${fontSize.value}sp") } fun main() { cardPadding(12.dp) titleFontSize(16.sp) }

Разбор по строкам​

  • Строки 1–2 и 3–4: @JvmInline value class Dp(...) / value class Sp(...) — два маленьких класса-обёртки вокруг Float. value class (value-класс) — та же тема из главы про Compose-экран: во время выполнения программы это просто число, без накладных расходов на создание отдельного объекта, но во время КОМПИЛЯЦИИ это два разных, несовместимых типа.
  • Строки 6–7: val Int.dp: Dp get() = Dp(this.toFloat()) — то же свойство-расширение .dp, что и в прошлой главе, только теперь рядом появляется симметричный .sp, оборачивающий число в Sp, а не в Dp.
  • Строка 9: fun cardPadding(padding: Dp) — функция принимает СТРОГО Dp, ничего другого.
  • Строка 13: fun titleFontSize(fontSize: Sp) — а эта — строго Sp.
  • Строки 17–18: cardPadding(12.dp) и titleFontSize(16.sp) — каждое число передано в СВОЮ функцию, с правильным суффиксом.

Что выведет: Отступ карточки: 12.0dp, затем Размер шрифта заголовка: 16.0sp. Пока суффикс соответствует ожидаемому параметру — компилятор доволен. Что случится, если их перепутать местами — cardPadding(16.sp) вместо titleFontSize(16.sp), — разобрано в разделе «Типичные ошибки»: настоящий Compose требует именно Dp для padding/height/width и именно TextUnit-значение вроде .sp для fontSize, и точно так же ловит несовпадение на этапе компиляции.

Отвечено вопросов: 0 из 4
Чем отличается sp от dp по факту исполнения, если пользователь НЕ менял настройки размера шрифта в системе?
Почему в демо cardPadding(padding: Dp) и titleFontSize(fontSize: Sp) — это ДВЕ разные сигнатуры, а не одна с типом Float?
На макете размер отступа между блоками карточки подписан числом «8» без единицы измерения рядом. Как определить, dp это или sp?
Что хранится внутри объекта Dp(12f) или Sp(16f) во время выполнения программы — что-то более сложное, чем просто число?
Отвечено верно: 0 из 4

Box, Column, Row: три контейнера для макета​

Прошлая глава уже разобрала Column (сверху вниз) и Row (слева направо). Макет карточки товара не обходится без третьего контейнера — Box, который до сих пор в курсе не встречался.

Box кладёт все свои дочерние элементы ДРУГ НА ДРУГА, а не рядом: первый вызванный внутри Box элемент рисуется первым, а каждый следующий — ПОВЕРХ уже нарисованных. Официальная документация Compose именно так и объясняет назначение Box: «put elements on top of another» («расположить элементы друг на друге»). Это ровно то, что нужно для бейджа скидки на макете: серый прямоугольник-заглушка изображения и красный бейдж с текстом занимают одно и то же место на экране, один поверх другого — а не выстроены в ряд или в столбец.

Прежде чем смотреть на настоящий Box, стоит увидеть сам принцип «порядок вызова определяет порядок наложения» на чистом Kotlin — в противовес Column, где каждый вызов получает своё, отдельное место.

fun myBox(content: () -> Unit) { println("--- Box: каждый следующий вызов рисует СВЕРХУ предыдущего ---") content() } fun myColumn(content: () -> Unit) { println("--- Column: каждый следующий вызов получает своё место НИЖЕ предыдущего ---") content() } fun main() { myBox { println("Слой 1: фон-заглушка изображения") println("Слой 2: бейдж скидки (лёг поверх слоя 1)") } myColumn { println("Блок 1: заголовок товара") println("Блок 2: цена (встал ПОД блоком 1, а не поверх)") } }

Разбор по строкам​

  • Строки 1–4 и 6–9: myBox/myColumn — обе функции принимают трейлинг-лямбду content и просто вызывают её — сама разница между Box и Column здесь не в коде функций (он одинаковый), а в том, ЧТО именно с элементами внутри делает настоящий Compose: Box кладёт их друг на друга, Column — друг под другом. Демо специально показывает это словами в подписи, раз нарисовать наложение печатью в консоль нельзя.
  • Строки 12–15: вызов myBox { ... } — два println внутри условно изображают два элемента, которые в настоящем Box заняли бы одно и то же место, один поверх другого, в порядке вызова.
  • Строки 16–19: вызов myColumn { ... } — те же по смыслу два элемента, но в настоящем Column они получили бы РАЗНЫЕ места, одно под другим, а не одно поверх другого.

Что выведет: обе шапки с чёрточкой ---, и под каждой — свои две строки, в порядке вызова. Печать одинакова для обеих функций — но за этим одинаковым выводом в консоль стоит совершенно разное поведение настоящих Box и Column на экране: Compose помнит порядок вызова и либо накладывает элементы друг на друга (Box), либо раскладывает их по отдельным местам (Column) — то, что в тексте демо просто проговорено словами.

contentAlignment и Modifier.align: где именно окажется элемент внутри Box​

Внутри Box элементы накладываются друг на друга, но по умолчанию — все они прижаты к верхнему левому углу. Чтобы бейдж скидки оказался именно в правом верхнем углу картинки, как на макете, нужно явно задать его позицию — двумя разными способами.

Способ 1 — contentAlignment у самого Box: задаёт положение СРАЗУ ДЛЯ ВСЕХ детей Box, если у них самих нет собственной настройки.

Нажми на часть выражения

Способ 2 — Modifier.align(...) у КОНКРЕТНОГО ребёнка: переопределяет contentAlignment только для одного элемента, не трогая остальные. Именно этот способ нужен для бейджа: заглушка изображения занимает весь Box целиком (ей выравнивание не важно), а вот бейдж должен оказаться именно в углу.

Нажми на часть выражения

Почему align существует только внутри Box​

Заметь формулировку в разборе .align выше: «доступна только внутри Box». Это не случайность и не искусственное ограничение — align в настоящем Compose объявлена как функция-расширение (extension function) специально для типа BoxScope, а BoxScope существует только внутри трейлинг-лямбды Box { ... }, куда его подставляет сам Compose. Вне этого места у компилятора просто нет объекта подходящего типа, у которого можно было бы вызвать align. Смоделируем этот же механизм на чистом Kotlin, без единой строчки Compose.

interface BoxScope class Position(val x: Int, val y: Int) fun BoxScope.place(x: Int, y: Int): Position = Position(x, y) fun myBox(content: BoxScope.() -> Unit) { val scope = object : BoxScope {} scope.content() } fun main() { myBox { val badgePosition = place(x = 100, y = 0) println("Бейдж внутри Box: x=${badgePosition.x}, y=${badgePosition.y}") } }

Разбор по строкам​

  • Строка 1: interface BoxScope — пустой интерфейс-маркер, точная модель настоящего BoxScope: сам по себе он ничего не содержит, а служит лишь «пропуском», подтверждающим, что код выполняется именно внутри Box.
  • Строка 4: fun BoxScope.place(x: Int, y: Int): Position — функция-расширение (extension function) типа BoxScope, ровно как .dp было функцией-расширением Int в прошлой главе — только здесь получателем (receiver) выступает не число, а тип-маркер BoxScope. Вызвать place(...) можно только там, где ЕСТЬ объект типа BoxScope, у которого её вызвать.
  • Строка 6: fun myBox(content: BoxScope.() -> Unit) — новый вид типа функции: BoxScope.() -> Unit вместо знакомого () -> Unit означает «лямбда с получателем» — внутри её тела уже ЕСТЬ неявный this типа BoxScope, и все функции-расширения BoxScope, включая place, доступны без явного указания объекта. Это тот же механизм, которым в настоящем Compose объявлены content у Box, Row, Column.
  • Строки 7–8: val scope = object : BoxScope {} и scope.content() — myBox создаёт конкретный объект нужного типа и вызывает переданную лямбду именно НА НЁМ — отсюда внутри лямбды place(...) внезапно становится доступен, хотя нигде явно не импортировался и не создавался читателем кода.
  • Строки 12–13: myBox { val badgePosition = place(x = 100, y = 0); ... } — внутри трейлинг-лямбды place вызывается как будто это обычная функция без получателя — а фактически это вызов scope.place(...), просто неявный.

Что выведет: Бейдж внутри Box: x=100, y=0. Ключевой вывод не в числах, а в том, ЧТО это демо доказывает: place (аналог настоящего align) технически недоступен НИГДЕ, кроме как внутри content: BoxScope.() -> Unit — то есть внутри myBox { ... }. Попытка вызвать его в main() напрямую, вне myBox { ... }, дала бы ту же самую ошибку компилятора, что и вызов weight() вне Row/Column — «Unresolved reference» — потому что вне подходящего receiver-объекта функция-расширение попросту не находится. Эта же ошибка разобрана дальше, в разделе «Типичные ошибки».

Отвечено вопросов: 0 из 4
Что произойдёт с двумя Text внутри Box { Text("Раз"); Text("Два") }, если не задавать contentAlignment вообще?
На карточке товара внутри Box два элемента: серая заглушка изображения на весь Box и бейдж скидки в углу. Почему для бейджа используют именно Modifier.align, а не только contentAlignment у самого Box?
Чем принципиально отличается порядок вызова элементов внутри Box от порядка вызова внутри Column?
Alignment.TopEnd — что означает часть "End" в контексте выравнивания слева направо, как в русском тексте?
Отвечено верно: 0 из 4

Arrangement и Alignment: где именно окажется каждый элемент​

У Row и Column — по два параметра позиционирования каждый, а не один общий, как contentAlignment у Box. Причина в том, что у Row и Column есть две принципиально разные оси: главная (main axis) — та, вдоль которой контейнер выстраивает элементы, и поперечная (cross axis) — перпендикулярная ей.

  • Arrangement отвечает за главную ось — КАК распределить элементы вдоль направления самого контейнера: у Row это горизонталь (horizontalArrangement), у Column — вертикаль (verticalArrangement).
  • Alignment отвечает за поперечную ось — КАК выровнять элементы относительно направления, перпендикулярного контейнеру: у Row это вертикаль (verticalAlignment), у Column — горизонталь (horizontalAlignment).

Официальная документация Compose формулирует это правило именно так: чтобы задать позицию детей внутри Row, указывают horizontalArrangement и verticalAlignment; для Column — наоборот, verticalArrangement и horizontalAlignment. Имя параметра всегда называет ТУ ось, за которую он отвечает, а не контейнер, в котором он стоит — отсюда и кажущаяся на первый взгляд путаница у Row, где verticalAlignment пишут чаще, чем horizontalArrangement.

КонтейнерГлавная осьПараметр главной осиПоперечная осьПараметр поперечной оси
RowгоризонтальhorizontalArrangementвертикальverticalAlignment
ColumnвертикальverticalArrangementгоризонтальhorizontalAlignment
Boxнет главной оси — только наложение—обе сразуcontentAlignment (и Modifier.align для отдельного ребёнка)

Значения Arrangement (одинаковый набор что для horizontalArrangement, что для verticalArrangement — меняется только направление):

ЗначениеЧто делает
Arrangement.Start / Arrangement.Topвсе элементы прижаты к началу контейнера, свободное место — в конце
Arrangement.End / Arrangement.Bottomвсе элементы прижаты к концу, свободное место — в начале
Arrangement.Centerвсе элементы сгруппированы в середине, свободное место поровну по обоим краям
Arrangement.SpaceBetweenсвободное место — только МЕЖДУ элементами, по краям — впритык, без зазора
Arrangement.SpaceAroundкаждый элемент получает свободное место с обеих сторон, но по краям контейнера места вдвое меньше, чем между элементами
Arrangement.SpaceEvenlyвсе промежутки — включая края контейнера — абсолютно одинаковые
Arrangement.spacedBy(8.dp)фиксированный зазор между элементами, без привязки к свободному месту — противоположность предыдущим четырём вариантам

Разница между SpaceBetween, SpaceAround и SpaceEvenly на словах звучит похоже, но числа расставляют всё по местам. Посчитаем зазоры вручную для ряда шириной 300, где три элемента по 60 занимают в сумме 180, а 120 остаётся свободными — ровно та же арифметика, которую Compose делает сам внутри Row/Column.

fun main() { val containerWidth = 300 val itemWidth = 60 val itemCount = 3 val freeSpace = containerWidth - itemWidth * itemCount val gapsBetween = itemCount - 1 println("SpaceBetween: зазор между элементами=${freeSpace / gapsBetween}, по краям=0") val aroundPerItem = freeSpace / itemCount println("SpaceAround: по краям=${aroundPerItem / 2}, между элементами=$aroundPerItem") val evenlyGaps = itemCount + 1 println("SpaceEvenly: каждый промежуток=${freeSpace / evenlyGaps}") }

Что выведет: SpaceBetween: зазор между элементами=60, по краям=0, SpaceAround: по краям=20, между элементами=40, SpaceEvenly: каждый промежуток=30. Три стратегии делят одни и те же свободные 120 единиц по-разному: SpaceBetween делит их на gapsBetween = itemCount - 1 = 2 промежутка МЕЖДУ элементами и ничего не оставляет по краям; SpaceAround даёт каждому элементу свою «личную» долю свободного места с двух сторон, но соседние элементы делят пространство пополам между собой (отсюда между элементами = 40, вдвое больше, чем по одному краю — 20); SpaceEvenly делит свободное место на itemCount + 1 = 4 РАВНЫХ промежутка, включая оба края.

COMPOSE · Arrangement — КУДА ДЕВАЕТСЯ СВОБОДНОЕ МЕСТОSpaceBetweenмежду элементами 60 · по краям 0SpaceAroundмежду элементами 40 · по краям 20SpaceEvenlyвсе промежутки одинаковые — по 30
SpaceBetween, SpaceAround и SpaceEvenly по-разному делят одно и то же свободное место между элементами ряда.

Значения Alignment для поперечной оси Row/Column — набор всего из трёх штук на каждую, потому что у поперечной оси нет середины между несколькими элементами, только положение самого блока:

Где используетсяЗначения
horizontalAlignment у ColumnAlignment.Start, Alignment.CenterHorizontally, Alignment.End
verticalAlignment у RowAlignment.Top, Alignment.CenterVertically, Alignment.Bottom
contentAlignment у Boxвсе 9 сочетаний: TopStart, TopCenter, TopEnd, CenterStart, Center, CenterEnd, BottomStart, BottomCenter, BottomEnd — потому что у Box, в отличие от Row и Column, обе оси равноправны

Девять сочетаний — это на самом деле просто две независимые координаты (по горизонтали и по вертикали), перемноженные друг на друга: 3 варианта по одной оси × 3 по другой. Посчитаем реальные координаты (x — слева, y — сверху) для трёх из девяти сочетаний, если сам Box — 100×100, а ребёнок внутри — 24×24:

fun main() { val boxWidth = 100 val boxHeight = 100 val childWidth = 24 val childHeight = 24 println("TopEnd: x=${boxWidth - childWidth}, y=0") println("Center: x=${(boxWidth - childWidth) / 2}, y=${(boxHeight - childHeight) / 2}") println("BottomStart: x=0, y=${boxHeight - childHeight}") }

Что выведет: TopEnd: x=76, y=0, Center: x=38, y=38, BottomStart: x=0, y=76. TopEnd — «верх» значит y=0 (самый верх), «конец» значит ребёнок прижат к правому краю (x = ширина Box − ширина ребёнка); Center делит остаток пополам по обеим осям сразу; BottomStart — зеркальное отражение TopEnd, прижато к левому краю и низу. Ровно эту же арифметику — «где взять свободное место и как его распределить по краю или пополам» — Compose делает сам для contentAlignment, только на настоящих размерах экрана, а не на придуманных 100×100.

COMPOSE · contentAlignment — 9 ПОЗИЦИЙ В BoxBox100×100TopEndx=76, y=0Centerx=38, y=38BottomStartx=0, y=763 позиции по горизонтали × 3 по вертикали — все 9 сочетаний Alignment
contentAlignment в Box — это две независимые координаты сразу: 3 варианта по горизонтали × 3 по вертикали дают 9 сочетаний.
Нажми на часть выражения
Отвечено вопросов: 0 из 4
В Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.Top) — какой из двух параметров отвечает за главную (горизонтальную) ось, а какой — за поперечную (вертикальную)?
Чем Arrangement.SpaceBetween отличается от Arrangement.SpaceEvenly?
Почему у Box всего один параметр contentAlignment, а не два, как у Row и Column?
Карточка товара: нужно, чтобы цена была слева, а кнопка "В корзину" — справа, с пустым местом между ними, без зазора по самым краям строки. Какое значение Arrangement подходит?
Отвечено верно: 0 из 4

Типичные ошибки чтения макета​

Важно

Все сообщения ниже сняты с настоящей сборки, а не пересказаны: библиотечный модуль Android, Kotlin 2.0.21, Compose BOM 2024.10.01, плагин сборки 8.7.3, compileSdk 34. Формулировки компилятора меняются от версии к версии — на Kotlin 1.x та же ошибка про weight звучала как Unresolved reference: weight, без кавычек и точки. Если у вас текст отличается, сверьте версии, прежде чем искать другую причину.

Ошибка 1. dp вместо sp для текста.

Text(text = "Цена", fontSize = 16.dp)
e: Probe.kt:5:23 None of the following candidates is applicable:
fun Text(text: AnnotatedString, modifier: Modifier = ..., color: Color = ..., fontSize: TextUnit = ..., …): Unit
fun Text(text: String, modifier: Modifier = ..., color: Color = ..., fontSize: TextUnit = ..., …): Unit

Сообщение выглядит пугающе и как будто не по делу: компилятор не называет виноватый аргумент, а вываливает список всех подходящих по имени функций. Читать его надо так — раз ни одна не подошла, ищите в сигнатуре свой параметр: fontSize: TextUnit. Вот и ответ, .dp даёт Dp, а нужен TextUnit, то есть .sp.

Хуже другой случай — когда размер задан через TextStyle с fontSize в sp, а отступы вокруг текста посчитаны «на глаз» в тех же числах. Тогда при увеличенном системном шрифте текст вырастет, а отступы — нет, и строка вылезет за карточку.

Ошибка 2. Позиционный аргумент Box.

Box(Alignment.Center) { Text("Привет") }
e: Probe.kt:6:27 Argument type mismatch: actual type is 'androidx.compose.ui.Alignment',
but 'androidx.compose.ui.Modifier' was expected.

Первый безымянный параметр Box — это modifier, а не contentAlignment. Писать надо с именем: Box(contentAlignment = Alignment.Center).

Ошибка 3. weight вне Row и Column.

Box {
Text("Слева", modifier = Modifier.weight(1f))
}
e: Probe.kt:6:59 Unresolved reference 'weight'.

weight — функция-расширение для RowScope и ColumnScope. Внутри Box такого получателя нет, и имя просто не находится. То же правило работает в обратную сторону: Modifier.align(Alignment.TopEnd) с двумя координатами существует только внутри Box.

Ошибка 4. Опечатка в имени именованного аргумента.

Row(horizontalArrangment = Arrangement.SpaceBetween) { }
e: Probe.kt:5:27 No parameter with name 'horizontalArrangment' found.

Сообщение точное, но взгляд по нему скользит: Arrangment без e выглядит почти как правильное слово. Если параметр «не находится», а имя выглядит верным — читайте его по буквам.

Отвечено вопросов: 0 из 3
Компилятор на вызове Text выдал «None of the following candidates is applicable» и список перегрузок. Как это читать?
Box(Alignment.Center) { … } не компилируется. Почему?
Modifier.weight(1f) внутри Box даёт «Unresolved reference». Почему?
Отвечено верно: 0 из 3

Слова главы​

mockup
1 / 8

Потренируйся печатать​

Цель: скорость ≥ 110 зн/мин, точность ≥ 90%

Box(contentAlignment = Alignment.TopEnd) {
клавиша ``клавиша 11клавиша 22клавиша 33клавиша 44клавиша 55клавиша 66клавиша 77клавиша 88клавиша 99клавиша 00клавиша --клавиша ==клавиша ⌫⌫клавиша TabTabклавиша QQклавиша WWклавиша EEклавиша RRклавиша TTклавиша YYклавиша UUклавиша IIклавиша OOклавиша PPклавиша [[клавиша ]]клавиша \\клавиша Caps LockCaps Lockклавиша AAклавиша SSклавиша DDклавиша FFклавиша GGклавиша HHклавиша JJклавиша KKклавиша LLклавиша ;;клавиша ''клавиша EnterEnterклавиша ShiftShiftклавиша ZZклавиша XXклавиша CCклавиша VVклавиша BBклавиша NNклавиша MMклавиша ,,клавиша ..клавиша //клавиша ShiftShiftклавиша CtrlCtrlклавиша WinWinклавиша AltAltклавиша ПробелПробелклавиша AltAltклавиша WinWinклавиша MenuMenuклавиша CtrlCtrl
следующая: B — указательный левой руки (+ Shift)

Строка позиционирования Row — то, что придётся набирать при каждом разведении элементов по краям:

Цель: скорость ≥ 120 зн/мин, точность ≥ 90%

horizontalArrangement = Arrangement.SpaceBetween

Символы и приёмы главы​

Символ / приёмКак называетсяЧто делаетПример
Box { }контейнер наложениякладёт детей друг на друга по порядку вызова, каждый следующий — поверх предыдущегоBox { Image(...); Icon(...) }
contentAlignmentпараметр Boxзадаёт позицию по умолчанию для всех детей Box сразу по обеим осямBox(contentAlignment = Alignment.Center)
Modifier.align(...)модификатор внутри Boxпереопределяет позицию одного конкретного ребёнка, доступен только внутри BoxModifier.align(Alignment.TopEnd)
horizontalArrangement / verticalArrangementпараметр главной осираспределяет элементы вдоль направления самого Row/ColumnRow(horizontalArrangement = Arrangement.SpaceBetween)
horizontalAlignment / verticalAlignmentпараметр поперечной осивыравнивает элементы перпендикулярно направлению Row/ColumnColumn(horizontalAlignment = Alignment.CenterHorizontally)
16.dpединица размера/отступаdensity-independent pixels — для всего, кроме размера текстаModifier.padding(12.dp)
16.spединица размера текстаscale-independent pixels — учитывает системную настройку размера шрифтаText(fontSize = 16.sp)
Color(0xAARRGGBB)конструктор цвета из числачетыре пары hex-цифр: прозрачность, красный, зелёный, синий каналыColor(0xFFE53935)
Углубиться

Как Compose на самом деле решает размер Box, Column и Row. Всё, что описано в этой главе, — снаружи: КАК элементы позиционируются друг относительно друга, если их размер уже известен. Но откуда сам Box или Column вообще узнаёт, какого размера ему быть? Ответ — трёхшаговый процесс измерения: контейнер сначала измеряет каждого ребёнка (спрашивает, каким тот хочет быть при доступных ограничениях по ширине и высоте), затем решает собственный размер на основе размеров детей и родительских ограничений, и только потом расставляет детей по вычисленным позициям. Row и Column — это готовые, уже написанные за тебя реализации этого процесса под конкретную стратегию; когда их не хватает — например, нужен ряд, сам переносящий лишние элементы на новую строку, — Compose позволяет написать процесс измерения вручную через низкоуровневый Layout и MeasurePolicy.

Почему на макете чемпионата важна каждая единица измерения. Критерии оценки часто автоматизированы: скрипт сравнивает скриншот собранного экрана с эталонным макетом попиксельно, с небольшим допуском. Отступ в 16.dp вместо 12.dp или текст в dp вместо sp — это не стилистическая мелочь, а формальное несовпадение, которое такой скрипт обнаружит и оценит как ошибку, даже если экран визуально выглядит «почти так же».

Дальше по теме:

  • Jetpack Compose Layouts — разбор своего Row, который сам переносит элементы на новую строку.
  • Custom layouts in Compose — официальное объяснение трёхшагового процесса измерения.

Куда дальше: проверенные ресурсы​

Русские материалы​

Официальная документация (на английском)​

  • Compose layout basics — открой за первоисточником примеров про Row, Column, Box, Arrangement и Alignment из этой главы.
  • Support different pixel densities — открой за официальным определением dp, sp и таблицей плотностей экрана.
  • Guide to Dev Mode — Figma Help Center — открой, чтобы увидеть, как выглядит инструмент, которым дизайнеры готовят те самые макеты.
  • Basic Layouts in Compose (codelab) — пошаговый практикум на 15 шагов.

Проверка по главе​

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Что должен уметь​

  • Читать в макете отступы и размеры (числа в dp) и текстовые размеры (числа в sp), не путая единицы между собой.
  • Раскладывать hex-цвет вроде #E53935 на каналы ARGB и объяснять, что означает каждая пара цифр.
  • Выбирать между Box (наложение), Column (сверху вниз) и Row (слева направо) по тому, что требует макет.
  • Использовать contentAlignment у Box для общей позиции содержимого и Modifier.align(...) — для позиции одного элемента.
  • По памяти сопоставлять Row/Column с параметрами главной и поперечной оси.
  • Узнавать по сообщению компилятора четыре типичных ошибки этой темы и чинить каждую.

Видео по теме

ВидеоУрок 5: Column & Row – позиционирование | Курс Android Jetpack ComposeKotlin & Android с нуля – Иван Ветров
ВидеоRow, Column & Box Layouts in Jetpack Compose | Android StudioAndroid Knowledge
ВидеоRow Layouts with Jetpack ComposeCodingWithMitch
ВидеоColumn Layouts with Jetpack ComposeCodingWithMitch
ВидеоRows and Columns - Jetpack ComposeStevdza-San

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.