Modal и свой Alert вместо пустышки

ввод≈ 16 минут

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

В прошлой статье долгое нажатие на строке подхода научилось срабатывать, пока палец ещё на экране, — осталось решить, что оно открывает. В «Подходе» это вопрос «Удалить подход?» с двумя кнопками. На телефоне такой вопрос задают одной строкой, Alert.alert(заголовок, текст, кнопки), и система рисует свой диалог. В предпросмотре эта строка не делает ничего, и не только в нём: веб-версия «Подхода» стоит на том же react-native-web. Поэтому соберём диалог сами — на Modal и с тем же API, что у Alert.alert, чтобы вызывающий код не заметил подмены. А по дороге выясним, чем свой диалог отличается от системного: где он живёт, что делает кнопка «назад» и что бывает, когда вопросов два. Этаж — React Native: Alert — его нативный модуль, Modal — его компонент.

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

В статье «Что в этой песочнице настоящее» Alert нашёлся в рантайме — класс со статическим alert, — и мы обещали вернуться к нему здесь. Вот вопрос, который задаст строка подхода: заголовок, текст и две кнопки, у второй — обработчик. Прогноз: что напечатает пример и появится ли «удалено» хотя бы через секунду?

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

Компилятор молчит: вызов написан по настоящим типам react-native 0.86. alert вернул undefined — на телефоне тоже, у метода тип void. А дальше ничего: ни диалога на экране примера, ни «удалено». Исходник метода напечатался целиком, и тело у него пустое. В react-native-web файл exports/Alert/index.js — это буквально класс с пустым методом:

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

Второго метода, prompt, в рантайме нет вовсе, хотя в типах он есть: его вызов упал бы с TypeError — тот же случай «типы шире рантайма». На телефоне prompt есть, но работает только на iOS: на Android его тело целиком стоит под if (Platform.OS === 'ios') и молча ничего не делает.

Что делает настоящий alert, видно по Libraries/Alert/Alert.js из react-native 0.86. Своего диалога в JS там нет: метод собирает аргументы и отдаёт нативному модулю — на iOS через prompt в RCTAlertManager, на Android в DialogManagerAndroid, по дороге отсекая лишние кнопки:

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

Веток две, iOS и Android. Третьей нет, а в предпросмотре и в веб-версии Platform.OS равен "web": даже настоящий Alert.js здесь промолчал бы, react-native-web просто сказал это короче. Отсюда стена этой статьи, и она не только в песочнице. В веб-версии «Подхода» вопрос «Удалить подход?» тоже не прозвучит, причём молча, без единой строки в консоли. Нужен свой диалог, а рисовать поверх экрана в React Native умеет Modal.

confirm() из части I останавливал страницу и возвращал true или false. Alert.alert не останавливает ничего: управление возвращается сразу, а ответ приходит позже — в onPress нажатой кнопки. Так будет и у нашего.

Modal рисует детей не на месте, а поверх всего экрана. На телефоне это буквально другое окно: на iOS содержимое показывает отдельный контроллер экрана (в Modal.js стиль показа по умолчанию fullScreen, у прозрачного — overFullScreen), на Android — отдельный Dialog со своим окном. Поэтому Modal накрывает и навигацию, и шапку, где бы он ни стоял в дереве. В предпросмотре react-native-web делает то же средствами веба: портал в конец body, position: fixed и z-index: 9999.

Пропсов, без которых не обходится ни один диалог, четыре. visible — показан ли. transparent — без него фон окна белый, с ним виден экран под диалогом, а затемнение рисуете вы. animationType — "none", "fade" или "slide". onRequestClose — о нём ниже. Кнопки «назад» в предпросмотре нет, поэтому в подложке лежит помощник pressBack(), который жмёт Escape: его react-native-web отдаёт открытому Modal туда же, куда Android отдаёт свою «назад», — в onRequestClose. Прогноз: кнопка открывает диалог, потом нажимают «назад». Что напечатается и в каком порядке?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
onShow пришёл после отрисовки с open: true: диалог сперва рисуется, потом сообщает, что показан; с анимацией — после её конца. «Назад» Modal не закрыла: она позвала onRequestClose, а закрыл его наш setOpen(false). На Android так задумано. В ReactModalHostView.kt «назад» и Escape перехватываются, чтобы диалог не закрылся сам, и решение отдаётся JS: захочет — выставит visible в false. Поэтому в типах про onRequestClose сказано, что он обязателен на iOS и Android. На iOS его зовёт жест смахивания листа, если разрешён allowSwipeDismissal.

