Перейти к основному содержимому
Мобилка21ГЛАВА 21Селект и нижняя шторка: выбор одного из спискаСамара⌄СамараТольяттиСызраньшторка вместо списка

Селект и нижняя шторка: выбор одного из списка

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

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

В критериях два соседних пункта: «Создайте компонент селект» и «Создайте компонент BottomSheet». Это не совпадение — на телефоне они почти всегда одно и то же: селект открывает нижнюю шторку, а шторка показывает варианты.

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

Весь код главы собран настоящим компилятором; сообщения об ошибках — его вывод.

Почему не выпадающий список​

НА ТЕЛЕФОНЕ СЕЛЕКТ — ЭТО НЕ ВЫПАДАЮЩИЙ СПИСОКсписок виситпод пальцеми перекрыт рукойвыпадающий списоквариант 1вариант 2вариант 3нижняя шторкаПочему такварианты у большого пальца,а не под нимстроки крупные: попасть легкопомещается длинный текстзакрывается смахиванием вниз
Выпадающий список оказывается под пальцем, шторка — у большого пальца

Привычный по вебу выпадающий список на телефоне работает плохо, и причины механические:

  • он раскрывается вниз от поля, то есть ровно туда, где сейчас находится рука;
  • строки в нём мелкие — попасть пальцем сложнее, чем курсором;
  • длинный вариант обрезается: ширина ограничена шириной поля;
  • закрыть его можно только нажатием мимо, а «мимо» на телефоне мало.

Нижняя шторка решает всё это разом: она приезжает снизу, к большому пальцу, занимает всю ширину, строки в ней крупные, а закрывается смахиванием вниз.

Совет

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

Отвечено вопросов: 0 из 3
Почему для выбора значения на телефоне берут шторку, а не выпадающий список?
Что означает «This material API is experimental»?
Куда ставить @OptIn?
Отвечено верно: 0 из 3

Закрыть шторку — это ожидание​

Вторая особенность: у шторки есть состояние с анимацией, и закрытие — не мгновенная операция.

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.
ЗАКРЫТЬ ШТОРКУ — ЭТО АНИМАЦИЯ, А ЗНАЧИТ ОЖИДАНИЕРывкомopen = falseшторка исчезает мгновенно,без съезда вниз — заметно и дёшевоС анимациейscope.launch { state.hide() } .invokeOnCompletion { … }сначала доигрывает съезд,потом убирает шторку из разметкиhide() приостанавливаемая — вызвать её прямо в разметке компилятор не дастSuspend function 'suspend fun hide(): Unit' should be called only from a coroutineотсюда и rememberCoroutineScope: место, откуда анимацию запускают
Убрать из разметки можно мгновенно; доиграть съезд вниз — нет

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

Сам селект​

ДВА СОСТОЯНИЯ, И ЖИВУТ ОНИ В РАЗНЫХ МЕСТАХВнутри компонентаvar open by rememberоткрыта ли шторка — дело самогоселекта, наружу это не нужновызывающий про шторку не знает вовсеСнаружиvalue: T?onSelect: (T) -> Unitвыбранное значение нужно экрану:его отправляют, проверяют, сохраняютправило: наружу выносят то, что нужно экрану, а не всё подрядвынесли бы open — каждый экран обязан был бы держать лишнее состояние ни за чем
Открыта ли шторка — дело компонента; выбранное значение — дело экрана
@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 у поля — обходной, и стоит понимать его цену: отключённое поле выглядит приглушённым. На макете селект обычно нарисован как обычное поле, поэтому цвета для него задают отдельно, через параметры цветов поля, а не полагаются на отключённый вид.

Отвечено вопросов: 0 из 3
Почему hide() нельзя вызвать прямо в разметке?
Зачем invokeOnCompletion после scope.launch?
Состояние «открыта ли шторка» держат внутри селекта. Почему не выносят наружу?
Отвечено верно: 0 из 3

Типичные ошибки​

Ошибка 1. @OptIn на весь файл или отключение проверки в сборке.

Разобрано выше: при обновлении библиотеки вы потеряете единственную подсказку о том, что именно надо посмотреть.

Ошибка 2. Закрытие рывком.

.clickable { onSelect(option); open = false }

Работает, но шторка исчезает без анимации. На демонстрации это заметно.

Ошибка 3. Нажатие повешено на текст внутри поля.

Тогда нажимается только площадь букв, а не всё поле. clickable вешают на modifier компонента.

Ошибка 4. Селект без состояния «не выбрано».

Если value не может быть пустым, при открытии формы там окажется первый вариант — и человек отправит его, не заметив, что ничего не выбирал.

Ошибка 5. Шторка со списком на сотню строк без прокрутки.

Column не прокручивается. Для длинных списков внутри шторки берут LazyColumn, иначе нижние варианты недостижимы.

Ошибка 6. Обобщённый селект, который печатает объект.

optionText = { it.toString() } // Gorod(id=3, name=Самара)

Значение по умолчанию годится для строк. Для своих типов передают своё — иначе в шторке окажется отладочный вывод.

Отвечено вопросов: 0 из 3
В шторке сто вариантов, нижние недостижимы. Что не так?
В шторке видно «Gorod(id=3, name=Самара)». Причина
Зачем селекту состояние «не выбрано»?
Отвечено верно: 0 из 3

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

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

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

Видео по теме

ВидеоModal Bottom Sheet Jetpack Compose Material 3 | Kotlin | @BashPSKBash PSK
ВидеоCreate Jetpack Compose Bottomsheet with Material 3Busy Programmer
ВидеоModal Bottom Sheet Example in Jetpack ComposeTresor Builds
ВидеоJetpack Compose: BottomSheetYOGESH SHINDE
ВидеоУрок 8: MutableState, remember. Состояние и Рекомпозиция | Курс Android Jetpack ComposeKotlin & Android с нуля – Иван Ветров

Комментарии

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