Клавиатура и безопасные зоны: что здесь рисуется, но мертво

ввод≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В прошлой статье диалог «Удалить подход?» встал посреди экрана, и до краёв ему дела не было. Форме подхода есть: сверху шапка «Тренировка», снизу поле повторов и «Записать». А по краям у телефона то, чего нет у окна браузера на компьютере: сверху полоса состояния с вырезом камеры, снизу полоса «домой», и в любой момент снизу выезжает клавиатура — прямо поверх поля, в которое печатают. У React Native для этого четыре имени: SafeAreaView, KeyboardAvoidingView, Keyboard и StatusBar. В предпросмотре все четыре есть, рисуются и ничего не делают, причём молча. Поэтому соберём свой провайдер безопасных отступов по образцу библиотеки, которую expo-router ставит в корень каждого приложения, научим его клавиатуре и составим поимённый список того, что в предпросмотре рисуется, но мертво. Этажей три: четыре имени — React Native; react-native-safe-area-context — библиотека сообщества, не входящая ни в React Native, ни в Expo; ставит её провайдер expo-router.

Разделы темы:·

Соберём экран формы так, как его собирают по документации React Native. Весь экран — в SafeAreaView: он должен отодвинуть содержимое от выреза и полосы «домой». Внутри — KeyboardAvoidingView с behavior="padding": когда поднимется клавиатура, он добавит снизу отступ на её высоту. StatusBar просит светлые значки в полосе состояния, а Keyboard.addListener подписывается на появление клавиатуры. Поле повторов получает фокус с первого кадра. Заглянуть в разметку помогает inspectBox из подложки: сам пример document не видит. Прогноз: что напечатается и появится ли строка «клавиатура:»?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Строки «клавиатура:» нет, а ошибок нет ни одной — ни у компилятора, ни в консоли. Поле в фокусе, но Keyboard.isVisible() говорит false. SafeAreaView попросил у браузера отступы с четырёх сторон и получил четыре нуля. Начнём с него — вот он весь в react-native-web:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Это обычный View с отступами из CSS-функции env() (старому Safari — constant()). Числа за ней знает браузер, и не нулями они бывают только на телефоне с вырезом и только у страницы, которая разрешила рисовать под ним, — viewport-fit=cover в метатеге viewport. Кадр примера на компьютере — не тот случай, отсюда нули. Остальные трое пусты уже по исходнику:

ИмяЧто внутри в react-native-web 0.21.2
KeyboardAvoidingViewкласс, который запоминает свою рамку из onLayout, а метод onKeyboardChange(event) {} пустой и ни на что не подписан. render отбрасывает behavior, contentContainerStyle и keyboardVerticalOffset и рисует View
KeyboardaddListener возвращает { remove: () => {} }, isVisible() всегда false, у metrics и scheduleLayoutAnimation нет даже заглушек. Настоящий только dismiss(): он снимает фокус с поля. Над объектом комментарий, что когда-нибудь можно будет взять браузерный API виртуальной клавиатуры
StatusBarфункция, которая возвращает null; setBarStyle, setHidden и остальные статические методы — пустые функции, currentHeight нет

Keyboard.metrics — снова случай из статьи «Что в этой песочнице настоящее», где типы шире любого рантайма: в типах react-native это метод, в предпросмотре — undefined, и вызов упал бы с TypeError. А StatusBar.currentHeight не всегда число и на телефоне: по StatusBar.js react-native 0.86 это высота полосы на Android и null на iOS.

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

У телефона отступы знает система, а не CSS, и получить их можно только у неё. Как это делает сам React Native, видно по SafeAreaView.js из react-native 0.86. Файл короткий:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

На iOS это нативный компонент, который сам ставит себе отступы. На Android и всюду ещё — просто View, и отступов он не даст вовсе. К тому же компонент помечен устаревшим, с советом брать react-native-safe-area-context. Пометку @deprecated компилятор в предпросмотре знает, но в список ошибок не выносит: редактор просто зачеркнул бы имя.

react-native-safe-area-context — библиотека сообщества, не часть React Native и не пакет Expo. Отступы в ней — не padding одного компонента, а числа для всего приложения. В корне стоит SafeAreaProvider. Его нативный вид сообщает отступы событием onInsetsChange — при запуске и всякий раз, когда они меняются, например при повороте. Провайдер кладёт их в контекст, а экраны читают хуком useSafeAreaInsets() и ставят туда, куда нужно: шапке сверху, панели снизу, строкам списка по бокам. Кто что даёт на трёх платформах:

iOSAndroidВеб
SafeAreaView из react-nativeнативный компонент ставит себе paddingобычный View: отступов нетView с padding из env()
react-native-safe-area-contextнативный провайдер шлёт onInsetsChangeто жескрытый div с padding из env(): числа читаются из вычисленного стиля при монтировании и заново по концу CSS-перехода