Последняя строка — повод не верить предпросмотру. onDismiss в react-native 0.86 работает только на iOS, в Modal.js так прямо и написано: «OnDismiss is implemented on iOS only». react-native-web зовёт его и в вебе, а Android не позовёт никогда. Что диалог закрыт, надёжнее знать по своему состоянию — оно у вас и так есть.

Два пропса из примера легко забыть. Прогноз: что будет с Modal, у которого нет ни visible, ни onRequestClose?

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

Он показан сразу: visible по умолчанию true и в react-native, и в react-native-web, так что «показать по флагу» надо писать явно. И он не закрывается: «назад» дошла, но слушать её некому. На Android то же самое — перехваченная «назад» уходит в пустоту, и человек не может уйти с экрана. Подписаться на «назад» в обход Modal предпросмотр не даст: BackHandler.addEventListener в react-native-web только печатает ошибку «BackHandler is not supported on web and should not be used.».

Теперь соберём диалог с API Alert.alert. Трудность не в Modal, а в самом вызове. Alert.alert(…) зовут из обработчика — из долгого нажатия, из таймера отдыха, из кода синхронизации в главе 7, — а не пишут в разметке. Системному этого хватает: его диалог вообще не в дереве React. А Modal — компонент, и кто-то должен его отрисовать. Значит, частей три: вопрос, который лежит вне React; функция alert, которая его туда кладёт; и компонент-хозяин, который один раз стоит в корне и рисует Modal по этому вопросу. Подписывается хозяин через useSyncExternalStore, как в статье части IV «Свой хук: логика без разметки».

Тип alert не пишем руками, а берём у настоящего: Pick<AlertStatic, "alert"> из react-native, а аргументы — через Parameters, как в статье части III «Partial, Pick, Omit, Record, ReturnType, Awaited». Тогда компилятор проверит и нашу реализацию, и каждый вызов. Прогноз: что напечатается и в каком порядке?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
«alert вернул управление» напечаталось раньше, чем хозяин нарисовал вопрос. alert только положил аргументы и оповестил подписчиков, а отрисовка случилась позже, когда вызвавший код уже закончил, — как у системного, который тоже возвращает управление сразу. Нажатие «Удалить» закрыло вопрос и позвало обработчик кнопки. Порядок в onPress не случайный: сначала show(null), потом обработчик. Если обработчик сам задаст следующий вопрос — «Подход удалён. Вернуть?», — то show(null) после него этот вопрос тут же и стёр бы.

И API действительно тот же. Опечатайтесь в стиле кнопки, напишите style: "danger" — и компилятор скажет то же, что сказал бы про системный Alert: Тип ""danger"" не может быть назначен для типа ""default" | "cancel" | "destructive" | undefined". Значит, вызывающий код можно переключить с Alert на MyAlert одной строкой импорта — к этому вернёмся в конце статьи.

Вопрос лежит снаружи ещё и потому, что его могут задать раньше, чем хозяин смонтирован. Вызов не пропадёт: хозяин прочтёт его первым же снимком.

Хозяин и MyAlert переехали в подложку, к пальцу и usePress из прошлой статьи. Хозяин там чуть богаче: под заголовком стоит текст вопроса, надпись «Удалить» красная, у карточки затемнённый фон. Строка подхода на коротком нажатии открывает подход, на долгом задаёт вопрос. Прогноз: палец держит вторую строку 650 мс и отпускает, потом касается первой строки, потом нажимают «Удалить». Что напечатается?

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

Вопрос открылся через 500 мс удержания, и отпускание нажатием уже не стало: «открыть подход» не напечаталось — это правило долгого нажатия из прошлой статьи. Касание первой строки тоже ничего не открыло: палец попал не в строку, а в фон диалога. Без открытого диалога то же касание печатает «открыть подход 80 кг × 8», а с диалогом под пальцем оказывается его фон — мы проверили оба случая. Modal накрыл экран, и всё, что под ним, для касаний недоступно. На телефоне так же, только строже: там Modal — отдельное окно. Кнопки же диалога мы жмём fireEvent.press: это Pressable, а его onPress в предпросмотре — click, которого палец не присылает. Это тоже из прошлой статьи.

fireEvent.press жмёт по testID напрямую и не спрашивает, что лежит сверху: Pressable под открытым диалогом он нажал бы. Строку на usePress он не нажал бы и без диалога — у неё нет onClick. Проверяя, что диалог правда закрывает экран, берите палец.

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

Хозяин из подложки отвечает на «назад» так же, как системный диалог Android. У того cancelable по умолчанию false — это видно в листинге Alert.js из первого раздела, — и «назад» не делает ничего. С cancelable: true «назад» закрывает диалог и зовёт options.onDismiss. В хозяине это один обработчик:

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

Прогноз для одного и того же вопроса дважды — без cancelable и с ним. Что напечатается после каждого «назад»?

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

