Перейти к основному содержимому
Мобилка18ГЛАВА 18Scaffold: каркас экрана, шапка и нижняя панелькаркас

Scaffold: каркас экрана, шапка и нижняя панель

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

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

Мы умеем собрать содержимое экрана: карточки, списки, тему. Но экран приложения — это не только содержимое. Сверху обычно шапка с заголовком и кнопкой «назад», снизу — панель разделов, где-то в углу может висеть круглая кнопка действия.

Всё это можно расставить вручную через 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))
}
SCAFFOLD — РАМКА С ЧЕТЫРЬМЯ МЕСТАМИtopBarсодержимое экранаполучает innerPaddingFABbottomBarзаголовок, кнопканазад, действияплавающая кнопкавкладки разделовснекбар показывается поверх и тоже знает про эти границы
Scaffold расставляет панели по краям и отдаёт содержимому отступы, оставшиеся от них

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

ОДИН ЗАБЫТЫЙ ПАРАМЕТРpadding проигнорированшапкапервый элемент — под нейвиден со второго элементаModifier.padding(innerPadding)шапкасписок начинается под шапкойScaffold сообщает высоту панелей, но применить её обязан ты сам
Без применения innerPadding содержимое начинается под шапкой; с ним — сразу под её краем

Коварство в том, что заметить это трудно. На коротком тестовом списке из трёх элементов всё выглядит нормально: под шапкой прячется первый, а его отсутствие не бросается в глаза. Проблема всплывает на реальных данных или на защите.

Важно

Компилятор здесь не помощник: неиспользованный параметр лямбды — не ошибка и даже не предупреждение. Единственная защита — привычка. Написали Scaffold — сразу же напишите Modifier.padding(innerPadding) в содержимом, до того как наполнять его чем-то ещё.

Отвечено вопросов: 0 из 2
Что такое innerPadding в Scaffold?
Почему ошибку с innerPadding часто замечают поздно?
Отвечено верно: 0 из 2

Шапка: 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 — не внутреннее свойство панели, а сравнение с внешним значением. Это то самое поднятие состояния из главы про состояние и события, только применённое к навигации.

КТО ПОМНИТ ВЫБРАННУЮ ВКЛАДКУselectedTabживёт выше панели: во ViewModel, а для перезапуска — в хранилищесостояние внизГлавнаяКаталогКорзинаПрофильнажатие вверхпанель ничего не помнит сама: она показывает то, что ей передали, и сообщает о нажатии
Панель показывает переданную вкладку и сообщает о нажатии; помнит выбор тот, кто стоит выше

Почему это важно — видно, если проследить жизнь состояния:

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

Отвечено вопросов: 0 из 2
Где хранится информация о выбранной вкладке NavigationBar?
Что нужно, чтобы выбранная вкладка пережила перезапуск приложения?
Отвечено верно: 0 из 2

Снекбар: сообщение поверх экрана​

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

Отвечено вопросов: 0 из 2
Экран с шапкой, нижней панелью и списком. Первый элемент списка не виден. Что проверить?
Зачем брать Icons.AutoMirrored для стрелки «назад»?
Отвечено верно: 0 из 2

Что дальше​

Каркас есть, панели на месте, вкладки переключаются. Но экраны пока пусты: показывать нечего, потому что данные брать неоткуда. Дальше в треке — корутины и потоки, а за ними то, ради чего они нужны: данные из сети и с диска, которые наполнят эти экраны настоящим содержимым.

Отвечено вопросов: 0 из 1
На экране только форма входа: ни шапки, ни панелей. Нужен Scaffold?
Отвечено верно: 0 из 1

Углубиться​

  • TopAppBarDefaults.enterAlwaysScrollBehavior() — шапка, которая прячется при прокрутке вниз и возвращается при прокрутке вверх; требует Modifier.nestedScroll.
  • NavigationRail — та же панель разделов, но сбоку: так делают на планшетах и в горизонтальной ориентации.
  • ModalNavigationDrawer — выдвижное меню слева, когда разделов больше пяти.
  • WindowInsets.safeDrawing — ручное управление системными отступами, когда Scaffold не подходит.
  • NavigationSuiteScaffold — каркас, который сам выбирает между нижней панелью и боковой в зависимости от размера экрана.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

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

  • Scaffold — developer.android.com — первоисточник по каркасу и его слотам.
  • App bars — все варианты верхней панели и поведение при прокрутке.
  • Navigation bar — нижняя панель разделов и её элементы.
  • Edge-to-edge — отрисовка под системными панелями и работа с отступами.

Видео по теме

ВидеоScaffold (TopAppBar, BottomBar & FAB) - Android Jetpack ComposeNeat Roots
ВидеоTop App Bar (Toolbar) - UX With Material3Philipp Lackner
ВидеоScaffold and Slot APIs with Jetpack ComposeCodingWithMitch
ВидеоManaging Snackbar's with Scaffold (Jetpack Compose)CodingWithMitch
ВидеоLearn What's New - Material 3 Top App Bar ComponentStevdza-San

Комментарии

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