В проекте на Expo провайдер руками не ставят: это делает expo-router. Вот корень, который он рисует вокруг всего приложения:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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

В предпросмотре не помогла бы и библиотека, будь здесь npm: на вебе она читает те же env() и получила бы те же нули. Чтобы увидеть отступы в работе, нужен телефон. Его в песочнице нет, поэтому телефон изобразит подложка, а провайдер соберём сами.

В подложке появился device — телефон для примеров, как finger был пальцем. Числа у него от iPhone 15: экран 393 × 852 точки, в портрете сверху 59 — полоса состояния с «островом» камеры, — снизу 34, полоса «домой». Отступы он шлёт так же, как нативный вид библиотеки: событием insetsChange с отступами и рамкой, и первое приходит уже после раскладки. rotate() поворачивает телефон.

Провайдер — контекст из части IV (статья «Контекст: данные без пробрасывания») и состояние, которое обновляет событие телефона. Имена и форму берём у библиотеки: SafeAreaProvider с необязательным initialMetrics и хук useSafeAreaInsets(), который без провайдера бросает ошибку с её же текстом. Тогда экран, написанный под наш провайдер, заработает и с настоящим. Прогноз: сколько раз и с какими числами напечатается «экран» — до поворота и после?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Первая отрисовка провайдера прошла без отступов, и экрана в ней не было вовсе: Workout впервые напечатался только после события телефона. Так устроена и библиотека — в её SafeAreaProvider дети рисуются под условием insets != null. Нарисовать экран с нулями было бы хуже: первый кадр поставил бы шапку под вырез, второй сдвинул бы её на 59 точек вниз.

После поворота отступы переехали: сверху 0 — в альбомной ориентации iPhone прячет полосу состояния, — слева и справа по 59, потому что вырез ушёл вбок, а система держит поле с обеих сторон. Полоса «домой» снизу стала тоньше, 21. Экран перерисовался сам: он читает отступы из контекста, и новое событие дошло до него без единой строки кода.

Пустой первый кадр чинит initialMetrics: если отступы известны до первой отрисовки, провайдер кладёт их в состояние сразу. На телефоне их знают заранее — библиотека при запуске синхронно читает константы своего нативного модуля и отдаёт их как initialWindowMetrics. На остальных платформах того же имени файл другой:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Это тот же приём, что в статье «Одно приложение, три платформы», только в чужом пакете: сборщик для телефона берёт файл с .native, для веба — без. На вебе отступы заранее не знает никто: env() читается из стилей уже нарисованной страницы. Поэтому expo-router там подставляет нули, а рядом стоит комментарий «SSR support»: странице, отрисованной на сервере, экран нужен с первого кадра. Как убрать пустой кадр в своём провайдере — вторая задача.

useSafeAreaInsets без провайдера бросает ошибку, а не отдаёт нули. Нули выглядели бы как «телефон без выреза», и шапка тихо уехала бы под полосу состояния.

На iPhone клавиатура выезжает поверх экрана и ничего в нём не сжимает — ровно туда, где у формы подхода поле повторов и «Записать». На Android это зависит от того, как окно приложения настроено отвечать клавиатуре (android:windowSoftInputMode). От этой же настройки, как предупреждает описание Keyboard.addListener в типах react-native, зависит и то, какие события клавиатуры вообще придут. Как клавиатуру обходит KeyboardAvoidingView, видно по его исходнику:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

На iOS он слушает «клавиатура сейчас появится», keyboardWillShow, и сдвигает экран вместе с ней одной анимацией: длительность и кривую берёт из события и отдаёт в LayoutAnimation.configureNext. На Android событий Will нет — в описании addListener так и сказано, — и там он ждёт keyboardDidShow, когда клавиатура уже на месте. Сдвиг — не вся высота клавиатуры, а только то, на сколько она залезла на сам вид: низ вида минус верхний край клавиатуры. Результат уходит в paddingBottom, height или bottom — смотря по behavior.

Нам нужно то же, но числом, а не компонентом: сколько отступить, решает сама форма. У device в подложке есть и клавиатура, show(height) и hide(); её высота в примере условная, 300 точек, — настоящая зависит от телефона и вида клавиатуры. Её addListener той же формы, что у Keyboard из react-native, и мы приписываем её к тому же типу — Pick<KeyboardStatic, "addListener">, как MyAlert к AlertStatic в прошлой статье. На телефоне вместо неё встанет настоящий Keyboard, а хук не заметит подмены. Провайдер из прошлого раздела и FormField из формы подхода — в подложке. Прогноз: какой отступ окажется под формой, пока клавиатура поднята?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
334 — на 34 точки больше клавиатуры. Отступ полосы «домой» и высота клавиатуры сложились, хотя полоса в это время под клавиатурой и заслонять ей нечего. Между полем и клавиатурой висит пустая полоса в 34 точки, на экране это видно сразу. Отступы от одного края не складывают — из них берут больший: const bottom = Math.max(insets.bottom, keyboardHeight);. С этой строкой под формой 34, 300 и снова 34.

