Карточка по макету: от картинки к коду
Зачем это нужно
Прошлая глава научила читать макет: где dp, где sp, какой контейнер выбрать, как работает Arrangement. Теперь надо сделать то, ради чего всё это нужно — собрать по макету настоящий элемент.
Разберём карточку товара. Это один из самых частых элементов на экранах интернет-магазинов и один из самых частых элементов в заданиях по мобильной разработке на чемпионате. Собирать будем по шагам, в том же порядке, в каком карточку обычно и собирают на практике: сначала внешняя рамка, потом слой с изображением и бейджем, потом текст, потом ряд с ценой.
А в конце — то, что отличает готовую работу от наброска: вынос чисел макета в именованные константы и разбор пяти ошибок, на которых карточка ломается чаще всего.
Готового набора цветов, отступов и текстовых стилей на все случаи в этой главе ещё нет — так, как проект обычно и начинается: с одной карточки и конкретных чисел из макета. Собрать из таких чисел переиспользуемую систему — тема следующей главы.
Перенос макета в код: карточка товара шаг за шагом
Спецификация из первого раздела уже собрана. Дальше — тот же порядок, в котором обычно и собирают карточку на практике: сначала внешняя рамка, потом слой с изображением и бейджем, потом текст, потом ряд с ценой.
Шаг 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 почти всегда подсказывает правильное имя раньше, чем успеваешь ошибиться, но при написании кода руками (или на бумаге на чемпионате) эта ошибка встречается часто.
Слова главы
Потренируйся печатать
Цель: скорость ≥ 110 зн/мин, точность ≥ 90%
Column(modifier = Modifier.padding(Dimens.CardPadding)) {
Символы и приёмы главы
| Символ / приём | Как называется | Что делает | Пример |
|---|---|---|---|
object Dimens { val X = ... } | объект-хранилище констант | выносит повторяющиеся числа макета в единственное именованное место | Dimens.CardPadding |
val, не const val | константа времени выполнения | единственный вариант для Dp и Sp | val CardPadding = 12.dp |
Modifier.fillMaxWidth() | растянуть по ширине | занять всю доступную ширину родителя | Modifier.fillMaxWidth().height(120.dp) |
Modifier.padding(...) | отступ | внутренний отступ; порядок в цепочке модификаторов важен | Modifier.padding(12.dp).background(color) |
Arrangement.SpaceBetween | развести по краям | прижимает первый элемент к началу, последний — к концу | Row(horizontalArrangement = Arrangement.SpaceBetween) |
Куда дальше: проверенные ресурсы
- Замена магического числа символьной константой — Refactoring.Guru — классическое объяснение рефакторинга «магическое число → константа» вне контекста Android, тем же приёмом, что применён к
Dimensв этой главе. - Compose layout basics — первоисточник примеров про Row, Column и Box.
- Basic Layouts in Compose (codelab) — пошаговый практикум: собрать целый экран с поиском, карточками и адаптивной навигацией.
- Compose Modifiers — официальный разбор цепочки модификаторов и того, почему их порядок имеет значение.
Челлендж ⭐
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 ⭐:
Ошибка будет той же, что разобрана в разделе «Типичные ошибки»: 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.
Проверка по главе
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Что должен уметь
- Собрать карточку по макету в четыре шага: рамка, слои, текст, ряд.
- Понять по макету, где нужен
Box, а где хватитColumnилиRow. - Прочитать получившийся код построчно и объяснить каждый модификатор.
- Вынести повторяющиеся числа макета в
objectс именованными константами. - Объяснить, почему для
DpиSpнуженval, а неconst val. - Узнать по сообщению компилятора пять типичных ошибок сборки и починить каждую.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.