Системные панели Android: почему кнопка уезжает под навбар и как ловить это на эмуляторе

Мобильная разработка
16 августа 2026
16 мин чтения
Системные панели Android: почему кнопка уезжает под навбар и как ловить это на эмуляторе

Есть неприятный класс мобильных багов: у разработчика экран выглядит идеально, на тестовом устройстве тоже всё нормально, а после релиза выясняется, что у части пользователей нижняя кнопка наполовину спрятана под системной панелью Android.

Мы наступили именно на такой баг.

Причём проблема оказалась не в конкретной модели смартфона, не в React Native и даже не в особенностях верстки конкретного экрана. Мы просто всё время проверяли приложение в одном режиме системной навигации.

Если вы ни разу не переключали Android Emulator с жестовой навигации на классические три кнопки, есть целый класс багов, который вы могли физически не увидеть.

Ниже разберём, почему это происходит, как Android теперь работает с системными панелями, как измерять реальные Window Insets через ADB и как превратить проверку в воспроизводимый тест.

Содержание

Откуда вообще появляется баг

Современный Android Emulator обычно запускается с жестовой навигацией.

На нашем тестовом AVD Pixel 9 с Android 16 / API 36 и плотностью 420 dpi нижняя системная область в этом режиме занимала:

24dp.

Переключаем устройство на классическую трёхкнопочную навигацию:

48dp.

Разница — ровно 24dp.

Если кнопка в приложении прибита к нижней границе окна абсолютным позиционированием и код не учитывает системный inset, эти дополнительные 24dp оказываются поверх неё.

На жестах экран выглядит нормально. На трёх кнопках — уже нет.

И это особенно неприятно потому, что проблема может пройти:

  • локальную разработку;
  • QA;
  • тестирование на эмуляторе;
  • screenshot review;
  • проверку в Expo development build.

Просто потому, что все смотрели приложение в одном и том же режиме навигации.

Почему системные панели теперь рисуются поверх приложения

Главная причина, почему такие проблемы стали особенно заметны, — переход Android на обязательный edge-to-edge.

Для приложений с targetSdk 35, работающих на Android 15, edge-to-edge включается по умолчанию. Android больше не резервирует для приложения прямоугольник между статус-баром и навигационной панелью: окно приложения занимает весь экран, а системные панели располагаются поверх него.

В Android 15 существовал временный способ отказаться от такого поведения через:

windowOptOutEdgeToEdgeEnforcement

Но для приложения с targetSdk 36, работающего на Android 16, этот механизм отключён: отказаться от edge-to-edge уже нельзя. Важная тонкость: если такое же приложение с targetSdk 36 запускается на Android 15, opt-out ещё может сработать.

Практический вывод простой:

Insets теперь являются частью верстки приложения, а не особенностью конкретного телефона.

Что происходит с цветами системных панелей

Здесь есть нюанс, который легко потерять в упрощённых объяснениях.

В Android 15:

  • setStatusBarColor / statusBarColor deprecated и больше не влияют на status bar;
  • setNavigationBarColor / navigationBarColor также deprecated и не влияют на жестовую navigation bar;
  • в режиме трёх кнопок navigationBarColor пока ещё может влиять на панель.

То есть рассчитывать на старую модель «покрасим системную панель и Android сам отделит её от контента» больше нельзя. Для edge-to-edge нужно правильно рисовать фон и работать с inset’ами.

Insets — это не просто paddingBottom

Ещё одна распространённая ошибка — считать safe area одним числом снизу.

На практике типов inset’ов несколько.

Есть:

  • statusBars;
  • navigationBars;
  • captionBar;
  • displayCutout;
  • ime;
  • systemGestures;
  • tappableElement.

statusBars, navigationBars и captionBar объединяются в systemBars.

Для обычного контента полезно ориентироваться на safe drawing area. В Compose для этого существует WindowInsets.safeDrawing. Для элементов с собственными жестами отдельно имеет смысл учитывать gesture-safe область: системный свайп назад, например, может конфликтовать с каруселью или горизонтальным swipe вашего интерфейса.

Но главное правило здесь даже не в названиях API.

