Перейти к основному содержимому
Мобилка19ГЛАВА 19Компонент библиотеки: кнопки, слоты и состоянияPrimarySecondaryGhostleading slotслот вместо флагов

Компонент библиотеки: кнопки, слоты и состояния

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

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

В критериях чемпионата по мобильной разработке есть отдельный список пунктов, и звучат они так:

Создайте компоненты кнопок
Создайте инпут
Создайте компонент селект
Создайте компонент Поиск
Создайте компонент BottomSheet
Создайте компонент TabBar
Создайте компонент карточки
Создайте компонент «Хэдер»
Все компоненты библиотеки адаптивны

Девять оцениваемых пунктов, и каждый — про свой компонент, а не про использование готового из Material. Разница принципиальная: Button из Material вы вызываете, а KitButtonPrimary вы проектируете — решаете, какие у него параметры, какие состояния, что можно подменить снаружи.

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

Важно

Весь Compose-код этой главы собран настоящим компилятором в библиотечном модуле Android: Kotlin 2.0.21, Compose BOM 2024.10.01, плагин сборки 8.7.3, compileSdk 34. Сообщения об ошибках ниже — его подлинный вывод, а не пересказ.

Порядок параметров — это документация​

ПОРЯДОК ПАРАМЕТРОВ У КОМПОНЕНТА — НЕ ВКУСОВЩИНАtext: String,обязательные — первымибез умолчаний: без них компонент бессмысленonClick: () -> Unit,что делатьдействие тоже обязательноmodifier: Modifier = Modifier,первый необязательныйсоглашение всего Compose: вызывающий всегда знает, где онenabled: Boolean = true,состоянияс разумным умолчаниемleading: (@Composable () -> Unit)? = null,слоты — последнимичтобы вызов заканчивался лямбдойчужой компонент читают по сигнатуре, а не по коду: порядок и есть его документация
Обязательные, потом modifier, потом состояния, слоты последними

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

Три варианта, а не три цвета​

ТРИ ВАРИАНТА — ЭТО ИЕРАРХИЯ, А НЕ ПАЛИТРАОформить заказPrimaryглавное действие экранаодно, и только одноВ избранноеSecondaryрядом с главнымравноправная альтернативаОтменаGhostуводит со сценарияне должна притягивать взглядвариант выбирают по важности действия, а не по тому, какой цвет лучше смотритсядве главные кнопки на экране означают, что важность не выбранана макете это видно сразу: залитая, обведённая и просто текст
Вариант выбирают по важности действия, а не по красоте

На макете обычно три кнопки, и отличаются они не оттенком, а весом действия:

@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("Отмена") понятно сразу), а каждый вариант может иметь свой набор параметров. Параметр-перечисление берут, когда вариантов много и они правда одинаковы по возможностям.

Отвечено вопросов: 0 из 3
Почему modifier ставят первым среди необязательных параметров?
Зачем компонент обязан принять modifier и повесить его на корневой элемент?
У призрачной кнопки нет параметров loading и leading. Почему?
Отвечено верно: 0 из 3

Слот вместо флагов​

КАЖДОЕ НОВОЕ ТРЕБОВАНИЕ — ЕЩЁ ОДИН ФЛАГ ИЛИ НИ ОДНОГОФлагиshowIcon: Boolean = false,iconRes: Int? = null,iconTint: Color? = null,showBadge: Boolean = false,и так на каждое пожелание дизайнераСлотleading: (@Composable () -> Unit)? = null,что положить — решает вызывающий,компонент про это ничего не знаетслот закрывает и иконку, и бейдж, и то, чего ещё не придумалицена — скобки при вызове; плата за флаги — переписывать компонент каждый раз
Флаги растут с каждым пожеланием, слот не растёт вовсе

Дизайнер просит иконку слева от текста. Первое решение, которое приходит в голову:

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

Важно

Это разделение стоит запомнить на весь блок: компилятор проверяет типы и контекст, а не смысл. Всё, что связано с жизненным циклом состояния и с тем, перерисуется ли компонент, ловится только чтением кода и запуском.

Отвечено вопросов: 0 из 3
Три разные ошибки дали одинаковый текст «@Composable invocations can only happen…». Что смотреть первым?
Где ставится @Composable у слота?
mutableStateOf без remember. Что скажет компилятор?
Отвечено верно: 0 из 3

Типичные ошибки проектирования​

Ошибка 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 без ? и без умолчания заставляет каждый вызов писать пустую лямбду. Необязательное делают необязательным.

Отвечено вопросов: 0 из 3
Снаружи задали Modifier.padding(16.dp), а кнопка просто стала больше. Почему?
Почему в компоненте библиотеки не пишут fontSize = 14.sp?
loading = true при enabled = true — чем это плохо?
Отвечено верно: 0 из 3

Шпаргалка по главе​

Что нужноКак
Порядок параметровобязательные → 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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

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

Видео по теме

ВидеоСлоты в Jetpack Compose | Slot APIbyCompose
ВидеоCustom Design Systems In Jetpack Compose - THESE Are My Best PracticesPhilipp Lackner
ВидеоMaterial 3 Buttons in Jetpack Compose | Filled, Elevated, Outlined & Text | Button Components #2RAN MasterApps
ВидеоBuild custom design systems with Jetpack Compose's new Styles APIAndroid Developers
ВидеоКомпозиция и Рекомпозиция в Jetpack ComposebyCompose

Комментарии

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