Перейти к основному содержимому
Мобилка14ГЛАВА 14Карточка по макету: от картинки к коду-30%1 990 ₽В корзинуDimens.CardPaddingснаружи внутрь

Карточка по макету: от картинки к коду

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

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

Прошлая глава научила читать макет: где dp, где sp, какой контейнер выбрать, как работает Arrangement. Теперь надо сделать то, ради чего всё это нужно — собрать по макету настоящий элемент.

Разберём карточку товара. Это один из самых частых элементов на экранах интернет-магазинов и один из самых частых элементов в заданиях по мобильной разработке на чемпионате. Собирать будем по шагам, в том же порядке, в каком карточку обычно и собирают на практике: сначала внешняя рамка, потом слой с изображением и бейджем, потом текст, потом ряд с ценой.

А в конце — то, что отличает готовую работу от наброска: вынос чисел макета в именованные константы и разбор пяти ошибок, на которых карточка ломается чаще всего.

КАРТОЧКУ СОБИРАЮТ СНАРУЖИ ВНУТРЬ1РамкаColumnвнешний контейнер, отступыи фон2СлоиBoxкартинка и бейдждруг на друге3ТекстTextзаголовок и описаниестолбиком4РядRowцена и кнопкапо краямкаждый шаг проверяют отдельно: так ошибка находится сразу, а не в собранной карточке целикомпорядок тот же, в каком макет читают глазами: сначала рамка, потом что внутриBox появляется ровно там, где на макете одно лежит поверх другого
Карточку собирают снаружи внутрь, и каждый шаг проверяют отдельно

Готового набора цветов, отступов и текстовых стилей на все случаи в этой главе ещё нет — так, как проект обычно и начинается: с одной карточки и конкретных чисел из макета. Собрать из таких чисел переиспользуемую систему — тема следующей главы.

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

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

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

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

ОДНО И ТО ЖЕ ЧИСЛО В ЧЕТЫРЁХ МЕСТАХГолые числа.padding(12.dp).padding(top = 12.dp).padding(horizontal = 12.dp)макет поменялся — искать все четыреИменованные константыobject Dimens { val CardPadding = 12.dp}правка в одном местеимя объясняет смысл: CardPadding понятнее, чем 12 в пятый рази сразу видно, где 12 — отступ карточки, а где случайное совпадение
Одно и то же число в четырёх местах против одной именованной константы

В собранном 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​

ПОЧЕМУ CONST VAL НЕ БЕРЁТ DPconst val Size = 12Int известен компиляторузначение готово ещё до запускаего можно подставить прямо в кодconst val Size = 12.dp.dp — вызов, а не числоConst 'val' has type 'Dp'.Only primitive types and 'String'свойство-расширение .dp вычисляется во время работы программы, а не при сборкелечение простое: обычный val — он вычисляется при создании объекта и Dp принимаетто же правило действует для любых Sp, Color и прочих не-примитивов
Int компилятор вычислит сам, а .dp — это вызов, который случится только при запуске
@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

Слова главы​

product card
1 / 6

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

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

Column(modifier = Modifier.padding(Dimens.CardPadding)) {
клавиша ``клавиша 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
следующая: C — средний левой руки (+ Shift)

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

Символ / приёмКак называетсяЧто делаетПример
object Dimens { val X = ... }объект-хранилище константвыносит повторяющиеся числа макета в единственное именованное местоDimens.CardPadding
val, не const valконстанта времени выполненияединственный вариант для Dp и Spval CardPadding = 12.dp
Modifier.fillMaxWidth()растянуть по ширинезанять всю доступную ширину родителяModifier.fillMaxWidth().height(120.dp)
Modifier.padding(...)отступвнутренний отступ; порядок в цепочке модификаторов важенModifier.padding(12.dp).background(color)
Arrangement.SpaceBetweenразвести по краямприжимает первый элемент к началу, последний — к концуRow(horizontalArrangement = Arrangement.SpaceBetween)

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

Челлендж ⭐​

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.
  • Узнать по сообщению компилятора пять типичных ошибок сборки и починить каждую.

Видео по теме

ВидеоКурс по Jetpack Compose Android | Card, Box, Image | #3 | Android StudioNeco Ru
ВидеоВерстка экрана в Jetpack Compose Beta. Часть 1. [Ru, Android] / Мобильный разработчикАлексей Гладков
ВидеоВерстка экрана в Jetpack Compose Beta. Часть 2. [Ru, Android] / Мобильный разработчикАлексей Гладков
ВидеоAndroid. Вёрстка Jetpack ComposeИлья Селетков
ВидеоLayouts in Jetpack Compose | Row, Column, Box, LazyColumn Explained (EP 2)ComposeNCode

Комментарии

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