У экрана четыре стороны.

Нельзя прочитать только bottom и считать задачу решённой.

Почему — станет понятно на ландшафтном режиме.

Реальные размеры системных панелей на Pixel 9

Все следующие значения мы снимали на живом Android Emulator:

  • AVD Pixel 9;
  • Android 16 / API 36;
  • density 420 dpi.

Получили такие значения:

РежимFrame нижней панелиПикселейВ dp
Жесты (navigation_mode=2)[0,2361][1080,2424]6324dp
Три кнопки (navigation_mode=0)[0,2298][1080,2424]12648dp
Статус-бар (портрет, с врезом камеры)[0,0][1080,142]142~54dp

Плотность устройства — 420 dpi.

У Android базовая плотность соответствует 160 dpi, поэтому:

420 / 160 = 2.625

То есть:

1dp = 2.625px

Проверяем жестовую панель:

63 / 2.625 = 24dp

Трёхкнопочную:

126 / 2.625 = 48dp

Именно поэтому элемент, который случайно «выживает» при 24dp, легко оказывается под системной панелью при 48dp.

Ландшафт — главная ловушка

Теперь поворачиваем устройство.

На трёхкнопочной навигации получили:

frame=[2298,0][2424,1080] sideHint=RIGHT

Навигационная панель больше не находится снизу.

Она занимает 48dp справа.

Нижний inset при этом равен нулю.

И вот здесь код вроде:

paddingBottom: insets.bottom

уже принципиально недостаточен.

В портрете он работает.

В ландшафте кнопка может оказаться под системной панелью справа.

Поэтому layout должен реагировать на:

  • top;
  • right;
  • bottom;
  • left.

То же самое относится к вырезу камеры.

На нашем Pixel 9 статус-бар в портрете занимал около 54dp, а не какие-то универсальные «канонические 24dp».

Высота зависит от конфигурации устройства и display cutout.

Хардкодить высоту status bar нельзя.

Как переключить Android Emulator на три кнопки

Вот команда, которая в итоге оказалась для нас самой полезной во всём расследовании.

Три кнопки

adb shell cmd overlay enable-exclusive --category \
  com.android.internal.systemui.navbar.threebutton

Жестовая навигация

adb shell cmd overlay enable-exclusive --category \
  com.android.internal.systemui.navbar.gestural

Две кнопки

Legacy-режим, но проверить его тоже можно:

adb shell cmd overlay enable-exclusive --category \
  com.android.internal.systemui.navbar.twobutton

Здесь принципиально важен параметр:

enable-exclusive --category

Мы сначала попробовали обычный:

cmd overlay enable

И получили неприятное состояние: несколько overlay отображались как включённые одновременно — [x].

То есть по списку уже было непонятно, какой режим реально активен.

enable-exclusive --category одновременно включает нужный overlay и выключает остальные overlay этой категории.

Проверяем, что режим действительно изменился

После переключения overlay обязательно читаем реальное значение:

adb shell settings get secure navigation_mode

Значения:

0 = 3 кнопки
1 = 2 кнопки
2 = жесты

Если нужно посмотреть доступные navbar overlay:

adb shell cmd overlay list | grep navbar

Это полезнее, чем просто смотреть глазами на экран: автоматический тест тоже должен иметь возможность проверить, что устройство действительно перешло в нужное состояние.

Как посмотреть реальные размеры системных панелей

Для наших измерений использовались следующие команды:

adb shell dumpsys window displays | grep -E "type=(navigationBars|statusBars)"
adb shell dumpsys window displays | grep mDisplayFrame
adb shell wm density
adb shell wm size

Первая показывает данные о системных панелях.

wm density нужен для перевода пикселей в dp.

Например:

density = 420

Значит:

420 / 160 = 2.625 px/dp

После этого любой frame из dumpsys можно перевести в понятные размеры.

Как проверить display cutout

Посмотреть доступные эмуляции:

adb shell cmd overlay list | grep cutout

На эмуляторе могут быть доступны:

  • corner;
  • double;
  • hole;
  • tall;
  • waterfall;
  • emu01.