В библиотеке для этого есть режим края. Её SafeAreaView принимает edges, и у каждого края свой режим: "additive" прибавляет отступ системы к вашему padding, "maximum" берёт больший из двух, "off" край не трогает:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Форме над клавиатурой подходит edges={{ bottom: "maximum" }} и высота клавиатуры в собственном paddingBottom; остальные края при такой записи выключены. Без edges все четыре края работают как "additive" — ровно та ошибка, что в примере. Для клавиатуры тоже есть своя библиотека на случай, когда KeyboardAvoidingView уже мало, — react-native-keyboard-controller. Она стоит на полке React Native и Expo, и там же видно, что Expo Go ей не помеха: её модуль в него уже вшит, а вся цена — обязательный сосед, Reanimated.

В веб-версии «Подхода» на телефоне KeyboardAvoidingView и Keyboard так же глухи, но клавиатуру видно по высоте окна: react-native-web берёт её у visualViewport, а он при открытой клавиатуре меньше. Ради этого его и взяли — так сказано в комментарии в исходнике Dimensions.

Четыре имени из первого раздела не единственные. За эту главу набралось достаточно, чтобы свести всё в одну таблицу. Каждая строка сверена с исходниками react-native-web 0.21.2 и react-native 0.86:

ЧтоВ предпросмотреНа телефонеГде разобрано
SafeAreaViewView с padding из env(): в кадре 0iOS — отступы; Android — обычный View; устарелэта статья
KeyboardAvoidingViewView; behavior и keyboardVerticalOffset отброшеныподнимает содержимое над клавиатуройэта статья
Keyboardпустая подписка, isVisible() всегда false, metrics нет; работает dismiss()события и размеры клавиатурыэта статья
StatusBar из react-native (веб-сборка expo-status-bar устроена так же)возвращает null, методы пустыецвет значков, видимость полосыэта статья
LayoutAnimation.configureNextколбэк конца зовётся сразу, анимации нетанимирует следующую раскладкуниже
Alert.alertпустой методсистемный диалог«Modal и свой Alert вместо пустышки»
BackHandler.addEventListenerпечатает ошибку, подписки неткнопка «назад» на Android«Modal и свой Alert вместо пустышки»
Pressable: hitSlop, pressRetentionOffsetне разбираются; onPress — это clickзона касания и удержания; onPress по отпусканию«Нажатие — это автомат, а не onClick»
TextInput: submitBehavior, onEndEditingпервое не читается, второго нетработают«TextInput: управляемое поле…», «Форма без библиотек»
ScrollView: keyboardShouldPersistTapsперехват первого касания закомментированпервое касание прячет клавиатуру«Форма без библиотек»
RefreshControlonRefresh отброшен, остаётся Viewобновление жестом«Бесконечная лента и обновление жестом», глава 3

Бывает и наоборот: onDismiss у Modal в предпросмотре зовётся, а на Android не позовётся никогда. Такое место опаснее мёртвого — пример работает, и проверять не хочется.

Строка про LayoutAnimation — повод для последнего прогноза. KeyboardAvoidingView на телефоне анимирует свой отступ через LayoutAnimation.configureNext: тот настраивает анимацию следующей раскладки и зовёт колбэк, когда она закончится. Пресет easeInEaseOut длится 300 мс и в react-native, и в react-native-web. Прогноз: в каком порядке напечатаются три строки?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

«Конец анимации» пришёл раньше, чем configureNext вернул управление. В react-native-web за ним стоит UIManager.configureNextLayoutAnimation, и тело у него в одну строку — onAnimationDidEnd(). Анимации нет, а конец есть. Код, который по этому колбэку, скажем, прокручивает к полю, в предпросмотре сработает мгновенно, а на телефоне — когда раскладка доанимируется.

Что это значит для «Подхода». Библиотека безопасных зон работает на всех трёх платформах: на телефоне у неё нативный провайдер, на вебе — те же env(), и в мобильном браузере они не нулевые, если страница разрешила viewport-fit=cover. Поэтому в проекте стоит она, а провайдер ей ставит expo-router. Наш провайдер остаётся предпросмотру — и чертежом: экран, написанный под useSafeAreaInsets, одинаково работает с обоими. Клавиатуру на телефоне обходит KeyboardAvoidingView или наш хук поверх настоящего Keyboard; в веб-версии оба глухи, и там остаётся высота окна из плашки выше.

