Хэдер и TabBar: шапка и нижняя панель как компоненты
Зачем это нужно
В критериях два отдельных пункта: «Создайте компонент TabBar» и «Создайте компонент „Хэдер“». И тут возникает законный вопрос: разве мы это уже не проходили? В главе про Scaffold были и шапка, и нижняя панель.
Разница ровно та же, что была с кнопкой: Scaffold — каркас экрана, готовый и общий, а хэдер и панель — ваши компоненты, которые в этот каркас вставляют. Каркас берут как есть; компоненты пишут так, чтобы на всех экранах приложения они выглядели и вели себя одинаково. Это и оценивается.
Заодно здесь встретится ошибка, которая уже была в главе про кнопки, — и это не совпадение.
Каркас и компоненты
Scaffold(
topBar = { KitHeader(title = "Каталог", actions = { KitButtonGhost("Фильтр", onClick = {}) }) },
bottomBar = { KitTabBar(tabs = tabs, selectedId = current, onSelect = ::go) },
) { innerPadding ->
CatalogList(modifier = Modifier.padding(innerPadding))
}
Работа Scaffold — сложить четыре слота и посчитать отступы, чтобы содержимое не уехало под шапку и под панель. Работа KitHeader и KitTabBar — выглядеть одинаково на всех двенадцати экранах.
Признак, что компонент нужен: вы второй раз пишете TopAppBar(title = { Text(...) }, navigationIcon = { ... }) с одними и теми же настройками. На третьем экране отличие уже проберётся — где-то другой отступ, где-то другой цвет. Компонент библиотеки существует именно для того, чтобы отличий не было.
Шапка: три места
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KitHeader(
title: String,
modifier: Modifier = Modifier,
centered: Boolean = false,
navigation: (@Composable () -> Unit)? = null,
actions: (@Composable () -> Unit)? = null,
) {
val titleSlot: @Composable () -> Unit = { Text(text = title) }
val navSlot: @Composable () -> Unit = { navigation?.invoke() }
val actionsSlot: @Composable () -> Unit = { actions?.invoke() }
if (centered) {
CenterAlignedTopAppBar(
title = titleSlot,
modifier = modifier,
navigationIcon = navSlot,
actions = { Row { actionsSlot() } },
)
} else {
TopAppBar(
title = titleSlot,
modifier = modifier,
navigationIcon = navSlot,
actions = { Row { actionsSlot() } },
)
}
}
Обратите внимание на несимметричность: заголовок — строка, а navigation и actions — слоты. Это не непоследовательность. Правило простое: слот там, где содержимое заранее неизвестно. Заголовок всегда текст, а слева может быть стрелка, крестик или три полоски.
Нижняя панель
data class KitTab(
val id: String,
val label: String,
val badge: Int? = null,
)
@Composable
fun KitTabBar(
tabs: List<KitTab>,
selectedId: String,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
icon: @Composable (KitTab) -> Unit = {},
) {
NavigationBar(modifier = modifier) {
tabs.forEach { tab ->
NavigationBarItem(
selected = tab.id == selectedId,
onClick = { onSelect(tab.id) },
icon = {
if (tab.badge != null && tab.badge > 0) {
BadgedBox(badge = { Badge { Text(tab.badge.toString()) } }) { icon(tab) }
} else {
icon(tab)
}
},
label = { Text(tab.label) },
alwaysShowLabel = true,
)
}
}
}
Кто помнит выбранную вкладку
Соблазн — хранить выбранную вкладку внутри панели: var selected by remember { … }. Так короче, и на первый взгляд работает.
Сломается это в трёх местах:
- вернулись на экран — панель сбросилась на первую вкладку, а показывается третья;
- перешли из уведомления или по ссылке — открылся экран корзины, а подсвечен каталог;
- нажали «назад» — экран сменился, панель нет.
Причина одна: выбранная вкладка есть у двух хозяев — у панели и у навигации, и они расходятся.
Правильно — то же правило, что у поля ввода: компонент показывает и сообщает, а помнит вызывающий. Причём вызывающий не хранит это отдельно, а вычисляет из текущего маршрута:
val current = navController.currentBackStackEntryAsState().value?.destination?.route ?: "catalog"
KitTabBar(
tabs = tabs,
selectedId = current,
onSelect = { navController.navigate(it) },
)
Источник истины один — текущий маршрут навигации. Панель не хранит ничего, и рассогласоваться нечему. Это тот же принцип, что и у состояния из главы про ViewModel: если одно и то же значение живёт в двух местах, они разойдутся — вопрос только в том, когда это заметят.
Ошибка, которая уже была
@Composable fun P() { NavigationBarItem(selected = true, onClick = {}, icon = { Text("к") }) }
e: Probe.kt:5:23 Unresolved reference 'NavigationBarItem'.
Имя написано правильно, импорт на месте — а ссылка не находится. Это ровно та же история, что с Modifier.weight в главе про кнопки: NavigationBarItem объявлена как расширение для RowScope, а он существует только внутри NavigationBar { … }.
«Неразрешённая ссылка» на функцию, которая точно существует, почти всегда означает одно: вы вне того контекста, для которого она объявлена. weight — внутри Row или Column, align с двумя координатами — внутри Box, NavigationBarItem — внутри NavigationBar. Полезнее запомнить признак, чем список.
Вторая ошибка проще:
NavigationBar { NavigationBarItem(onClick = {}, icon = { Text("к") }) }
e: Probe.kt:6:71 No value passed for parameter 'selected'.
У NavigationBarItem нет умолчания для selected — и это осознанно: вкладка обязана знать, выбрана она или нет, а разумного значения по умолчанию тут не существует.
Типичные ошибки
Ошибка 1. Хранить выбранную вкладку в панели.
Разобрано выше: рассогласование с навигацией.
Ошибка 2. Значок с нулём.
Пустая корзина носит «0». Проверка на > 0 — одна строка.
Ошибка 3. Подписи прячутся.
alwaysShowLabel по умолчанию скрывает подписи невыбранных вкладок. Если на макете видны все — параметр надо задать явно.
Ошибка 4. Шапка собирается заново на каждом экране.
Двенадцать экранов — двенадцать слегка разных шапок. Это ровно то, против чего пункт критериев.
Ошибка 5. Scaffold есть, а innerPadding не использован.
Содержимое уезжает под шапку. Ошибка не из этой главы, но проявляется именно здесь — когда шапка наконец появилась.
Ошибка 6. Больше пяти вкладок.
Material рассчитан на три–пять. Шесть не помещаются по ширине на узком экране, подписи начинают обрезаться.
Шпаргалка по главе
| Что нужно | Как |
|---|---|
| Шапка в каркасе | Scaffold(topBar = { KitHeader(…) }) |
| Панель в каркасе | Scaffold(bottomBar = { KitTabBar(…) }) |
| Разрешить шапку | @OptIn(ExperimentalMaterial3Api::class) |
| Заголовок по центру | отдельный компонент CenterAlignedTopAppBar |
| Вызов необязательного слота | navigation?.invoke() |
| Несколько действий справа | actions = { Row { … } } |
| Выбранная вкладка | selected = tab.id == selectedId, а selectedId — из маршрута |
| Значок со счётчиком | BadgedBox + проверка badge > 0 |
| Подписи всех вкладок | alwaysShowLabel = true |
Слова главы
| Термин | Что это |
|---|---|
| Каркас экрана | Scaffold: раскладывает слоты и считает отступы |
| Шапка | Верхняя полоса: уводящее действие, заголовок, действия |
| Нижняя панель | Переключение основных разделов приложения |
| Значок-счётчик | Badge: число поверх значка вкладки |
| Источник истины | Единственное место, где значение хранится |
| Расширение для области | Функция, доступная только внутри своего контейнера |
Цель: скорость ≥ 95 зн/мин, точность ≥ 90%
TopAppBar(title = titleSlot, navigationIcon = navSlot)
Что должен уметь после главы
- объяснить разницу между каркасом экрана и компонентом библиотеки;
- собрать шапку с тремя местами и обосновать, где строка, а где слот;
- вызвать необязательный слот правильно;
- собрать нижнюю панель со значками-счётчиками;
- объяснить, почему выбранную вкладку вычисляют из маршрута, а не хранят;
- узнать по «неразрешённой ссылке», что вы вне нужного контекста.
Проверка по главе
Сводная проверка по всей главе: 10 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- App bars в Material 3 — виды шапок и их слоты.
- Navigation bar — нижняя панель, значки и подписи.
- Навигация в Compose — откуда берётся текущий маршрут.
- Scaffold — как каркас считает отступы для содержимого.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.