Без cancelable вопрос пережил «назад»: кнопка «Отмена» нашлась и нажалась. С cancelable «назад» его закрыла, но «Отмена: onPress» не напечаталось — позвался только onDismiss. Это поведение Android, а не наша прихоть. Закрытый диалог шлёт из DialogModule в JS действие dismissed, и Alert.js отвечает на него вызовом options.onDismiss, а не кнопки со стилем cancel. Код, который ждёт ответа только в кнопках, после «назад» не узнает на Android ничего. Как не потерять такой ответ — первая задача.

Второе расхождение — два вопроса сразу. Человек думает над «Удалить подход?», а таймер отдыха досчитал и спрашивает своё. Прогноз: какие кнопки удастся нажать и что напечатается?

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

Первый вопрос исчез без следа: show просто заменил его вторым. Кнопки «Удалить» больше нет, а «вопрос закрыт» не напечаталось — о своей пропаже первый вопрос не узнал. Системные диалоги ведут себя иначе, и каждый по-своему. Android тоже закрывает прежний: DialogModule перед показом нового зовёт dismissExisting(). Но закрытый диалог шлёт в JS dismissed, и «вопрос закрыт» там напечаталось бы. На iOS каждый вызов получает собственное окно — в RCTAlertController это новый UIWindow на уровне UIWindowLevelAlert + 1, — так что по исходнику второй вопрос встаёт поверх первого, а первый ждёт под ним. У нас третий вариант, и худший: ответ потерян молча. Очередь вместо замены — вторая задача.

Сведём различия. Слева — что делает системный диалог по исходникам react-native 0.86, справа — наш:

Системный Alert.alertСвой на Modal
Где живётвне дерева React: на iOS — собственное окно UIWindow, на Android — DialogFragmentв дереве React: Modal внутри хозяина, смонтированного в корне
Предпросмотр и вебпустой методработает
Сколько кнопокiOS — сколько передали; Android — первые три, остальные отбрасываютсясколько нарисуете
Места кнопок на Androidпоследняя из трёх — положительная, перед ней отрицательная, первая нейтральная; расставляет системакак в массиве
style и isPreferrediOS: отдельное место для cancel, красная destructive, выделенная isPreferred; Android их не читаетчто нарисуете: у нас красная destructive
Без кнопокодна «OK»: iOS переводит её на язык системы, Android пишет 'OK' как есть — в исходнике рядом комментарий, что перевести бы надоодна «OK»
«Назад» на Androidзакрывает только при cancelable: true и зовёт onDismiss, а не «Отмену»как напишете в onRequestClose; у нас — как у системного
Второй вопрос при открытом первомAndroid закрывает первый и зовёт его onDismiss; iOS показывает второй в новом окне поверхзаменяет первый молча — до второй задачи
Видсистемный, по теме телефона; на iOS тему задаёт userInterfaceStyle в optionsлюбой, в цветах «Подхода»

Свой диалог не хуже и не лучше системного — он другой. Системный на телефоне бесплатно даёт привычный вид, тему, перевод «OK» и окно выше любого Modal. Свой даёт ваш вид и работает там, где системного нет. Поэтому в «Подходе» стоят оба: на телефоне Alert из react-native, в вебе — MyAlert. Разводит их не if (Platform.OS === "web") в каждом вызове, а пара файлов — приём из статьи «Одно приложение, три платформы»: для веба сборщик возьмёт файл с .web, для телефона — без.

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

Экраны импортируют AppAlert и зовут AppAlert.alert(…) — тот же вызов, что у системного. Хозяин ставится в корневую раскладку expo-router рядом со стеком, а на телефоне просто ничего не рисует. Оба файла приписаны к одному типу, Pick<AlertStatic, "alert">, поэтому экран, написанный под один, работает и с другим.

