Вёрстка по макету
Зачем это нужно
На чемпионате «Профессионалы» задание почти никогда не звучит как «сделай красивый экран, на своё усмотрение». Вместо этого судьи выдают макет (mockup) — картинку или ссылку на Figma с уже готовым дизайном — и критерии оценки, которые сверяют твой экран с этим макетом буквально попиксельно: не тот отступ, не тот размер шрифта, не тот цвет — минус баллы, даже если экран выглядит красиво на глаз. Умение прочитать макет и перенести его в код так же важно, как умение писать сам код.
Прошлые главы уже дали строительные блоки — Column, Row, Modifier, Text, Button, .dp — но использовали их для простых, придуманных с нуля экранов. Эта глава переворачивает задачу: не «придумай экран», а «вот готовый макет с конкретными числами — воспроизведи его точно». Разберём макет карточки товара (product card) — один из самых частых элементов на экранах интернет-магазинов и один из самых частых элементов в заданиях по мобильной разработке на чемпионате — по шагам: как читать числа и цвета на макете, как выбрать между Column, Row и новым для этого курса контейнером Box, как понять, какое именно свойство Arrangement или Alignment нужно, и как не оставить в коде россыпь голых чисел, а вынести их в константы.
Готового набора цветов, отступов и текстовых стилей на все случаи (design system, ui-kit) в этой главе ещё нет — так, как проект обычно и начинается: с одной карточки и конкретных чисел из макета. Собрать из таких чисел переиспользуемую систему — тема следующей главы.
Как и в двух прошлых главах, сам Jetpack Compose (Box, Column, Row, Text, Button) в браузере не запускается — нужен настоящий Android-проект. Compose-код здесь снова даётся статичными блоками с построчным разбором. А там, где под капотом скрывается обычный Kotlin — числа, строки, функции, объекты, — глава, как и раньше, даёт исполняемые примеры, которые можно запустить прямо здесь.
- Зачем это нужно
- Как читать макет: отступы, размеры, цвета
- dp и sp: чем отличаются единицы измерения
- Box, Column, Row: три контейнера для макета
- Arrangement и Alignment: где именно окажется каждый элемент
- Перенос макета в код: карточка товара шаг за шагом
- Вынос размеров в константы
- Типичные ошибки
- Слова главы
- Потренируйся печатать
- Символы и приёмы главы
- Куда дальше: проверенные ресурсы
- Челлендж ⭐
- Что должен уметь
Как читать макет: отступы, размеры, цвета
Независимо от инструмента, которым пользовался дизайнер (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, фон #EEEEEE | Modifier.height(Dimens.ImageHeight).background(Color(0xFFEEEEEE)) |
| Бейдж скидки | маленький прямоугольник в правом верхнем углу картинки, заливка #E53935, вокруг текста отступ «4» | фон #E53935, отступ 4dp | Modifier.align(Alignment.TopEnd).background(Color(0xFFE53935)).padding(Dimens.BadgePadding) |
| Заголовок товара | подпись рядом с текстом «16sp / Medium» | размер шрифта 16sp | fontSize = Dimens.TitleFontSize, где TitleFontSize = 16.sp |
| Цена | подпись «18sp / Bold» | размер шрифта 18sp | fontSize = Dimens.PriceFontSize |
| Ряд «цена + кнопка» | стрелка от левого края до правого с засечками на обоих концах, подпись «space-between» | распределить по краям, ничего не оставляя посередине | horizontalArrangement = Arrangement.SpaceBetween |
А теперь потренируй сам навык чтения чисел с макета — на глаз, как это приходится делать, когда стрелки с подписями на картинке видны, а точное значение нужно назвать самому.
Цвет как число: 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 внутри себя делает ровно эти же четыре строки, просто один раз, при создании объекта — а не при каждом обращении к цвету.
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, эта настройка перестанет работать именно для этого текста — он останется мелким, даже если пользователь специально увеличил шрифт во всей системе.
Правило простое: если число со макета относится к тексту (размер шрифта, межстрочный интервал) — .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, и точно так же ловит несовпадение на этапе компиляции.
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-объекта функция-расширение попросту не находится. Эта же ошибка разобрана дальше, в разделе «Типичные ошибки».
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 РАВНЫХ промежутка, включая оба края.
Значения Alignment для поперечной оси Row/Column — набор всего из трёх штук на каждую, потому что у поперечной оси нет середины между несколькими элементами, только положение самого блока:
| Где используется | Значения |
|---|---|
horizontalAlignment у Column | Alignment.Start, Alignment.CenterHorizontally, Alignment.End |
verticalAlignment у Row | Alignment.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.
Перенос макета в код: карточка товара шаг за шагом
Спецификация из первого раздела уже собрана. Дальше — тот же порядок, в котором обычно и собирают карточку на практике: сначала внешняя рамка, потом слой с изображением и бейджем, потом текст, потом ряд с ценой.
Шаг 1. Внешняя рамка карточки
Карточка целиком — вертикальный список из трёх блоков (изображение, заголовок, цена), значит внешний контейнер — Column. Из первой строки таблицы спецификации («Карточка целиком»): фон белый (#FFFFFF), внутренний отступ — 12dp со всех сторон, ширина — на весь доступный экран.
@Composable
fun ProductCard(title: String, price: String, discountPercent: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFFFFFFF))
.padding(12.dp)
) {
// содержимое — дальше, шаг за шагом
}
}
Modifier.background(...) — ещё один модификатор из той же цепочки, что и .fillMaxSize()/.padding() из прошлой главы: принимает Color и красит фон элемента этим цветом, ничего больше не меняя в остальной цепочке.
Шаг 2. Изображение и бейдж скидки — слой на Box
Внутри карточки первым идёт изображение — но по спецификации на него ещё накладывается бейдж скидки. Это тот самый случай «два элемента в одном месте», под который заведён Box.
Box {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.background(Color(0xFFEEEEEE))
)
Text(
text = "-$discountPercent%",
fontSize = 12.sp,
modifier = Modifier
.align(Alignment.TopEnd)
.background(Color(0xFFE53935))
.padding(4.dp)
)
}
Обрати внимание: здесь ДВА вложенных Box — внешний просто группирует «слой с изображением», а внутренний, поменьше, служит серой заглушкой самого изображения (в настоящем проекте на его месте была бы функция AsyncImage для загрузки настоящей картинки по imageUrl, но она не входит в тему этой главы). Modifier.align(Alignment.TopEnd) у Text — то самое переопределение позиции для ОДНОГО ребёнка, разобранное в прошлом разделе: заглушка изображения растянута на весь Box без выравнивания, а вот бейдж явно прижат в правый верхний угол.
Шаг 3. Заголовок и цена с кнопкой
Заголовок — обычный Text с fontSize из спецификации. Цена и кнопка — Row с Arrangement.SpaceBetween, как и определено в таблице выше.
Text(text = title, fontSize = 16.sp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = price, fontSize = 18.sp)
Button(onClick = { }) {
Text(text = "В корзину")
}
}
verticalAlignment = Alignment.CenterVertically здесь не из таблицы спецификации напрямую, а логическое следствие того, что цена и кнопка — разной высоты (текст ниже кнопки), и без выравнивания по центру они бы «прилипли» к верху ряда неровно.
Собираем всё вместе
@Composable
fun ProductCard(title: String, price: String, discountPercent: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFFFFFFF))
.padding(12.dp)
) {
Box {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.background(Color(0xFFEEEEEE))
)
Text(
text = "-$discountPercent%",
fontSize = 12.sp,
modifier = Modifier
.align(Alignment.TopEnd)
.background(Color(0xFFE53935))
.padding(4.dp)
)
}
Text(text = title, fontSize = 16.sp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = price, fontSize = 18.sp)
Button(onClick = { }) {
Text(text = "В корзину")
}
}
}
}
Разбор по строкам
- Строка 2:
fun ProductCard(title: String, price: String, discountPercent: Int)— три параметра снаружи, ровно столько, сколько реально меняется от товара к товару; всё остальное (отступы, цвета, размеры шрифта) — фиксированные числа из макета, а не параметры. - Строки 3–8: внешний
Column— из шага 1, без изменений: фон, ширина на весь экран, отступ 12dp со всех сторон. - Строки 9–24: внешний
Boxс двумя детьми — из шага 2: серая заглушка изображения растянута на весьBox(.fillMaxWidth().height(120.dp)), аTextс бейджем прижат в угол через.align(Alignment.TopEnd), покрашен красным и получил свой собственный маленький отступ в 4dp — ВНУТРИ бейджа, а не относительно всей карточки. - Строка 25:
Text(text = title, fontSize = 16.sp)— заголовок, второй по счёту ребёнок внешнегоColumn, значит встанет НИЖЕ блока с изображением. - Строки 26–35:
Rowс ценой и кнопкой — третий и последний ребёнокColumn;SpaceBetweenразводит цену и кнопку по краям строки. Всего у внешнегоColumnтри ребёнка:Boxсо слоями,Textзаголовка и этотRow.
Что должно появиться на экране: белая карточка на всю ширину, сверху — серый прямоугольник изображения с красным бейджем -N% в правом верхнем углу, ниже — заголовок товара, ещё ниже — строка, где слева цена, а справа кнопка «В корзину».
Собранную карточку можно покрутить вживую прямо здесь: слева — упрощённый мокап экрана, справа — Compose-псевдокод, сгенерированный из того же самого дерева вёрстки. Подвигай padding, arrangement и fontSize и смотри, как едет вёрстка — и как синхронно переписывается код. Одно упрощение: Modifier.align(Alignment.TopEnd) эта песочница не умеет, поэтому бейдж скидки здесь прижат к углу по умолчанию — TopStart.
Column(modifier = Modifier.fillMaxWidth().background(Color(0xFFFFFFFF)).padding(12.dp)) {
Box {
Box(modifier = Modifier.fillMaxWidth().height(120.dp).background(Color(0xFFEEEEEE))) {
}
Text("-20%", fontSize = 12.sp, modifier = Modifier.background(Color(0xFFE53935)).padding(4.dp))
}
Text("Заголовок товара", fontSize = 16.sp)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("1 990 ₽", fontSize = 18.sp)
Button(onClick = { }) { Text("В корзину") }
}
}Как это устроено под капотом
А чтобы структура «внешний контейнер → изображение → заголовок → ряд с ценой» уложилась в голове, собери её ещё раз сам — из перемешанных строк вёрстки.
Вынос размеров в константы
В собранном ProductCard числа — 12.dp, 120.dp, 4.dp, 16.sp, 18.sp, 12.sp и два цвета — разбросаны по всему телу функции. Пока карточка одна, это терпимо. Но макет обычно содержит МНОГО похожих элементов — карточки в списке, другие экраны с теми же отступами, — и голое число 12.dp, повторённое в десяти местах, называют магическим числом (magic number): непонятно без контекста, что оно значит, и любое его изменение требует искать и править каждое повторение по отдельности.
Решение — вынести числа в именованные константы один раз и обращаться к ним по имени. В Compose для этого чаще всего заводят отдельный объект (object — уже знакомый механизм из примера с Arrangement выше, только теперь свой собственный):
object Dimens {
val CardPadding = 12.dp
val ImageHeight = 120.dp
val BadgePadding = 4.dp
val TitleFontSize = 16.sp
val PriceFontSize = 18.sp
val BadgeFontSize = 12.sp
}
Правило val, а не const val, специфично именно для Dp/Sp/Color и подобных типов Compose. Там, где значение — обычный примитив (Int, String, Boolean), const val работает и даже предпочтительнее: он вычисляется на этапе компиляции, а не при первом обращении. Разница видна на простом примере — рефакторинг магических чисел «до и после», где сами числа — обычные Int, без единиц измерения:
fun describeCardBefore() {
println("ДО: отступ=12, высота изображения=120, мин. высота карточки=${120 + 12 * 2}")
}
object Dimens {
const val CardPadding = 12
const val ImageHeight = 120
}
fun describeCardAfter() {
println("ПОСЛЕ: отступ=${Dimens.CardPadding}, высота изображения=${Dimens.ImageHeight}, мин. высота карточки=${Dimens.ImageHeight + Dimens.CardPadding * 2}")
}
fun main() {
describeCardBefore()
describeCardAfter()
}
Что выведет: одинаковые числа в обеих строках — 144 для минимальной высоты что «до», что «после». Рефакторинг НЕ меняет результат — и не должен: цель не в новом поведении, а в том, что теперь 12 и 120 существуют в коде ровно один раз, под понятным именем, а не разбросаны по функциям. Если на макете отступ карточки завтра станет 16 вместо 12, в версии «после» достаточно поменять одну строку — const val CardPadding = 12 на 16 — и значение обновится сразу везде, где встречается Dimens.CardPadding. В версии «до» пришлось бы искать каждое отдельное вхождение числа 12 по всему файлу и гадать, какое из них — тот самый отступ, а какое — случайное совпадение с другим смыслом.
Типичные ошибки
Все пять ошибок этой главы воспроизведены и проверены вживую в этой же песочнице — тем же компилятором Kotlin, что стоит за встроенным KotlinPlay, — на минимальных классах, повторяющих структуру настоящих Compose-типов (Dp, Sp, Modifier, Alignment, RowScope) ровно настолько, насколько нужно, чтобы получить ту же самую диагностику компилятора.
1. sp передан туда, где ожидается Dp (и наоборот)
@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 setFontSize(fontSize: Sp) {}
fun main() {
setFontSize(16.dp)
}
Argument type mismatch: actual type is 'Dp', but 'Sp' was expected.
Перевод: «несовпадение типа аргумента: фактический тип Dp, а ожидался Sp». В настоящем Compose параметр fontSize ждёт значение типа TextUnit (тип, в который превращает число .sp), а 16.dp — это Dp, совсем другой тип: числа выглядят одинаково («шестнадцать»), но единицы измерения, а значит и типы Kotlin, разные.
Как починить: заменить суффикс на правильный по смыслу — .sp для размера текста, .dp для всего остального: setFontSize(16.sp).
2. Позиционный аргумент Box перепутан местами
Box(Alignment.TopEnd) {
Text(text = "Бейдж")
}
Argument type mismatch: actual type is 'Alignment', but 'Modifier' was expected.
Перевод: «несовпадение типа аргумента: фактический тип Alignment, а ожидался Modifier». У Box первый ПОЗИЦИОННЫЙ параметр (без явного имени) — modifier, а contentAlignment — только второй; если написать Box(Alignment.TopEnd) без имени contentAlignment =, компилятор пытается подставить Alignment.TopEnd на место modifier и обнаруживает несовпадение типов. Формулировка проверена на минимальном аналоге с той же сигнатурой параметров, что и у настоящего Box.
Как починить: указать имя параметра явно — Box(contentAlignment = Alignment.TopEnd) { ... }. Именованные аргументы для необязательных параметров, идущих не первыми, — не стиль, а необходимость: без имени компилятор ориентируется строго по порядку.
3. weight() вызван вне Row или Column
interface RowScope
fun RowScope.weight(value: Float): Unit {}
fun main() {
weight(1f)
}
Unresolved reference 'weight'.
Перевод: «неразрешённая ссылка weight». В настоящем Compose weight(...) (задаёт, какую долю свободного места в Row/Column займёт элемент) — не отдельная свободная функция, а функция-расширение, объявленная специально для RowScope/ColumnScope. Такую функцию можно вызвать ТОЛЬКО у объекта с подходящим типом-получателем (receiver) — то есть только внутри трейлинг-лямбды Row { ... } или Column { ... }, где Compose сам подставляет нужный scope. Внутри Box или вообще вне какого-либо контейнера тот же вызов weight(...) не находит подходящей функции — ровно то же самое случилось бы с Modifier.align(...) вне Box, разобранным чуть раньше в этой главе.
Как починить: использовать weight только внутри Row { ... } или Column { ... }, а не внутри Box или отдельно; если нужно занять долю свободного места ИМЕННО внутри Box, weight не подходит вообще — там за размер отвечают fillMaxWidth(fraction = ...) и подобные модификаторы без ограничения по scope.
4. const val с типом Dp — не примитив и не String
@JvmInline
value class Dp(val value: Float)
val Int.dp: Dp get() = Dp(this.toFloat())
const val CardPadding = 12.dp
fun main() {
println(CardPadding)
}
Const 'val' has type 'Dp'. Only primitive types and 'String' are allowed.
Перевод: «const val имеет тип Dp. Разрешены только примитивные типы и String». const val требует значения, которое компилятор может вычислить ЦЕЛИКОМ на этапе компиляции, без запуска программы, — а 16.dp фактически вызывает функцию (.dp — свойство-расширение), результат которой известен только во время выполнения. Именно поэтому в разделе про константы object Dimens использует обычный val, а не const val, для всех Dp/Sp-значений.
Как починить: заменить const val на обычный val: val CardPadding = 12.dp. const val оставить только для настоящих компайл-тайм значений — Int, String, Boolean и подобных примитивов, как в демо с Dimens.CardPadding: Int в разделе про рефакторинг.
5. Опечатка в имени именованного аргумента
fun myRow(horizontalArrangement: String = "start", content: () -> Unit) {}
fun main() {
myRow(arrangement = "center") { }
}
No parameter with name 'arrangement' found.
Перевод: «параметр с именем arrangement не найден». Настоящий параметр Row называется horizontalArrangement, а не просто arrangement — сокращённое имя, которое иногда кажется логичным по аналогии с contentAlignment у Box, компилятор не подставляет автоматически: имя именованного аргумента должно совпадать буквально.
Как починить: использовать точное имя параметра — horizontalArrangement у Row, verticalArrangement у Column. Автодополнение Android Studio почти всегда подсказывает правильное имя раньше, чем успеваешь ошибиться, но при написании кода руками (или на бумаге на чемпионате) эта ошибка встречается часто.
Слова главы
Потренируйся печатать
Объявление Box с выравниванием содержимого — то, что придётся набирать при каждом наложении элементов:
Цель: скорость ≥ 110 зн/мин, точность ≥ 90%
Box(contentAlignment = Alignment.TopEnd) {
Строка позиционирования Row из спецификации карточки товара:
Цель: скорость ≥ 120 зн/мин, точность ≥ 90%
horizontalArrangement = Arrangement.SpaceBetween
Символы и приёмы главы
| Символ / приём | Как называется | Что делает | Пример |
|---|---|---|---|
Box { } | контейнер наложения | кладёт детей друг на друга по порядку вызова, каждый следующий — поверх предыдущего | Box { Image(...); Icon(...) } |
contentAlignment | параметр Box | задаёт позицию по умолчанию для всех детей Box сразу по обеим осям | Box(contentAlignment = Alignment.Center) |
Modifier.align(...) | модификатор внутри Box | переопределяет позицию одного конкретного ребёнка, доступен только внутри Box | Modifier.align(Alignment.TopEnd) |
horizontalArrangement / verticalArrangement | параметр главной оси | распределяет элементы вдоль направления самого Row/Column | Row(horizontalArrangement = Arrangement.SpaceBetween) |
horizontalAlignment / verticalAlignment | параметр поперечной оси | выравнивает элементы перпендикулярно направлению Row/Column | Column(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) |
object Dimens { val X = ... } | объект-хранилище констант | выносит повторяющиеся числа макета в единственное именованное место | Dimens.CardPadding |
Углубиться
Как Compose на самом деле решает размер Box, Column и Row. Всё, что описано в этой главе, — снаружи: КАК элементы позиционируются друг относительно друга, если их размер уже известен. Но откуда сам Box или Column вообще узнаёт, какого размера ему быть? Ответ — трёхшаговый процесс измерения: контейнер сначала измеряет каждого ребёнка (спрашивает, каким тот хочет быть при доступных ограничениях по ширине/высоте — constraints), затем решает собственный размер на основе размеров детей и родительских ограничений, и только потом расставляет детей по вычисленным позициям. Row и Column — это, по сути, готовые, уже написанные за тебя реализации этого процесса под конкретную стратегию (в ряд или в столбец); когда готовых Row/Column/Box не хватает — например, нужен ряд, сам переносящий лишние элементы на новую строку, — Compose позволяет написать этот процесс измерения вручную через низкоуровневый Layout composable и MeasurePolicy.
Почему на макете чемпионата важна каждая единица измерения. Критерии оценки на реальных чемпионатах часто автоматизированы: скрипт сравнивает скриншот собранного экрана с эталонным макетом попиксельно, с небольшим допуском. Отступ в 16.dp вместо 12.dp или текст в dp вместо sp (который выглядит иначе при изменённых настройках доступности на тестовом устройстве) — это не стилистическая мелочь, а формальное несовпадение, которое такой скрипт обнаружит и оценит как ошибку, даже если экран визуально выглядит «почти так же».
Дальше по теме:
- Jetpack Compose Layouts — открой, чтобы увидеть, что происходит, когда готовых Row/Column/Box не хватает: разбор своего Row, который сам переносит элементы на новую строку.
- Custom layouts in Compose — официальное объяснение трёхшагового процесса измерения и
Layout/MeasurePolicyиз первого абзаца выше.
Куда дальше: проверенные ресурсы
Русские материалы
- Единицы измерения. Чем отличается dp (dip) от px. Screen Density — открой за более подробным объяснением плотности экрана и того, как именно Android пересчитывает dp в реальные пиксели на разных устройствах.
- Замена магического числа символьной константой — Refactoring.Guru — открой за классическим объяснением рефакторинга «магическое число → константа» вне контекста Android, тем же приёмом, что применён к
Dimensв этой главе.
Официальная документация (на английском)
-
Compose layout basics — открой за первоисточником примеров про Row, Column, Box, Arrangement и Alignment из этой главы.
-
Support different pixel densities — открой за официальным определением dp, sp и таблицей плотностей экрана (
ldpi…xxxhdpi). -
Guide to Dev Mode — Figma Help Center — открой, чтобы увидеть, как на практике выглядит инструмент, которым дизайнеры готовят те самые макеты с отступами, размерами и hex-цветами, разобранные в начале главы.
-
Basic Layouts in Compose (codelab) — открой для пошагового практикума на 15 шагов: собрать целое приложение-экран с поиском, карточками и адаптивной навигацией, используя Modifier, Arrangement, Alignment, Box, LazyRow и Scaffold.
-
Видео и материалы сообщества — ниже — ролики по теме и ссылки от студентов и преподавателей смотри в самом низу страницы.
Челлендж ⭐
ProductCard из раздела «Собираем всё вместе» уже готов. Дальше — без эмулятора, только чтением и написанием кода: реши обе ступени сам, а потом сверься с решением.
Ступень 1. На макете под ценой и кнопкой добавлена ещё одна строка — рейтинг товара: слева текст "★ 4.8", справа — "132 отзыва", разведённые по краям строки точно так же, как цена и кнопка выше. Допиши ProductCard вторым по счёту Row — последним, четвёртым ребёнком внешнего Column, — используя то же значение Arrangement, что и в ряду с ценой.
Ступень 2 ⭐. Представь, что кто-то в команде решил «сделать константы понадёжнее» и переписал object Dimens из раздела про вынос размеров так:
object Dimens {
const val CardPadding = 12.dp
const val ImageHeight = 120.dp
}
Объясни своими словами (без запуска кода — просто зная материал этой главы), какую именно ошибку это вызовет и почему val здесь работал, а const val — нет.
Решение (сначала попробуй сам)
Ступень 1:
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = "★ 4.8")
Text(text = "132 отзыва")
}
Этот Row — четвёртый и последний ребёнок внешнего Column (после Box, заголовка и ряда с ценой), значит встанет ниже ряда с ценой и кнопкой; Arrangement.SpaceBetween разводит два текста по краям строки той же стратегией, что и раньше.
Ступень 2 ⭐:
Ошибка будет той же самой, что разобрана в пункте 4 раздела «Типичные ошибки»: Const 'val' has type 'Dp'. Only primitive types and 'String' are allowed. — «const val имеет тип Dp. Разрешены только примитивные типы и String». 12.dp — не число, известное компилятору заранее, а результат вызова свойства-расширения .dp, который вычисляется во время выполнения программы; const val же требует значения, полностью готового ещё ДО того, как программа вообще запустится. Int, String, Boolean компилятор может вычислить сам на этапе компиляции — а вызов функции, даже такой маленькой, как .dp, для него уже не «просто число», а код, который нужно выполнить. Починка — вернуть обычный val, как в оригинальном Dimens.
Что должен уметь
- Читать в макете отступы и размеры (числа в dp) и текстовые размеры (числа в sp), не путая единицы между собой.
- Раскладывать hex-цвет вроде
#E53935на каналы ARGB и объяснять, что означает каждая пара цифр. - Выбирать между
Box(наложение элементов друг на друга),Column(сверху вниз) иRow(слева направо) по тому, что именно требует макет. - Использовать
contentAlignmentуBoxдля общей позиции содержимого иModifier.align(...)— для позиции одного конкретного элемента. - По памяти сопоставлять
Row/Columnс их параметрами главной и поперечной оси:horizontalArrangement/verticalAlignmentуRow,verticalArrangement/horizontalAlignmentуColumn. - Переносить макет карточки в код пошагово: внешний контейнер → слои с наложением → текст → ряды с распределением, — и читать получившийся код построчно.
- Выносить повторяющиеся числа макета в
objectс именованными константами, объясняя, почему дляDp/Sp-значений нуженval, а неconst val. - Узнавать по сообщению компилятора пять типичных ошибок этой темы — путаницу
dp/sp, перепутанный позиционный аргументBox,weightвнеRow/Column,const valс типомDp, опечатку в имени именованного аргумента — и чинить каждую.
Комментарии
Комментарии появятся после настройки. Нужен аккаунт GitHub — вход прямо в виджете выше.