Провайдер, хук useKeyboardHeight и device остаются в подложке этой статьи. Следующая глава, про навигацию, начинает со своей: экранов станет больше одного, и важнее станет не то, что у экрана по краям, а то, как экраны сменяют друг друга. Приём при этом не пропадёт. Экран, написанный под useSafeAreaInsets(), в проекте получит отступы от настоящего провайдера, а его руками не ставят: это делает expo-router.

  • В предпросмотре SafeAreaView, KeyboardAvoidingView, Keyboard и StatusBar рисуются и молчат: env() в кадре даёт ноль, onKeyboardChange пустой, addListener отдаёт пустую подписку, StatusBar возвращает null. Ни ошибки, ни предупреждения.
  • SafeAreaView из react-native 0.86 нативный только на iOS, на Android это View, и он устарел. Отступы на всех платформах даёт react-native-safe-area-context, а её SafeAreaProvider в корень ставит expo-router.
  • Отступы — числа от системы: провайдер получает их событием, кладёт в контекст, экраны читают useSafeAreaInsets() и ставят туда, куда нужно, — шапке сверху, панели снизу, строкам по бокам в альбомной ориентации.
  • Первое событие приходит после раскладки, и без initialMetrics первый кадр пуст. На телефоне initialWindowMetrics знает отступы заранее, на вебе — нет; нули вместо настоящих чисел дают прыжок шапки.
  • Клавиатура — ещё один нижний край: KeyboardAvoidingView слушает keyboardWillShow на iOS и keyboardDidShow на Android и сдвигает вид только на перекрытие. С полосой «домой» её не складывают, а берут больший отступ — режим "maximum".
  • Мёртвое выдаёт себя тишиной: проглоченный аргумент, подписка без событий, колбэк «конца анимации», который приходит сразу, как у LayoutAnimation. Такие места проверяют на телефоне, а в предпросмотре — своей миниатюрой.

1/4Что делает SafeAreaView из react-native на Android-телефоне и в предпросмотре?

2/4В предпросмотре подписались на Keyboard.addListener("keyboardDidShow", fn) и поставили фокус в поле. Что будет?

3/4Внизу экрана полоса «домой» с отступом 34, клавиатура поднялась на 300. Какой отступ поставить под форму?

4/4Почему SafeAreaProvider без initialMetrics не рисует экран в первом кадре?

Шапке — только верх

В подложке — провайдер из статьи. SafeAreaView ниже ставит все четыре отступа, и шапка «Тренировка» получила снизу 34 точки полосы «домой», до которой ей далеко. Научите его edges, как у библиотеки: отступ только с перечисленных краёв, а без edges — со всех четырёх.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Отступ края берём, только если край перечислен; без edges — все четыре, как у библиотеки.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: шапка: 59px 0px 0px 0px, низ: 0px 0px 34px 0px. У шапки в edges стоят ещё left и right: в портрете они нулевые, а повернёте телефон — шапка отодвинется от выреза сбоку. Библиотека делает на шаг больше: она не заменяет padding из вашего style, а прибавляет к нему отступ (режим "additive") и для этого сперва сплющивает стиль через StyleSheet.flatten. У нас отступ в массиве стилей стоит последним, и свой padding экрана он бы перекрыл.

Шапка со второго кадра

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

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Провайдеру нужен initialMetrics: с ним отступы лежат в состоянии с первой отрисовки.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: шапка, отступ сверху: 59, потом первый кадр собран. Событие телефона всё равно придёт, но с теми же отступами, и шапка второй раз не рисуется. На телефоне то же делает initialWindowMetrics из библиотеки: <SafeAreaProvider initialMetrics={initialWindowMetrics}>. Нули вместо настоящих чисел — не решение: шапка была бы в первом кадре, но под вырезом, и прыгнула бы на 59 точек вниз, когда придёт событие. На вебе initialWindowMetrics равен null, провайдер снова ждёт события — поэтому expo-router там и подставляет нули.

Клавиатура накрыла не всё

Напишите relativeKeyboardHeight — сколько клавиатура отнимает у вида. Правило возьмите из листинга KeyboardAvoidingView.js в разделе про клавиатуру. Рамка вида считается от родителя, клавиатура — от верха экрана, а keyboardVerticalOffset — то, что между ними, например шапка навигации.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ровно как в KeyboardAvoidingView.js: верхний край клавиатуры переводим в координаты вида и смотрим, насколько низ вида ниже него.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: 300, 217, 0 и 300. Форма над вкладками теряет не 300, а 217: остальные 83 точки клавиатура отнимает у панели вкладок. Форме в верхней половине сдвигаться незачем. Без keyboardVerticalOffset последняя строка дала бы 197: frame.y считается от родителя, screenY — от верха экрана, и шапка в 103 точки между ними теряется. Поэтому KeyboardAvoidingView под шапкой навигации без этого пропа поднимает форму недостаточно, и нижние 103 точки остаются под клавиатурой.