Селект и нижняя шторка: выбор одного из списка
Зачем это нужно
В критериях два соседних пункта: «Создайте компонент селект» и «Создайте компонент BottomSheet». Это не совпадение — на телефоне они почти всегда одно и то же: селект открывает нижнюю шторку, а шторка показывает варианты.
Заодно здесь впервые в блоке появляются две вещи, которых не было у кнопки и поля: экспериментальный интерфейс, который надо разрешить явно, и асинхронное закрытие — потому что закрыть шторку значит доиграть анимацию.
Весь код главы собран настоящим компилятором; сообщения об ошибках — его вывод.
Почему не выпадающий список
Привычный по вебу выпадающий список на телефоне работает плохо, и причины механические:
- он раскрывается вниз от поля, то есть ровно туда, где сейчас находится рука;
- строки в нём мелкие — попасть пальцем сложнее, чем курсором;
- длинный вариант обрезается: ширина ограничена шириной поля;
- закрыть его можно только нажатием мимо, а «мимо» на телефоне мало.
Нижняя шторка решает всё это разом: она приезжает снизу, к большому пальцу, занимает всю ширину, строки в ней крупные, а закрывается смахиванием вниз.
Выпадающий список в Compose существует — DropdownMenu, и он уместен: в панели инструментов, в меню «ещё» из трёх точек, на планшете. Правило простое: меню действий — выпадающее, выбор значения для формы — шторка.
Экспериментальный интерфейс
Первое, что произойдёт при попытке использовать шторку:
@Composable fun P() { ModalBottomSheet(onDismissRequest = {}) { Text("шторка") } }
e: Probe.kt:5:23 This material API is experimental and is likely to change or to be removed in the future.
Это не поломка и не запрет, а предупреждение, поднятое до уровня ошибки: авторы Material 3 оставляют за собой право изменить этот интерфейс. Разрешение даётся явно:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KitBottomSheet(…) { … }
Соблазн велик: поставить @OptIn один раз на весь файл или, ещё хуже, отключить проверку в настройках сборки. Так делать не стоит именно потому, что предупреждение полезное. Когда библиотека обновится и интерфейс изменится, точечная аннотация покажет ровно те три функции, которые надо посмотреть, а глобальная — ничего.
Закрыть шторку — это ожидание
Вторая особенность: у шторки есть состояние с анимацией, и закрытие — не мгновенная операция.
val st = rememberModalBottomSheetState()
ModalBottomSheet(onDismissRequest = {}, sheetState = st) { Text("шторка"); st.hide() }
e: Probe.kt:10:81 Suspend function 'suspend fun hide(): Unit' should be called only from a coroutine or another suspend function.
hide() помечена как приостанавливаемая, потому что внутри она ждёт окончания анимации. Вызвать её прямо в разметке нельзя — разметка не умеет ждать. Нужна область корутин:
val state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
val hide: () -> Unit = {
scope.launch { state.hide() }.invokeOnCompletion {
if (!state.isVisible) onDismiss()
}
}
Разница между «убрать» и «закрыть» видна на устройстве сразу. open = false заставляет шторку исчезнуть, как будто её выключили; правильный путь показывает, как она съезжает вниз. Это ровно тот класс мелочей, из которых складывается впечатление «сделано аккуратно».
Компонент шторки
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KitBottomSheet(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
title: String? = null,
content: @Composable (hide: () -> Unit) -> Unit,
) {
val state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
val hide: () -> Unit = {
scope.launch { state.hide() }.invokeOnCompletion {
if (!state.isVisible) onDismiss()
}
}
ModalBottomSheet(onDismissRequest = onDismiss, modifier = modifier, sheetState = state) {
Column(modifier = Modifier.padding(bottom = 24.dp)) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
}
content(hide)
}
}
}
Обратите внимание на тип слота: @Composable (hide: () -> Unit) -> Unit. Это слот с аргументом — шторка передаёт внутрь способ закрыть себя. Иначе содержимое не смогло бы закрыть шторку после выбора: состояние анимации живёт внутри компонента.
Приём «слот получает то, что есть только внутри» встречается постоянно. Так же устроен Scaffold, отдающий innerPadding, и списки, отдающие индекс элемента. Если содержимому нужно что-то из внутренностей компонента — это передают в слот, а не выносят наружу.
Сам селект
@Composable
fun <T> KitSelect(
value: T?,
options: List<T>,
onSelect: (T) -> Unit,
label: String,
modifier: Modifier = Modifier,
error: String? = null,
enabled: Boolean = true,
placeholder: String = "Не выбрано",
optionText: (T) -> String = { it.toString() },
) {
var open by remember { mutableStateOf(false) }
KitTextField(
value = value?.let(optionText) ?: "",
onValueChange = {},
label = label,
modifier = modifier.clickable(enabled = enabled) { open = true },
placeholder = placeholder,
error = error,
enabled = false,
)
if (open) {
KitBottomSheet(onDismiss = { open = false }, title = label) { hide ->
options.forEach { option ->
Text(
text = optionText(option),
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(option); hide() }
.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
}
}
Приём с enabled = false у поля — обходной, и стоит понимать его цену: отключённое поле выглядит приглушённым. На макете селект обычно нарисован как обычное поле, поэтому цвета для него задают отдельно, через параметры цветов поля, а не полагаются на отключённый вид.
Типичные ошибки
Ошибка 1. @OptIn на весь файл или отключение проверки в сборке.
Разобрано выше: при обновлении библиотеки вы потеряете единственную подсказку о том, что именно надо посмотреть.
Ошибка 2. Закрытие рывком.
.clickable { onSelect(option); open = false }
Работает, но шторка исчезает без анимации. На демонстрации это заметно.
Ошибка 3. Нажатие повешено на текст внутри поля.
Тогда нажимается только площадь букв, а не всё поле. clickable вешают на modifier компонента.
Ошибка 4. Селект без состояния «не выбрано».
Если value не может быть пустым, при открытии формы там окажется первый вариант — и человек отправит его, не заметив, что ничего не выбирал.
Ошибка 5. Шторка со списком на сотню строк без прокрутки.
Column не прокручивается. Для длинных списков внутри шторки берут LazyColumn, иначе нижние варианты недостижимы.
Ошибка 6. Обобщённый селект, который печатает объект.
optionText = { it.toString() } // Gorod(id=3, name=Самара)
Значение по умолчанию годится для строк. Для своих типов передают своё — иначе в шторке окажется отладочный вывод.
Шпаргалка по главе
| Что нужно | Как |
|---|---|
| Разрешить шторку | @OptIn(ExperimentalMaterial3Api::class) над функцией |
| Состояние шторки | rememberModalBottomSheetState(skipPartiallyExpanded = true) |
| Откуда запускать анимацию | rememberCoroutineScope() |
| Закрыть по-настоящему | scope.launch { state.hide() }.invokeOnCompletion { … } |
| Слот с аргументом | content: @Composable (hide: () -> Unit) -> Unit |
| Нажатие на всё поле | modifier.clickable { open = true } |
| Как показать вариант | optionText: (T) -> String |
| Длинный список в шторке | LazyColumn, а не Column |
Слова главы
| Термин | Что это |
|---|---|
| Нижняя шторка | Панель, приезжающая снизу; на телефоне заменяет выпадающий список |
| Экспериментальный интерфейс | Часть библиотеки, которая может измениться; требует @OptIn |
| Приостанавливаемая функция | Та, что умеет ждать; вызывается только из корутины |
| Область корутин | rememberCoroutineScope() — место, откуда запускают ожидание из разметки |
| Слот с аргументом | Слот, которому компонент передаёт что-то из своих внутренностей |
| Обобщённый компонент | Работает с любым типом значения: fun <T> KitSelect |
Цель: скорость ≥ 95 зн/мин, точность ≥ 90%
@OptIn(ExperimentalMaterial3Api::class)
Что должен уметь после главы
- объяснить, почему выбор значения на телефоне делают шторкой, а не выпадающим списком;
- разрешить экспериментальный интерфейс точечно и объяснить, почему не на весь файл;
- узнать по сообщению приостанавливаемую функцию и вызвать её правильно;
- закрыть шторку с анимацией, а не рывком;
- сделать слот, который передаёт содержимому что-то из внутренностей компонента;
- решить, какое состояние компонент держит у себя, а какое отдаёт наружу.
Проверка по главе
Сводная проверка по всей главе: 10 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Bottom sheets в Material 3 — виды шторок и их состояния.
- Корутины в Compose —
rememberCoroutineScopeи когда он нужен. - Экспериментальные интерфейсы Kotlin — как устроен
@OptInи зачем он. - Меню в Compose — когда всё-таки уместен выпадающий список.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.