Компонент библиотеки: кнопки, слоты и состояния
Зачем это нужно
В критериях чемпионата по мобильной разработке есть отдельный список пунктов, и звучат они так:
Создайте компоненты кнопок
Создайте инпут
Создайте компонент селект
Создайте компонент Поиск
Создайте компонент BottomSheet
Создайте компонент TabBar
Создайте компонент карточки
Создайте компонент «Хэдер»
Все компоненты библиотеки адаптивны
Девять оцениваемых пунктов, и каждый — про свой компонент, а не про использование готового из Material. Разница принципиальная: Button из Material вы вызываете, а KitButtonPrimary вы проектируете — решаете, какие у него параметры, какие состояния, что можно подменить снаружи.
Эта глава — про то, как устроен такой компонент, на примере кнопок. Дальше в блоке те же правила применяются к полям ввода, выбору, поиску и остальному списку.
Весь Compose-код этой главы собран настоящим компилятором в библиотечном модуле Android: Kotlin 2.0.21, Compose BOM 2024.10.01, плагин сборки 8.7.3, compileSdk 34. Сообщения об ошибках ниже — его подлинный вывод, а не пересказ.
Порядок параметров — это документация
Порядок параметров в Compose — жёсткое соглашение, и следовать ему стоит не из вежливости: по нему чужой компонент читают, не открывая код.
@Composable
fun KitButtonPrimary(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false,
leading: (@Composable () -> Unit)? = null,
) {
Button(
onClick = onClick,
modifier = modifier,
enabled = enabled && !loading,
) {
KitButtonContent(text = text, loading = loading, leading = leading)
}
}
modifier: Modifier = Modifier выглядит как тавтология, но это не она: слева — имя параметра, справа — объект-пустышка Modifier, к которому вызывающий добавляет что хочет. Компонент обязан его принять и повесить на свой корневой элемент — иначе снаружи невозможно ни задать отступ, ни растянуть кнопку по ширине.
Три варианта, а не три цвета
На макете обычно три кнопки, и отличаются они не оттенком, а весом действия:
@Composable
fun KitButtonSecondary(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
loading: Boolean = false,
leading: (@Composable () -> Unit)? = null,
) {
OutlinedButton(
onClick = onClick,
modifier = modifier,
enabled = enabled && !loading,
) {
KitButtonContent(text = text, loading = loading, leading = leading)
}
}
@Composable
fun KitButtonGhost(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
TextButton(onClick = onClick, modifier = modifier, enabled = enabled) {
Text(text = text, style = MaterialTheme.typography.labelLarge)
}
}
Обратите внимание: у призрачной кнопки нет loading и leading. Это не забывчивость — так проектируют. Третьестепенное действие («Отмена», «Пропустить») не бывает долгим и не носит иконку; лишние параметры только создали бы впечатление, что ими можно пользоваться.
Три варианта — три отдельные публичные функции, а не одна с параметром variant: ButtonVariant. Так вызов читается без заглядывания в перечисление (KitButtonGhost("Отмена") понятно сразу), а каждый вариант может иметь свой набор параметров. Параметр-перечисление берут, когда вариантов много и они правда одинаковы по возможностям.
Слот вместо флагов
Дизайнер просит иконку слева от текста. Первое решение, которое приходит в голову:
showIcon: Boolean = false,
iconRes: Int? = null,
iconTint: Color? = null,
Через неделю просят бейдж с количеством — ещё три параметра. Потом индикатор загрузки вместо иконки — ещё два. Компонент превращается в свалку настроек, из которых в каждом конкретном вызове используются две.
Слот решает это целиком:
leading: (@Composable () -> Unit)? = null,
Вызывающий кладёт туда что угодно: иконку, бейдж, картинку, вообще всё, что рисуется. Компонент про это ничего не знает — он просто ставит содержимое слева и отступ после него.
@Composable
private fun KitButtonContent(
text: String,
loading: Boolean,
leading: (@Composable () -> Unit)?,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
} else if (leading != null) {
leading()
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = text, style = MaterialTheme.typography.labelLarge)
}
}
Три ошибки, которые ловит компилятор
Ошибка 1. Забыли @Composable у функции.
fun Screen() { Text("привет") }
e: Probe.kt:3:5 Functions which invoke @Composable functions must be marked with the @Composable annotation
e: Probe.kt:3:16 @Composable invocations can only happen from the context of a @Composable function
Две строки на одну ошибку: первая говорит, что делать, вторая — где именно сломалось.
Ошибка 2. Слот объявлен без @Composable в типе.
@Composable fun Card(slot: () -> Unit) { slot() }
@Composable fun Use() { Card(slot = { Text("внутри") }) }
e: Probe.kt:5:39 @Composable invocations can only happen from the context of a @Composable function
Смотрите на позицию: 5:39 — это место вызова, внутри лямбды с Text. Сам Card скомпилировался нормально: обычная функция без аргументов — законный тип. Сломалось там, где в неё попытались положить разметку. Отсюда правило: аннотацию пишут внутри скобок типа — (@Composable () -> Unit).
Ошибка 3. Разметка в обработчике нажатия.
@Composable fun P() { Button(onClick = { Text("нельзя") }) { Text("ок") } }
e: Probe.kt:5:42 @Composable invocations can only happen from the context of a @Composable function
onClick — обычная функция, она выполняется по нажатию, а не во время отрисовки. Рисовать из неё нечего и некуда. Если по нажатию должно что-то появиться — меняют состояние, а разметка реагирует на изменение сама.
Все три ошибки дают одно и то же сообщение @Composable invocations can only happen from the context of a @Composable function. Разными у них будут только строка и столбец. Поэтому первое, что делают с этой ошибкой, — смотрят не на текст, а на позицию.
А эту компилятор не ловит
@Composable
fun P() {
val s = mutableStateOf(0)
Text(s.value.toString())
}
(сборка проходит, ошибок нет)
Состояние без remember создаётся заново при каждой перерисовке, и значение всегда сбрасывается. Компилятор молчит — это законный код, просто бесполезный. Предупреждение выдаст только проверка стиля в среде разработки, и то если она включена.
Это разделение стоит запомнить на весь блок: компилятор проверяет типы и контекст, а не смысл. Всё, что связано с жизненным циклом состояния и с тем, перерисуется ли компонент, ловится только чтением кода и запуском.
Типичные ошибки проектирования
Ошибка 1. Компонент не принимает modifier.
Снаружи такому компоненту ни отступ не задать, ни ширину. Лечится добавлением параметра и передачей его на корневой элемент — но обычно к этому моменту компонент уже вызван в двадцати местах с обёрткой Box вокруг.
Ошибка 2. modifier повешен не на корень.
Button(onClick = onClick) {
Row(modifier = modifier) { … } // отступ окажется ВНУТРИ кнопки
}
Снаружи задали padding(16.dp), ожидая отступ вокруг кнопки, а получили увеличенную кнопку.
Ошибка 3. Состояния не согласованы.
loading = true при enabled = true — кнопка показывает индикатор и продолжает нажиматься. Пользователь оформит заказ дважды.
Ошибка 4. Цвета и размеры вписаны числами внутри компонента.
Библиотека на то и библиотека, что берёт значения из темы: MaterialTheme.typography.labelLarge, а не fontSize = 14.sp. Иначе смена темы не дойдёт до компонента.
Ошибка 5. Слот сделан обязательным.
leading: @Composable () -> Unit без ? и без умолчания заставляет каждый вызов писать пустую лямбду. Необязательное делают необязательным.
Шпаргалка по главе
| Что нужно | Как |
|---|---|
| Порядок параметров | обязательные → modifier → состояния → слоты |
| Пустышка для модификатора | modifier: Modifier = Modifier |
| Куда вешать modifier | на корневой элемент компонента |
| Слот | leading: (@Composable () -> Unit)? = null |
| Вызов слота | leading() внутри нужного места разметки |
| Согласование состояний | enabled = enabled && !loading |
| Размеры и цвета | из MaterialTheme, не числами |
| Варианты | отдельные функции, а не параметр-перечисление |
Слова главы
| Термин | Что это |
|---|---|
| Компонент библиотеки | Своя @Composable-функция с продуманной сигнатурой |
| Слот | Параметр-разметка: (@Composable () -> Unit)? |
| Вариант | Отдельная функция под свой вес действия: primary, secondary, ghost |
| Состояние компонента | enabled, loading и подобные; должны быть согласованы |
| Корневой элемент | Самый внешний элемент компонента; на него вешают modifier |
Пустышка Modifier | Объект без изменений, к которому вызывающий добавляет своё |
Цель: скорость ≥ 95 зн/мин, точность ≥ 90%
modifier: Modifier = Modifier,
Что должен уметь после главы
- расставить параметры компонента в принятом порядке и объяснить, почему он такой;
- принять
modifierи повесить его на корневой элемент; - сделать три варианта кнопки и обосновать разный набор параметров у них;
- заменить набор флагов слотом и объяснить выигрыш;
- узнать по позиции в сообщении, какая из трёх ошибок контекста произошла;
- назвать то, чего компилятор не проверяет, и почему это опаснее.
Проверка по главе
Сводная проверка по всей главе: 10 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Compose API guidelines — первоисточник соглашения про порядок параметров и слоты.
- Модификаторы — почему порядок в цепочке важен и куда вешать
modifier. - Кнопки в Material 3 — какие готовые варианты есть и чем отличаются.
- Слоты и переиспользуемые компоненты — официальное объяснение приёма.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.