Например, включаем высокий cutout:

adb shell cmd overlay enable-exclusive --category \
  com.android.internal.display.cutout.emulation.tall

Но здесь есть важная ловушка.

В Pixel AVD вырез может быть уже задан самим device profile или skin. В таком случае дополнительный cutout overlay визуально включится, но фактическая геометрия окна не изменится.

Поэтому после переключения снова смотрим dumpsys.

Если статус-бар был 142px и остался 142px, значит тест фактически ничего не изменил.

Для тестирования разных cutout надёжнее использовать generic AVD или Developer options → Display cutout.

Главное правило:

не доверяем настройке — доверяем фактическому dumpsys.

Поворачиваем устройство через ADB

Отключаем автоматический поворот:

adb shell settings put system accelerometer_rotation 0

Переключаем ориентацию:

adb shell settings put system user_rotation 1

Значения:

0 — портрет
1 — ландшафт
2/3 — остальные ориентации

Есть ещё одна мелочь, которая стоила нам времени.

Если на переднем плане находится launcher, который не допускает нужную ориентацию, команда может выполниться, а экран фактически не повернётся.

Сначала запускайте проверяемое приложение, затем меняйте user_rotation.

Ещё несколько способов быстро сломать свою верстку

Navbar — далеко не единственная ось тестирования.

Увеличенный шрифт

adb shell settings put system font_scale 1.3

Изменённая плотность

adb shell wm density 480

Искусственно уменьшаем высоту экрана

adb shell wm size 1080x2100

Возвращаем настройки

adb shell wm density reset && adb shell wm size reset

Это очень дешёвые тесты.

Один экран может выглядеть нормально при стандартном размере, но разваливаться сразу после увеличения font scale или уменьшения доступной высоты.

Как проверить клавиатуру

Отдельно нужно тестировать ime inset.

Если у вашего AVD включена аппаратная клавиатура, Android может вообще не показывать экранную клавиатуру.

Для корректного теста выставляем в конфигурации AVD:

hw.keyboard=no

После этого можно проверить, что происходит с:

  • формой;
  • нижними кнопками;
  • bottom sheet;
  • sticky footer;

когда одновременно присутствуют navigation bar и IME.

Полезная визуальная диагностика

Можно включить отображение границ layout:

adb shell setprop debug.layout true

И заставить интерфейс перерисоваться:

adb shell service call activity 1599295570

Для сохранения состояния экрана:

adb exec-out screencap -p > shot.png

Этого уже достаточно, чтобы вместо «потыкали приложение глазами» получить воспроизводимый набор скриншотов.

Скрипт: проверяем три режима навигации автоматически

Вот минимальный скрипт, который имеет смысл положить прямо в репозиторий проекта:

#!/usr/bin/env bash
set -e
for mode in gestural threebutton twobutton; do
  adb shell cmd overlay enable-exclusive --category \
    "com.android.internal.systemui.navbar.$mode"
  sleep 3
  echo "$mode -> navigation_mode=$(adb shell settings get secure navigation_mode)"
  adb exec-out screencap -p > "shot-$mode.png"
done

На выходе получаем:

shot-gestural.png
shot-threebutton.png
shot-twobutton.png

Их можно:

  • посмотреть рядом;
  • сравнить через image diff;
  • прикрепить как CI artifact;
  • использовать в screenshot-тестах.

Следующий очевидный шаг — добавить внешний цикл по:

  • font_scale;
  • ориентации;
  • нескольким AVD.

Например, отдельно прогонять компактный телефон, обычный флагман и планшет.

Дальше это уже можно связывать с Paparazzi, Roborazzi, Maestro или вашим существующим pipeline screenshot-тестов.

Главное здесь не конкретный инструмент.

Главное — перестать считать системную навигацию неизменной частью окружения.

Какие AVD нужны минимум

Одного Pixel недостаточно.

Мы бы закладывали минимум три типа устройств.

1. Небольшой телефон

Он быстро показывает проблемы с вертикальным пространством.

Особенно полезен вместе с:

adb shell settings put system font_scale 1.3

2. Современный флагман с вырезом

