Клавиатура и безопасные зоны: что здесь рисуется, но мертво
Здесь будет встроен видео-разбор темы (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 не видит. Прогноз: что напечатается и появится ли строка «клавиатура:»?
Строки «клавиатура:» нет, а ошибок нет ни одной — ни у компилятора, ни в консоли. Поле в фокусе, но Keyboard.isVisible() говорит false. SafeAreaView попросил у браузера отступы с четырёх сторон и получил четыре нуля. Начнём с него — вот он весь в react-native-web:
Это обычный View с отступами из CSS-функции env() (старому Safari — constant()). Числа за ней знает браузер, и не нулями они бывают только на телефоне с вырезом и только у страницы, которая разрешила рисовать под ним, — viewport-fit=cover в метатеге viewport. Кадр примера на компьютере — не тот случай, отсюда нули. Остальные трое пусты уже по исходнику:
| Имя | Что внутри в react-native-web 0.21.2 |
|---|---|
KeyboardAvoidingView | класс, который запоминает свою рамку из onLayout, а метод onKeyboardChange(event) {} пустой и ни на что не подписан. render отбрасывает behavior, contentContainerStyle и keyboardVerticalOffset и рисует View |
Keyboard | addListener возвращает { 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. Файл короткий:
На iOS это нативный компонент, который сам ставит себе отступы. На Android и всюду ещё — просто View, и отступов он не даст вовсе. К тому же компонент помечен устаревшим, с советом брать react-native-safe-area-context. Пометку @deprecated компилятор в предпросмотре знает, но в список ошибок не выносит: редактор просто зачеркнул бы имя.
react-native-safe-area-context — библиотека сообщества, не часть React Native и не пакет Expo. Отступы в ней — не padding одного компонента, а числа для всего приложения. В корне стоит SafeAreaProvider. Его нативный вид сообщает отступы событием onInsetsChange — при запуске и всякий раз, когда они меняются, например при повороте. Провайдер кладёт их в контекст, а экраны читают хуком useSafeAreaInsets() и ставят туда, куда нужно: шапке сверху, панели снизу, строкам списка по бокам. Кто что даёт на трёх платформах:
| iOS | Android | Веб | |
|---|---|---|---|
SafeAreaView из react-native | нативный компонент ставит себе padding | обычный View: отступов нет | View с padding из env() |
react-native-safe-area-context | нативный провайдер шлёт onInsetsChange | то же | скрытый div с padding из env(): числа читаются из вычисленного стиля при монтировании и заново по концу CSS-перехода |
В проекте на Expo провайдер руками не ставят: это делает expo-router. Вот корень, который он рисует вокруг всего приложения:
Отсюда две вещи для своего провайдера. Он один на всё приложение и стоит выше навигации, поэтому любой экран достаёт отступы, не зная, где он в дереве. И у него есть initialMetrics: на вебе и в тестах expo-router подставляет нули, на телефоне — ничего. Зачем, увидим в следующем разделе.
В предпросмотре не помогла бы и библиотека, будь здесь npm: на вебе она читает те же env() и получила бы те же нули. Чтобы увидеть отступы в работе, нужен телефон. Его в песочнице нет, поэтому телефон изобразит подложка, а провайдер соберём сами.
В подложке появился device — телефон для примеров, как finger был пальцем. Числа у него от iPhone 15: экран 393 × 852 точки, в портрете сверху 59 — полоса состояния с «островом» камеры, — снизу 34, полоса «домой». Отступы он шлёт так же, как нативный вид библиотеки: событием insetsChange с отступами и рамкой, и первое приходит уже после раскладки. rotate() поворачивает телефон.
Провайдер — контекст из части IV (статья «Контекст: данные без пробрасывания») и состояние, которое обновляет событие телефона. Имена и форму берём у библиотеки: SafeAreaProvider с необязательным initialMetrics и хук useSafeAreaInsets(), который без провайдера бросает ошибку с её же текстом. Тогда экран, написанный под наш провайдер, заработает и с настоящим. Прогноз: сколько раз и с какими числами напечатается «экран» — до поворота и после?
Первая отрисовка провайдера прошла без отступов, и экрана в ней не было вовсе: Workout впервые напечатался только после события телефона. Так устроена и библиотека — в её SafeAreaProvider дети рисуются под условием insets != null. Нарисовать экран с нулями было бы хуже: первый кадр поставил бы шапку под вырез, второй сдвинул бы её на 59 точек вниз.
После поворота отступы переехали: сверху 0 — в альбомной ориентации iPhone прячет полосу состояния, — слева и справа по 59, потому что вырез ушёл вбок, а система держит поле с обеих сторон. Полоса «домой» снизу стала тоньше, 21. Экран перерисовался сам: он читает отступы из контекста, и новое событие дошло до него без единой строки кода.
Пустой первый кадр чинит initialMetrics: если отступы известны до первой отрисовки, провайдер кладёт их в состояние сразу. На телефоне их знают заранее — библиотека при запуске синхронно читает константы своего нативного модуля и отдаёт их как initialWindowMetrics. На остальных платформах того же имени файл другой:
Это тот же приём, что в статье «Одно приложение, три платформы», только в чужом пакете: сборщик для телефона берёт файл с .native, для веба — без. На вебе отступы заранее не знает никто: env() читается из стилей уже нарисованной страницы. Поэтому expo-router там подставляет нули, а рядом стоит комментарий «SSR support»: странице, отрисованной на сервере, экран нужен с первого кадра. Как убрать пустой кадр в своём провайдере — вторая задача.
useSafeAreaInsets без провайдера бросает ошибку, а не отдаёт нули. Нули выглядели бы как «телефон без выреза», и шапка тихо уехала бы под полосу состояния.
На iPhone клавиатура выезжает поверх экрана и ничего в нём не сжимает — ровно туда, где у формы подхода поле повторов и «Записать». На Android это зависит от того, как окно приложения настроено отвечать клавиатуре (android:windowSoftInputMode). От этой же настройки, как предупреждает описание Keyboard.addListener в типах react-native, зависит и то, какие события клавиатуры вообще придут. Как клавиатуру обходит KeyboardAvoidingView, видно по его исходнику:
На 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 из формы подхода — в подложке. Прогноз: какой отступ окажется под формой, пока клавиатура поднята?
const bottom = Math.max(insets.bottom, keyboardHeight);. С этой строкой под формой 34, 300 и снова 34.В библиотеке для этого есть режим края. Её SafeAreaView принимает edges, и у каждого края свой режим: "additive" прибавляет отступ системы к вашему padding, "maximum" берёт больший из двух, "off" край не трогает:
Форме над клавиатурой подходит 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:
| Что | В предпросмотре | На телефоне | Где разобрано |
|---|---|---|---|
SafeAreaView | View с padding из env(): в кадре 0 | iOS — отступы; Android — обычный View; устарел | эта статья |
KeyboardAvoidingView | View; 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 | перехват первого касания закомментирован | первое касание прячет клавиатуру | «Форма без библиотек» |
RefreshControl | onRefresh отброшен, остаётся View | обновление жестом | «Бесконечная лента и обновление жестом», глава 3 |
Бывает и наоборот: onDismiss у Modal в предпросмотре зовётся, а на Android не позовётся никогда. Такое место опаснее мёртвого — пример работает, и проверять не хочется.
Строка про LayoutAnimation — повод для последнего прогноза. KeyboardAvoidingView на телефоне анимирует свой отступ через LayoutAnimation.configureNext: тот настраивает анимацию следующей раскладки и зовёт колбэк, когда она закончится. Пресет easeInEaseOut длится 300 мс и в react-native, и в react-native-web. Прогноз: в каком порядке напечатаются три строки?
«Конец анимации» пришёл раньше, чем 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 — со всех четырёх.
Отступ края берём, только если край перечислен; без edges — все четыре, как у библиотеки.
Вывод: шапка: 59px 0px 0px 0px, низ: 0px 0px 34px 0px. У шапки в edges стоят ещё left и right: в портрете они нулевые, а повернёте телефон — шапка отодвинется от выреза сбоку. Библиотека делает на шаг больше: она не заменяет padding из вашего style, а прибавляет к нему отступ (режим "additive") и для этого сперва сплющивает стиль через StyleSheet.flatten. У нас отступ в массиве стилей стоит последним, и свой padding экрана он бы перекрыл.
Шапка со второго кадра
Провайдер из подложки, как и библиотечный, рисует экран только после первого события телефона. Сделайте так, чтобы шапка была в первом же кадре — и сразу с настоящим отступом, а не с нулём. Провайдер не трогайте: у него для этого есть проп.
Провайдеру нужен initialMetrics: с ним отступы лежат в состоянии с первой отрисовки.
Вывод: шапка, отступ сверху: 59, потом первый кадр собран. Событие телефона всё равно придёт, но с теми же отступами, и шапка второй раз не рисуется. На телефоне то же делает initialWindowMetrics из библиотеки: <SafeAreaProvider initialMetrics={initialWindowMetrics}>. Нули вместо настоящих чисел — не решение: шапка была бы в первом кадре, но под вырезом, и прыгнула бы на 59 точек вниз, когда придёт событие. На вебе initialWindowMetrics равен null, провайдер снова ждёт события — поэтому expo-router там и подставляет нули.
Клавиатура накрыла не всё
Напишите relativeKeyboardHeight — сколько клавиатура отнимает у вида. Правило возьмите из листинга KeyboardAvoidingView.js в разделе про клавиатуру. Рамка вида считается от родителя, клавиатура — от верха экрана, а keyboardVerticalOffset — то, что между ними, например шапка навигации.
Ровно как в KeyboardAvoidingView.js: верхний край клавиатуры переводим в координаты вида и смотрим, насколько низ вида ниже него.
Вывод: 300, 217, 0 и 300. Форма над вкладками теряет не 300, а 217: остальные 83 точки клавиатура отнимает у панели вкладок. Форме в верхней половине сдвигаться незачем. Без keyboardVerticalOffset последняя строка дала бы 197: frame.y считается от родителя, screenY — от верха экрана, и шапка в 103 точки между ними теряется. Поэтому KeyboardAvoidingView под шапкой навигации без этого пропа поднимает форму недостаточно, и нижние 103 точки остаются под клавиатурой.