MyAlert и хозяин переезжают в подложку дальше. Следующая статья — последняя в главе. Наш диалог стоит посреди экрана, и ему всё равно, что по краям. А по краям у телефона вырез камеры, полоса жестов и клавиатура, которая закрывает поле ввода. Там соберём свой провайдер безопасных отступов и назовём поимённо компоненты, которые в предпросмотре рисуются, но ничего не делают. Пустой Alert.alert войдёт в этот список со ссылкой сюда.

  • Alert.alert в react-native-web — static alert() {}: в предпросмотре и в веб-версии приложения вопрос не прозвучит вовсе, и молча. На телефоне это нативный модуль, RCTAlertManager на iOS и DialogManagerAndroid на Android; ветки для веба нет и в самом Alert.js.
  • Modal рисует детей поверх всего экрана, на телефоне — в отдельном окне. visible по умолчанию true, transparent убирает белый фон. «Назад» на Android и Escape приходят в onRequestClose, а закрывает диалог ваш код. onDismiss на телефоне работает только на iOS.
  • Свой Alert.alert — это три части: вопрос в хранилище вне React, функция alert с типом Pick<AlertStatic, "alert"> и хозяин в корне, подписанный через useSyncExternalStore. Кнопка сначала закрывает вопрос, потом зовёт свой onPress.
  • Как и системный, alert возвращает управление сразу, а ответ приходит в onPress кнопки. На Android «назад» при cancelable: true зовёт onDismiss, а не «Отмену», и промис поверх диалога обязан слушать и его.
  • Системный диалог Android показывает не больше трёх кнопок, не читает style и перед новым вопросом закрывает прежний, позвав его onDismiss; iOS кладёт новый вопрос в новое окно поверх. Свой делает то, что вы напишете, — включая молчаливую замену, если не написать очередь.
  • В «Подходе» — системный диалог на телефоне и свой в вебе, разведённые файлами с .web и приписанные к одному типу: экраны зовут один и тот же alert.

1/4Что делает Alert.alert("Удалить подход?", …) в предпросмотре и в веб-версии приложения на react-native-web?

2/4Android. Вопрос задан с кнопками «Отмена» (style: "cancel") и «Удалить» и с { cancelable: true, onDismiss }. Человек нажимает «назад». Что будет вызвано?

3/4На экране стоит <Modal transparent> без visible и без onRequestClose. Что увидит человек на Android и что будет по кнопке «назад»?

4/4Почему своему MyAlert нужен хозяин, смонтированный в корне, а системному Alert — нет?

Спросить и дождаться

Колбэки в кнопках неудобны, когда после ответа надо сделать что-то ещё: хочется, как с confirm из части I, написать if (await ask(…)). Функция ask ниже отдаёт промис поверх MyAlert из подложки. Первый вопрос она доводит до ответа, а на «назад» второй вопрос остаётся на экране, и «ответ 2» не печатается никогда. Доведите её: «назад» должна закрывать вопрос и давать false.

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

Нужны оба поля options: cancelable, чтобы «назад» вообще закрывала вопрос, и onDismiss, чтобы промис узнал об этом.

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

Вывод: ответ 1: true, ответ 2: false. Половина решения опаснее исходного кода. С одним cancelable вопрос закроется, но кнопку никто не нажмёт, и промис не решится никогда: код после await просто не выполнится, без ошибки и без строчки в консоли. Системный Alert на Android с тем же кодом ведёт себя так же: «назад» — это onDismiss, а не «Отмена». Лишний resolve не страшен, промис решается один раз.

Отдых кончился посреди вопроса

Здесь хозяин из раздела «Свой Alert.alert»: второй вопрос заменяет первый, и ответ на первый пропадает молча. Переделайте хранилище в очередь: вопросы показываются по одному, в порядке вызова, а закрытие вопроса открывает следующий.

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

Вместо одного вопроса — массив, а хозяин рисует его голову.

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

Вывод: удалено, к подходу. Снимок — сам элемент queue[0], а не queue.slice(0, 1): пока голова очереди та же, getSnapshot обязан возвращать тот же объект, иначе React решит, что хранилище меняется на каждом чтении. Во что обходится новый объект на каждое чтение, покажет статья «Главная копия — в телефоне» в главе 6. Порядок в кнопке прежний, и теперь он работает на очередь: вопрос, заданный из onPress, встанет в конец, а не сотрёт себя сам. Это третье поведение рядом с двумя системными: Android закрывает прежний диалог, iOS кладёт новый поверх, а наш терпеливо ждёт своей очереди.

Четыре кнопки на Android

В таблице сказано, что Android показывает не больше трёх кнопок и сам раздаёт им места. Напишите androidDialog: по массиву кнопок Alert.alert она говорит, какая кнопка встанет нейтральной, какая отрицательной и какая положительной. Правило возьмите из листинга Alert.js в первом разделе.

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

Ровно как в Alert.js: отрезать первые три и снимать с конца.

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

Вывод: {"positive":"OK"}, {"positive":"Удалить"}, {"negative":"Отмена","positive":"Удалить"}, {"neutral":"Позже","negative":"Отмена","positive":"Удалить"}. «Удалить всё» пропало без предупреждения, и на iPhone та же четвёрка покажется целиком. Места раздаёт порядок в массиве, а не style: «Отмена» должна стоять второй с конца, чтобы стать отрицательной. Четыре варианта ответа — повод для своего диалога или списка на экране, а не для Alert. JSON.stringify пропускает поля со значением undefined, поэтому пустых мест в выводе нет.