Нужен для проверки:

  • status bar;
  • display cutout;
  • edge-to-edge;
  • gesture navigation;
  • three-button navigation.

3. Планшет

Там появляется ещё один источник inset’ов — системный taskbar.

Если аудитория приложения использует foldable-устройства, добавляем и складной профиль.

Кроме свежего API имеет смысл держать хотя бы одну версию Android около вашего minSdk: поведение системного UI отличается.

И обязательно проверять multi-window / split-screen.

Размер окна приложения в таком режиме уже не совпадает с размером физического дисплея.

Что смотреть на каждом экране

При проверке нас в первую очередь интересуют следующие места.

  1. FAB и плавающие кнопки. Особенно всё, что использует абсолютное позиционирование относительно нижней границы.
  2. Последний элемент ScrollView / FlatList / списка. Нижний inset должен позволять контенту полностью проскроллиться выше системной панели.
  3. Sticky footer, bottom toolbar, bottom sheet, snackbar.
  4. Открытая клавиатура. Проверяем комбинацию IME и системной панели.
  5. Ландшафт. Navbar может оказаться справа, а cutout — сбоку.
  6. Верхняя часть интерфейса. Заголовки, back button, toolbar и контент около status bar.
  7. Диалоги и модальные окна. У них может быть собственная геометрия окна и собственная обработка inset’ов.
  8. Горизонтальные жесты около края. Системный back gesture может конкурировать с carousel, swipe-to-delete и другими собственными жестами приложения. Android отдельно предоставляет gesture-safe insets именно для таких сценариев.

Типовые ошибки

Хардкодить 24dp или 48dp

Сегодня у вас получилось 48dp.

Это не означает:

paddingBottom = 48

Это означает:

нужно читать inset.

Наши 24dp и 48dp нужны не как константы для приложения, а как демонстрация того, насколько сильно могут различаться два режима на одном и том же AVD.

Учитывать только bottom

Портрет скрывает эту ошибку.

Ландшафт показывает её сразу.

Insets должны рассматриваться для всех четырёх сторон.

Считать inset константой

Пользователь может изменить режим системной навигации.

Layout должен получать актуальное состояние, а не значение, вычисленное один раз при разработке.

Применять safe-area два раза

Обратная проблема тоже встречается постоянно.

Например:

  • родитель уже добавил safe area;
  • дочерний экран снова добавляет тот же inset.

В результате вместо перекрытия получаем огромную пустую полосу снизу.

Использовать размер дисплея вместо размера окна

Это особенно быстро ломается в split-screen, multi-window и на больших экранах.

Android View

В обычном Android View базовый инструмент — listener Window Insets:

ViewCompat.setOnApplyWindowInsetsListener

Для системных панелей используется:

WindowInsetsCompat.Type.systemBars()

Android рекомендует обрабатывать визуальные пересечения через Window Insets, а для edge-to-edge использовать соответствующие API вместо предположений о размерах системных областей.

fitsSystemWindows может помочь быстро исправить простой экран, но для сложного layout это слишком грубый инструмент.

Jetpack Compose

В Compose есть:

Modifier.safeDrawingPadding()

и:

WindowInsets.safeDrawing
WindowInsets.systemBars
WindowInsets.ime

safeDrawing предназначен именно для контента, который не должен быть визуально перекрыт системным UI.

Отдельная частая ошибка — Scaffold.

Он отдаёт PaddingValues, но разработчик принимает параметр и затем просто не применяет его к контенту.

Также при сложной вложенности стоит следить за consumeWindowInsets, чтобы один и тот же inset не применялся несколько раз.

React Native и Expo

В React Native / Expo основной инструмент — react-native-safe-area-context.

Есть два основных подхода:

SafeAreaView

и:

useSafeAreaInsets()

У SafeAreaView по умолчанию применяются все четыре стороны:

["top", "right", "bottom", "left"]

Причём собственный padding компонента прибавляется к safe-area padding.

И вот здесь находится очень типичная причина нашего класса багов:

edges={['top']}

Сверху всё становится красиво.

А снизу safe-area намеренно отключена.

