Карточка и адаптивность: компонент решает сам
Зачем это нужно
Последний компонент списка — «Создайте компонент карточки». И рядом, отдельной строкой, пункт, который относится ко всей библиотеке сразу: «Все компоненты библиотеки адаптивны».
Второй пункт коварнее первого. «Адаптивность» звучит расплывчато, проверяется придирчиво, а компилятор про неё не скажет ни слова: всё, что её ломает, — совершенно законный код.
Эта глава закрывает оба пункта: карточка как компонент и четыре проверки, из которых складывается адаптивность.
Карточка в списке и в сетке
Одна и та же карточка товара на макете нарисована дважды: в каталоге сеткой по две в ряд (узкая, столбиком) и в корзине списком (широкая, строкой).
Соблазн — сделать два компонента или добавить параметр horizontal: Boolean. Оба решения перекладывают выбор на вызывающего, а он о доступной ширине знает не больше карточки.
Правильно — карточка смотрит на свою ширину:
BoxWithConstraints {
val wide = maxWidth.value >= wideFrom
if (wide) {
Row(…) { image?.invoke(); KitCardTexts(…); action?.invoke() }
} else {
Column(…) { image?.invoke(); KitCardTexts(…); action?.invoke() }
}
}
Попытка узнать ширину не в том месте:
@Composable fun P() { Text(maxWidth.toString()) }
e: Probe.kt:4:28 Unresolved reference 'maxWidth'.
Знакомый признак из прошлой главы: имя есть, а контекста нет. maxWidth существует только внутри BoxWithConstraints.
Вызывающий не должен выбирать вид карточки. Он говорит, сколько места даёт, — и всё. Это тот же принцип, что с modifier: снаружи задают условия, а компонент решает, как в них уложиться.
Четыре проверки адаптивности
Первая: нет жёсткой ширины.
Card(modifier = Modifier.width(320.dp)) { … }
(сборка проходит, ошибок нет)
Компилятор доволен. На узком экране такая карточка вылезет за край, на широком — оставит пустоту справа. Вместо этого: fillMaxWidth() там, где карточка занимает всю ширину, и widthIn(max = …) там, где надо ограничить рост на планшете.
Вторая: длинный текст обрезается.
Text("Очень длинное название товара, которое не поместится ни на каком экране целиком")
(сборка проходит, ошибок нет)
Без maxLines карточка растёт вниз, и в сетке соседние карточки встают лесенкой. С maxLines, но без overflow, текст обрывается на полуслове без многоточия — выглядит как обрезанная вёрстка, а не как сокращение.
Text(
text = title,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Третья: раскладку выбирает компонент. Разобрана выше.
Четвёртая: высота от содержимого. height(180.dp) фиксирует высоту, и при крупном системном шрифте текст обрежется по нижнему краю. heightIn(min = 180.dp) задаёт минимум, оставляя карточке право вырасти.
Все четыре проверяются одинаково: переключить размер шрифта в настройках на крупный, повернуть экран и открыть тот товар, у которого самое длинное название. Три действия, полторы минуты — и видно всё, чего не видит компилятор.
Компонент целиком
@Composable
fun KitProductCard(
title: String,
price: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
onClick: (() -> Unit)? = null,
image: (@Composable () -> Unit)? = null,
action: (@Composable () -> Unit)? = null,
wideFrom: Int = 360,
) {
Card(
modifier = modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier),
) {
BoxWithConstraints {
val wide = maxWidth.value >= wideFrom
…
}
}
}
И тексты отдельной приватной функцией — они одинаковы в обеих раскладках:
@Composable
private fun KitCardTexts(title: String, subtitle: String?, price: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(text = title, style = MaterialTheme.typography.titleMedium,
maxLines = 2, overflow = TextOverflow.Ellipsis)
if (subtitle != null) {
Text(text = subtitle, style = MaterialTheme.typography.bodySmall,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(text = price, style = MaterialTheme.typography.titleLarge)
}
}
Заголовок в две строки, подзаголовок в одну, цена без ограничения — это не произвол. Цену обрезать нельзя никогда: «1 990…» хуже, чем сломанная вёрстка, потому что вводит в заблуждение. Всё, что человек может неверно прочитать, обрезать не стоит.
Почему не WindowSizeClass
Для адаптивности в Android есть готовый механизм — классы размеров окна: компактный, средний, развёрнутый. Он полезен, но решает другую задачу: как перестроить экран целиком — показать список и деталь рядом на планшете вместо двух экранов подряд.
Компоненту библиотеки он не подходит по простой причине: карточка может оказаться узкой и на широком экране — например, в сетке по три в ряд. Класс размера окна скажет «развёрнутый», а места у карточки будет 200 точек.
Правило: экран смотрит на размер окна, компонент — на свои ограничения. Компонент, который принимает решения по размеру окна, сломается в первой же сетке.
Типичные ошибки
Ошибка 1. Жёсткая ширина в компоненте.
width(320.dp) — и карточка вылезает на узком экране.
Ошибка 2. Параметр «горизонтальная ли карточка».
Перекладывает решение на вызывающего, который знает не больше.
Ошибка 3. Текст без ограничения строк.
Сетка превращается в лесенку из-за одного длинного названия.
Ошибка 4. Обрезанная цена.
Разобрано: вводит в заблуждение.
Ошибка 5. Фиксированная высота.
При крупном системном шрифте текст обрежется снизу. heightIn(min = …) вместо height(…).
Ошибка 6. Проверка только на одном устройстве.
Эмулятор с одним разрешением и обычным шрифтом покажет, что всё хорошо. Три действия — крупный шрифт, поворот, самое длинное название — покажут правду.
Ошибка 7. Карточка знает про товар слишком много.
fun KitProductCard(product: Product)
Библиотечный компонент, принимающий тип из предметной области, перестаёт быть библиотечным: его не переиспользовать для категории, акции или пункта заказа. Принимают отдельные значения и слоты.
Шпаргалка по главе
| Что нужно | Как |
|---|---|
| Узнать свою ширину | BoxWithConstraints { maxWidth … } |
| Ширина карточки | fillMaxWidth() или widthIn(max = …), не width(…) |
| Высота | heightIn(min = …), не height(…) |
| Обрезать заголовок | maxLines = 2, overflow = TextOverflow.Ellipsis |
| Цена | без ограничения строк |
| Условный модификатор | .then(if (…) Modifier.clickable { … } else Modifier) |
| Что принимать | отдельные значения и слоты, не тип из предметной области |
| Проверка | крупный шрифт + поворот + самое длинное название |
Слова главы
| Термин | Что это |
|---|---|
BoxWithConstraints | Контейнер, внутри которого известны собственные ограничения |
| Ограничения | Сколько места родитель разрешил занять: maxWidth, maxHeight |
| Порог раскладки | Ширина, начиная с которой компонент перестраивается |
TextOverflow.Ellipsis | Многоточие вместо обрыва на полуслове |
| Класс размера окна | Компактный, средний, развёрнутый — для экрана, не для компонента |
| Условный модификатор | Приклеенный через .then только при выполнении условия |
Цель: скорость ≥ 95 зн/мин, точность ≥ 90%
BoxWithConstraints {
Что должен уметь после главы
- собрать карточку, которая сама выбирает раскладку по доступной ширине;
- назвать четыре проверки адаптивности и объяснить, почему компилятор молчит на всех;
- обрезать длинный текст правильно и сказать, что обрезать нельзя;
- объяснить разницу между ограничениями компонента и классом размера окна;
- приклеить модификатор условно;
- проверить адаптивность тремя действиями за полторы минуты.
Проверка по главе
Сводная проверка по всей главе: 10 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- BoxWithConstraints — как компонент узнаёт свои ограничения.
- Адаптивные приложения — классы размеров окна и когда они нужны.
- Работа с текстом —
maxLines,overflowи переносы. - Cards в Material 3 — виды карточек и их поведение.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.