Scaffold: каркас экрана, шапка и нижняя панель
- Зачем это нужно
- Четыре слота
innerPadding: ошибка, которую делают все- Шапка:
TopAppBar - Нижняя панель и её состояние
- Снекбар: сообщение поверх экрана
- Системные панели и edge-to-edge
- Когда
Scaffoldне нужен - Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Мы умеем собрать содержимое экрана: карточки, списки, тему. Но экран приложения — это не только содержимое. Сверху обычно шапка с заголовком и кнопкой «назад», снизу — панель разделов, где-то в углу может висеть круглая кнопка действия.
Всё это можно расставить вручную через Column и Box. Так делают, и так получают три проблемы разом: содержимое уезжает под шапку, нижняя панель перекрывается системными кнопками телефона, а снекбар с сообщением появляется поверх панели вместо того, чтобы встать над ней.
Готовая рамка называется Scaffold — по-английски «строительные леса». Она знает, где у экрана верх, где низ и сколько места занимают панели, и сообщает это содержимому.
На чемпионате нижняя панель разделов встречается почти в каждом задании, причём отдельным критерием идёт требование, чтобы выбранная вкладка сохранялась после перезапуска приложения. Обе темы разберём в этой главе.
Четыре слота
Scaffold — компонент со слотами, ровно в том смысле, который мы разбирали на UI Kit: вы передаёте ему разметку кусками, а он расставляет их по местам.
Scaffold(
topBar = { TopAppBar(title = { Text("Каталог") }) },
bottomBar = { NavigationBar { /* вкладки */ } },
floatingActionButton = { FloatingActionButton(onClick = {}) { Icon(Icons.Default.Add, null) } },
) { innerPadding ->
ProductList(modifier = Modifier.padding(innerPadding))
}
Последний параметр — само содержимое, и он не просто лямбда: в него приходит innerPadding. Это и есть главная работа Scaffold — сказать содержимому, сколько места заняли панели.
innerPadding: ошибка, которую делают все
Проследим, что происходит, если этот параметр не применить:
data class Insets(val top: Int, val bottom: Int)
fun scaffold(topBar: Int, bottomBar: Int, content: (Insets) -> Unit) {
println("Scaffold: шапка $topBar dp, нижняя панель $bottomBar dp")
content(Insets(topBar, bottomBar))
}
fun drawList(label: String, offsetTop: Int, offsetBottom: Int) {
val first = 0 + offsetTop
println(" $label: первый элемент на высоте $first dp, снизу зарезервировано $offsetBottom dp")
if (offsetTop == 0) println(" ↑ первый элемент оказался ПОД шапкой — его не видно")
}
fun main() {
scaffold(topBar = 64, bottomBar = 80) { insets ->
drawList("padding проигнорирован", 0, 0)
drawList("padding применён", insets.top, insets.bottom)
}
}
Живой вывод:
Scaffold: шапка 64 dp, нижняя панель 80 dp
padding проигнорирован: первый элемент на высоте 0 dp, снизу зарезервировано 0 dp
↑ первый элемент оказался ПОД шапкой — его не видно
padding применён: первый элемент на высоте 64 dp, снизу зарезервировано 80 dp
Scaffold сообщает размеры панелей, но применить их обязаны вы сами. Пропустили — первый элемент списка уезжает под шапку, а последний прячется за нижней панелью.
Коварство в том, что заметить это трудно. На коротком тестовом списке из трёх элементов всё выглядит нормально: под шапкой прячется первый, а его отсутствие не бросается в глаза. Проблема всплывает на реальных данных или на защите.
Компилятор здесь не помощник: неиспользованный параметр лямбды — не ошибка и даже не предупреждение. Единственная защита — привычка. Написали Scaffold — сразу же напишите Modifier.padding(innerPadding) в содержимом, до того как наполнять его чем-то ещё.
Шапка: TopAppBar
Верхняя панель бывает четырёх размеров, и все они — готовые компоненты Material:
TopAppBar(
title = { Text("Каталог") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
actions = {
IconButton(onClick = onSearch) {
Icon(Icons.Default.Search, contentDescription = "Поиск")
}
},
)
Варианты CenterAlignedTopAppBar, MediumTopAppBar и LargeTopAppBar отличаются расположением и высотой заголовка. Берите тот, что совпадает с макетом: если заголовок по центру — CenterAlignedTopAppBar, а не обычный с ручным выравниванием.
Нижняя панель и её состояние
NavigationBar — панель разделов внизу экрана. Устроена она так же, как все компоненты Material: сама ничего не помнит, а показывает то, что ей передали.
NavigationBar {
tabs.forEach { tab ->
NavigationBarItem(
selected = tab == selectedTab,
onClick = { onTabSelected(tab) },
icon = { Icon(tab.icon, contentDescription = tab.title) },
label = { Text(tab.title) },
)
}
}
Обратите внимание: selected — не внутреннее свойство панели, а сравнение с внешним значением. Это то самое поднятие состояния из главы про состояние и события, только применённое к навигации.
Почему это важно — видно, если проследить жизнь состояния:
enum class Tab(val title: String) { Home("Главная"), Catalog("Каталог"), Cart("Корзина"), Profile("Профиль") }
fun navigationBar(selected: Tab, onSelect: (Tab) -> Unit) {
val row = Tab.entries.joinToString(" ") { if (it == selected) "[${it.title}]" else " ${it.title} " }
println(" панель: $row")
}
fun main() {
var current = Tab.Home
println("экран открылся:")
navigationBar(current) { current = it }
println("нажали «Корзина»:")
navigationBar(Tab.Cart) { current = it }
current = Tab.Cart
println()
println("перезапуск приложения без сохранения:")
var afterRestart = Tab.Home
navigationBar(afterRestart) { }
println(" ↑ вернулись на Главную, хотя пользователь был в Корзине")
println("перезапуск с сохранением состояния:")
afterRestart = current
navigationBar(afterRestart) { }
}
экран открылся:
панель: [Главная] Каталог Корзина Профиль
нажали «Корзина»:
панель: Главная Каталог [Корзина] Профиль
перезапуск приложения без сохранения:
панель: [Главная] Каталог Корзина Профиль
↑ вернулись на Главную, хотя пользователь был в Корзине
перезапуск с сохранением состояния:
панель: Главная Каталог [Корзина] Профиль
Последние четыре строки — это ровно тот критерий, который встречается в конкурсных заданиях: «необходимо сохранять состояние Bottom Bar после перезапуска приложения». Пока выбранная вкладка живёт в remember, она умирает вместе с экраном. Чтобы она пережила перезапуск, значение нужно сохранить в хранилище — этим займёмся в главе про данные.
Снекбар: сообщение поверх экрана
Короткое сообщение внизу экрана в Material называется снекбаром. Его тоже показывает Scaffold — и это как раз тот случай, где рамка окупается: снекбар сам встаёт над нижней панелью, а не под ней.
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Button(onClick = {
scope.launch { snackbarHostState.showSnackbar("Товар добавлен в корзину") }
}) { Text("В корзину") }
}
showSnackbar — suspend-функция: она ждёт, пока сообщение покажется и скроется. Поэтому вызывать её нужно из корутины: rememberCoroutineScope() даёт область, привязанную к экрану, — она отменится, когда экран уйдёт. Что такое приостанавливаемая функция и почему её нельзя вызвать откуда угодно, подробно разберём в главе про корутины.
Снекбар — событие, а не состояние, и разница здесь не философская. Событие происходит один раз; состояние существует, пока экран открыт. Дальше в треке, в главе про Flow, увидим, куда такие разовые события кладут и почему состояние для них не годится: после поворота экрана сообщение показалось бы второй раз.
Системные панели и edge-to-edge
Современный Android рисует приложение под системными панелями — часами сверху и полосой навигации снизу. Это называется edge-to-edge и с недавних версий включено принудительно.
Хорошая новость: Scaffold про это знает. Он учитывает системные отступы при расчёте innerPadding, поэтому шапка не залезет под часы, а нижняя панель — под полосу навигации. Ещё одна причина не собирать каркас вручную.
Отдельного внимания требует клавиатура: когда она открывается, поле ввода может оказаться под ней. Лечится модификатором:
Modifier.imePadding()
Проверять это нужно обязательно — на эмуляторе с открытой клавиатурой, а не на глаз.
Когда Scaffold не нужен
Не каждый экран требует каркаса. Диалог, экран загрузки, страница авторизации без шапки — там Scaffold только добавит лишний уровень вложенности.
Правило простое: нужен Scaffold, если на экране есть хотя бы одно из — шапка, нижняя панель, плавающая кнопка, снекбар. Если ничего из этого нет, хватит обычного Column или Box.
Типичные ошибки
Не применили innerPadding. Содержимое под шапкой. Самая частая ошибка главы.
Применили innerPadding не туда. Отступ повесили на внутренний элемент вместо контейнера — часть содержимого сдвинулась, часть нет.
Забыли contentDescription. Иконки в шапке без описания: незрячий пользователь слышит «кнопка, кнопка, кнопка».
Обычная стрелка вместо AutoMirrored. На локалях с письмом справа налево стрелка «назад» указывает в противоположную сторону.
Состояние вкладки внутри NavigationBar. Панель пытаются заставить помнить выбор саму — она этого не умеет и не должна.
Scaffold внутри Scaffold. Обычно результат копирования куска другого экрана. Панели дублируются, отступы складываются дважды.
Слова главы
Scaffold— каркас экрана со слотами для панелей и содержимого.innerPadding— отступы, оставшиеся от панелей; применять обязан разработчик.TopAppBar— верхняя панель; вариантыCenterAligned,Medium,Large.navigationIcon/actions— слоты кнопок слева и справа в шапке.NavigationBar/NavigationBarItem— нижняя панель разделов и её элемент.SnackbarHostState— состояние снекбара; показ черезsuspend-функцию.- edge-to-edge — отрисовка под системными панелями;
Scaffoldучитывает их сам. imePadding— отступ под открытую клавиатуру.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
Scaffold(topBar = { TopAppBar(title = { Text("Каталог") }) })
На чемпионате
Нижняя панель есть почти в каждом задании, и вокруг неё собирается сразу несколько критериев:
- Панель совпадает с макетом — иконки, подписи, выделение активной вкладки.
- Состояние вкладки переживает перезапуск — отдельный критерий, встречается из года в год.
- Содержимое не уезжает под панели — это как раз
innerPadding. - Экраны разнесены по вкладкам правильно: в заданиях встречается требование, чтобы разные сущности жили на разных страницах.
Практический порядок: соберите Scaffold с обеими панелями на пустом экране в первый же день, проверьте отступы, и только потом наполняйте содержимым. Переделывать каркас под готовым экраном дороже.
Что дальше
Каркас есть, панели на месте, вкладки переключаются. Но экраны пока пусты: показывать нечего, потому что данные брать неоткуда. Дальше в треке — корутины и потоки, а за ними то, ради чего они нужны: данные из сети и с диска, которые наполнят эти экраны настоящим содержимым.
Углубиться
TopAppBarDefaults.enterAlwaysScrollBehavior()— шапка, которая прячется при прокрутке вниз и возвращается при прокрутке вверх; требуетModifier.nestedScroll.NavigationRail— та же панель разделов, но сбоку: так делают на планшетах и в горизонтальной ориентации.ModalNavigationDrawer— выдвижное меню слева, когда разделов больше пяти.WindowInsets.safeDrawing— ручное управление системными отступами, когдаScaffoldне подходит.NavigationSuiteScaffold— каркас, который сам выбирает между нижней панелью и боковой в зависимости от размера экрана.
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Scaffold — developer.android.com — первоисточник по каркасу и его слотам.
- App bars — все варианты верхней панели и поведение при прокрутке.
- Navigation bar — нижняя панель разделов и её элементы.
- Edge-to-edge — отрисовка под системными панелями и работа с отступами.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.