Перейти к основному содержимому
06МОБИЛКА · ГЛАВА 06Вёрстка по макету

Вёрстка по макету

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

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

На чемпионате «Профессионалы» задание почти никогда не звучит как «сделай красивый экран, на своё усмотрение». Вместо этого судьи выдают макет (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 — числа, строки, функции, объекты, — глава, как и раньше, даёт исполняемые примеры, которые можно запустить прямо здесь.

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

Независимо от инструмента, которым пользовался дизайнер (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 0xFFshr 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нет главной оси — только наложениеобе сразуcontentAlignmentModifier.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

Перенос макета в код: карточка товара шаг за шагом

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

Шаг 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.

-20%
Заголовок товара
1 990 ₽В корзину
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("В корзину") }
    }
}
Параметры — превью и код меняются вместе
Row · arrangement
Измени параметры 3 раза и посмотри, как меняется код: 0 из 3
Как это устроено под капотом
Телефон слева — это не Android и не Compose, а обычный HTML: вёрстка описана JSON-деревом, где Column и Row превращаются во flexbox-контейнеры (flex-direction: column / row), Box — в CSS grid с наложением слоёв, а dp — в пиксели один к одному. Код справа генерируется из того же самого JSON-дерева обходом в глубину, поэтому превью и код физически не могут разойтись. Двигаешь ползунок — меняется одно поле в дереве, и React перерисовывает обе стороны сразу. Кнопка копирования отдаёт именно этот сгенерированный текст — его можно вставить в Android Studio как основу настоящего экрана.

А чтобы структура «внешний контейнер → изображение → заголовок → ряд с ценой» уложилась в голове, собери её ещё раз сам — из перемешанных строк вёрстки.

Кликай по строкам в правильном порядке — сверху вниз, как они идут в коде карточки. Клик по собранной строке возвращает её обратно.
Карточка пока пустая
Отвечено вопросов: 0 из 4
Почему в собранном ProductCard изображение и бейдж лежат внутри Box, а не внутри ещё одного Column?
В коде .padding(4.dp) стоит на самом Text с бейджем, ПОСЛЕ .background(Color(0xFFE53935)). Что произойдёт, если поменять их местами — сначала .padding(4.dp), потом .background(...)?
Строка Text(text = "-$discountPercent%", ...) — что здесь делает символ $ перед discountPercent?
Что было бы, если у Row с ценой и кнопкой убрать verticalAlignment = Alignment.CenterVertically?
Отвечено верно: 0 из 4

Вынос размеров в константы

В собранном 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 по всему файлу и гадать, какое из них — тот самый отступ, а какое — случайное совпадение с другим смыслом.

Отвечено вопросов: 0 из 4
Почему магическое число называют именно "магическим", а не просто "числом"?
Почему для object Dimens с Dp-значениями нужно писать val, а не const val, хотя для Int-значений const val работает нормально?
В рефакторинге describeCardBefore/After — что изменилось в РЕЗУЛЬТАТЕ работы программы?
Если Dimens.CardPadding используется в пяти разных местах файла ProductCard.kt, сколько строк нужно изменить, чтобы поменять отступ карточки на новое значение?
Отвечено верно: 0 из 4

Типичные ошибки

Все пять ошибок этой главы воспроизведены и проверены вживую в этой же песочнице — тем же компилятором 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 почти всегда подсказывает правильное имя раньше, чем успеваешь ошибиться, но при написании кода руками (или на бумаге на чемпионате) эта ошибка встречается часто.

Отвечено вопросов: 0 из 4
Какая из пяти ошибок этой главы возникает из-за того, что первый позиционный (безымянный) параметр Box — именно modifier, а не contentAlignment?
Чем ошибка 3 (weight вне Row/Column) объясняется на уровне механизма Kotlin, а не просто "так работает Compose"?
Почему val CardPadding = 12.dp работает без ошибок, а const val CardPadding = 12.dp — нет?
myRow(arrangement = "center") при объявлении fun myRow(horizontalArrangement: String = "start", ...) — почему это не сработает даже с правильным типом значения (String)?
Отвечено верно: 0 из 4

Слова главы

mockup
1 / 8

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

Объявление Box с выравниванием содержимого — то, что придётся набирать при каждом наложении элементов:

Цель: скорость ≥ 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)
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 из первого абзаца выше.

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

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

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

  • Compose layout basics — открой за первоисточником примеров про Row, Column, Box, Arrangement и Alignment из этой главы.

  • Support different pixel densities — открой за официальным определением dp, sp и таблицей плотностей экрана (ldpixxxhdpi).

  • 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 — вход прямо в виджете выше.