Пока используется жестовая навигация, ошибка может быть почти незаметна.

Потом пользователь включает три кнопки — и элемент:

position: absolute
bottom: X

оказывается под системной панелью.

Для Expo это особенно актуально в edge-to-edge layout: актуальная документация прямо рекомендует safe areas для предотвращения перекрытия контента системными панелями.

Мы предпочитаем не исправлять каждый экран отдельно, а иметь один источник правды.

Например, общий useLayout(), который возвращает bottomInset.

Тогда экран вычисляет:

bottom = base + bottomInset

а не заменяет собственный дизайнерский отступ системным inset’ом.

Flutter

Во Flutter важно понимать разницу между:

MediaQuery.viewPadding
MediaQuery.viewInsets
MediaQuery.padding

Для типового интерфейса удобен SafeArea.

Но и здесь нужно следить за вложенностью.

Если Scaffold или другой родитель уже учёл системную область, дополнительный SafeArea может привести к двойному отступу.

Особенно внимательно стоит тестировать экран вместе с клавиатурой: IME меняет доступную область иначе, чем обычные системные панели.

Реальный кейс: детское шахматное приложение

У нас проблема проявилась в детском шахматном приложении на Expo.

На карте уровней была плавающая кнопка Play.

Она использовала абсолютное позиционирование относительно нижней части экрана.

Корневой safe-area контейнер при этом был настроен так:

edges={['top']}

На нашем стандартном эмуляторе с жестовой навигацией всё выглядело нормально.

Переключили AVD на три кнопки — и кнопка наполовину ушла под navigation bar.

После этого мы проверили остальные экраны.

Из 14 экранов сломаны были ровно два.

И это были именно те два экрана, где нижняя safe area ранее была вручную отключена.

Остальные использовали safe area со всеми сторонами и не пострадали.

Исправление заняло несколько строк: нижний inset вынесли в общий layout hook и стали прибавлять его к базовой позиции кнопки.

А воспроизведение бага после этого занимало буквально один переключатель ADB и скриншот.

И вот это, пожалуй, главная мораль всей истории.

Проблема была не столько в верстке конкретного экрана. Проблема была в нашем тестовом окружении: мы просто ни разу не переключали режим системной навигации.

Что добавить в предрелизный чек-лист

После этого кейса наш минимальный набор проверки Android выглядит так:

  1. Жестовая навигация.
  2. Три кнопки.
  3. Портрет.
  4. Ландшафт.
  5. Увеличенный font scale.
  6. Экранная клавиатура.
  7. Устройство с cutout.
  8. Небольшой экран.
  9. Планшет.
  10. Split-screen для экранов, где это имеет смысл.

Причём первые два пункта практически бесплатны:

adb shell cmd overlay enable-exclusive --category \
  com.android.internal.systemui.navbar.threebutton

и:

adb shell settings get secure navigation_mode

На переключение уходит меньше времени, чем на описание баг-репорта.

Вывод

Системные панели Android больше нельзя воспринимать как фиксированную рамку вокруг приложения.

В edge-to-edge layout окно приложения занимает весь доступный экран, а ответственность за безопасное размещение интерфейса лежит на самом приложении. Для targetSdk 35 это стало стандартным поведением на Android 15, а для targetSdk 36 на Android 16 отказаться от него уже нельзя.

Поэтому:

  • инсеты читаем, а не хардкодим;
  • учитываем все четыре стороны, а не только bottom;
  • тестируем жесты и три кнопки;
  • проверяем фактическое состояние через settings и dumpsys;
  • держим один источник правды по safe-area в проекте;
  • автоматизируем screenshot-прогон.

Если ваш Android-эмулятор всю жизнь работал только в gesture navigation, начните хотя бы с этой команды:

adb shell cmd overlay enable-exclusive --category \
  com.android.internal.systemui.navbar.threebutton

После неё стоит открыть основные экраны приложения ещё раз.

Есть неплохой шанс увидеть несколько вещей, которые до этого просто негде было увидеть.

Редакция NJ Soft
Редакция NJ Soft
Редактор блога

Поделиться:

Нужна разработка или поддержка проекта?

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