Собираем стек экранов с историей

навигация≈ 22 минут

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

Три поломки из статьи «Экраны на useState» пережили и адреса, и раскладки: «‹ Назад» в карточке зашита на один адрес, системная «назад» на Android уводит из приложения, а справочник после возврата снова начинается с приседаний. Причина одна — в состоянии корня лежит один адрес. В этой статье он становится массивом, историей, с двумя операциями — push и back, — а корень начинает держать смонтированными все экраны истории, а не только верхний. Подпишем стек на системную «назад», поставим его в корневую раскладку на место Slot и сравним с тем, что делают StackRouter и нативный стек внутри expo-router, — включая то, чего миниатюра не умеет: на iOS свайп снимает экран раньше, чем об этом узнаёт JavaScript.

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

В прошлой статье в состоянии корня лежала строка — текущий адрес, и кнопке «‹ Назад» в карточке оставалось вести на зашитый "/". Первая задача статьи «Экраны на useState» показала, куда заводит попытка запомнить «откуда»: у «откуда» есть своё «откуда», и честная форма этого знания — список. Заменим строку массивом адресов. На экране последний, push кладёт новый сверху, back снимает верхний. Ячейка состояния и функция-обновитель — как в части IV; новое здесь только то, что лежит в ячейке.

Подложка — справочник, экраны и маршрутизатор прошлых статей, с двумя правками. renderRoute передаёт раскладкам и экранам не go, а nav — объект с переходами стека. А у Card появилась строка «похожее»: следующее упражнение той же группы, чтобы было куда уйти на экран глубже. Прогноз: человек открыл из тренировки жим лёжа, оттуда похожее упражнение, а потом дважды нажал «‹ Назад». Какие пять строк напечатает корень?

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

Из похожего упражнения «‹ Назад» вернула в жим, из жима — в тренировку, хотя кнопка в обеих карточках одна и та же и адреса в ней нет. Всё знание о прошлом теперь лежит в массиве, и карточке больше не нужно знать, откуда её открыли. В expo-router эти операции называются так же: router.push и router.back.

Обратите внимание на back: если снимать нечего, он возвращает false и не трогает стек — последний экран из истории не выбросить. Это значение пригодится в разделе про системную «назад».

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

Статья «Экраны на useState» закончилась правилом: держать смонтированными ровно те экраны, на которые можно вернуться, и сносить тот, с которого ушли назад. Массив истории — готовый список таких экранов. Пусть корень рисует все записи, видимой оставляет только верхнюю, а остальным ставит display: "none". Компонент при этом не размонтируется: его состояние, эффекты и подписки остаются при нём, а вид просто выпадает из раскладки и не рисуется.

Записи рисуются списком, и им нужны ключи — как в части IV. Только ключ здесь не адрес: один адрес может лежать в истории дважды — жим, похожее, похожее на похожее и снова жим. Поэтому запись стека — пара «ключ и адрес», а ключ выдаёт счётчик. Экран справочника отмечает в эффекте, когда появляется и уходит, а замер where — тот же, что в статье «Экраны на useState»: Catalog в подложке кладёт в refs нативные виды списка и его первой строки.

Прогноз: список прокручен на 720 точек, человек открывает становую тягу и возвращается. Сколько раз справочник уйдёт с экрана и где окажется первая строка?

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

Справочник не ушёл ни разу, и первая строка после возврата там же, где была. Тот же замер в статье «Экраны на useState» давал 0: список уничтожали, и новый начинал сверху. Этот никто не трогал: пока была открыта карточка, он лежал в дереве спрятанным, и его вид прокрутки — в песочнице это узел DOM — держал позицию, как держит всегда. Вместе с прокруткой уцелело и то, что хранит React: состояние компонентов, загруженные данные, ввод в управляемых полях.

Сносится теперь только то, с чего ушли назад: back убирает запись из массива, и React размонтирует её поддерево. Стек растёт, пока человек идёт вглубь, и сжимается, когда он возвращается.

Прятать экраны через display придумано не для песочницы — так делает веб-сборка стека expo-router. Её NativeStackView.js рисует все записи state.routes и ставит display: 'none' каждой, кроме сфокусированной и тех, что лежат под прозрачной модалкой. А сам display: "none" на телефоне ведёт себя не так, как в песочнице, и на двух платформах по-разному. В React Native 0.86 такой вид получает в C++ признак Hidden (ConcreteViewShadowNode.h), и на iOS монтирование его пропускает (sliceChildShadowNodeViewPairs.cpp): нативный UIScrollView справочника снимается с экрана и удаляется, а компонент и его узел в теневом дереве остаются. Прокрутка переживает это потому, что ScrollView держит смещение ещё и в своём состоянии в теневом дереве и отдаёт его новому виду — RCTScrollViewComponentView.mm объясняет это в комментарии прямо у восстановления. На Android пропуск закрыт флагом useTraitHiddenOnAndroid, по умолчанию выключенным, и спрятанный вид остаётся на месте. Настоящий стек на телефоне прячет экраны вообще не через display — об этом последний раздел.

Нижние экраны живы по-настоящему: их эффекты работают, подписки слушают, а при смене пропсов или контекста они перерисовываются, хотя их никто не видит. Против лишних перерисовок у react-native-screens есть заморозка — опция экрана freezeOnBlur: пока экран не в фокусе, перерисовки до него не доходят. Включена она не сама собой. В core.ts react-native-screens 4.25.2 стоит let ENABLE_FREEZE = false, а в типах native-stack сказано, что freezeOnBlur по умолчанию равен true, только если в начале приложения вызван enableFreeze(). Ни expo-router 57.0.16, ни пакет expo этого вызова не делают, так что без него нижние экраны стека перерисовываются вместе со всеми.

fireEvent ищет элемент по testID во всём документе, а спрятанные экраны тоже в нём. Окажись в стеке две карточки, press("back") нажмёт кнопку нижней. Поэтому дальше со второй карточки назад уходят системной кнопкой.

Обработчику BackHandler во второй задаче статьи «Экраны на useState» не хватало одного — знания, куда возвращаться. Теперь оно есть у стека, а nav.back() возвращает ровно то, чего BackHandler ждёт от подписчика: true — нажатие моё, экран снят; false — назад некуда, отдавайте нажатие дальше. Подписка нужна одна на весь стек, и делает её корень, а не экраны.

Подписываемся один раз, при появлении корня, а обработчик должен видеть свежий nav — это useEffectEvent из части IV. Почему нельзя переподписываться на каждой отрисовке, покажет вторая задача. Модель backHandler и pressSystemBack — из статьи «Экраны на useState», с правилами из Libraries/Utilities/BackHandler.android.js: подписчиков опрашивают с последнего, первый true забирает нажатие, а если не ответил никто, зовётся exitApp(). Стек useStack — push и back из прошлых примеров — уехал в подложку. Прогноз: человек открыл жим, оттуда похожее и трижды нажал системную «назад».

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

Первые две «назад» сняли по записи. Третья пришла на тренировку, где снимать нечего: back вернул false, других подписчиков нет, и сработал exitApp() — человек на рабочем столе. Так и должно быть: с первого экрана системная «назад» уводит из приложения, и владелец Android этого ждёт. Поломкой было другое — что в статье «Экраны на useState» так происходило уже с карточки.

Настоящий навигатор устроен так же. В react-navigation, на которой стоит expo-router, подписку делает хук useBackButton. Вот он из копии react-navigation внутри expo-router 57.0.16 — тело модуля без обёрток сборки:

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

Одна подписка на весь контейнер навигации: true, если есть куда назад, false — если нет. Суффикс .native.js — приём из статьи «Одно приложение, три платформы»: веб-сборка возьмёт соседний useBackButton.js, а в нём пустая функция с комментарием, что BackHandler в вебе недоступен.

И ещё одна деталь из исходников. На первом экране роутер стека действие GO_BACK не обрабатывает вовсе — его getStateForAction возвращает null. Если назад не взялся ни один навигатор, ExpoRoot.js в отладочной сборке пишет в консоль «The action 'GO_BACK' was not handled by any navigator.» с подсказкой «Is there any screen to go back to?». В выпускной сборке на этом месте пустая функция, и такой переход молча ничего не делает.

Осталось соединить стек с раскладками. В expo-router стек рисует корневая раскладка: в app/_layout.tsx вместо <Slot /> стоит <Stack /> — так устроена и корневая раскладка приложения автора из прошлой статьи. Сделаем так же. Stack в миниатюре — тот же Slot, контекст, только в нём лежит не одно звено цепочки, а все записи истории. Корневая раскладка рисуется один раз, над стеком, а в каждую запись идёт цепочка ниже неё: у справочника это раскладка (tabs) и экран, у карточки — только экран.

Смена вкладки не должна становиться шагом истории. Для этого у useStack в подложке есть третий переход, до сих пор не понадобившийся, — replace: он меняет адрес верхней записи, не трогая её ключ. Вкладки TabBar из прошлой статьи переключаются теперь им.

Прогноз: в прошлой статье раскладка (tabs) уходила с экрана, как только открывали карточку. Что напечатает тот же путь теперь — вкладка «Справочник», жим лёжа, «‹ Назад»?

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

Раскладка появилась один раз и больше не уходила. replace сменил адрес верхней записи, но не ключ, — React оставил запись на месте, и под Slot раскладки сменился только экран. Карточка легла сверху новой записью, а запись со вкладками осталась под ней, спрятанной, со всем деревом: полоска вкладок, справочник, его прокрутка. «‹ Назад» просто сняла карточку. В прошлой статье на том же пути было «раскладка (tabs): ушла с экрана» и новое «на экране» по возвращении.

Три требования из таблицы статьи «Экраны на useState» — история, системная «назад», живой нижний экран — закрыты. А переходы между экранами проекта остались теми же, что в прошлых статьях: экран зовёт nav.push("/exercise/" + id) и не знает, в каком файле лежит карточка и какие раскладки её обернут.

Замена адреса без смены ключа — упрощение. В expo-router вкладки — вложенный навигатор со своей историей, и в корневом стеке у них одна запись, (tabs). У настоящих вкладок и своя «назад»: TabRouter по умолчанию работает с backBehavior 'firstRoute' и сначала возвращает на первую вкладку, а уже с неё нажатие уходит дальше.

В миниатюре история живёт в одном месте — в состоянии корня, а экраны выводятся из неё отрисовкой. Настоящий Stack expo-router, пока речь о JavaScript, устроен так же: состояние навигатора — объект с массивом routes и номером index, а каждое действие проходит через чистую функцию getStateForAction(state, action). Навигатор у expo-router свой — форк createNativeStackNavigator в build/fork/native-stack, — а роутер тот же StackRouter из react-navigation. Прогон настоящего StackRouter из expo-router 57.0.16 на трёх маршрутах — index, exercise/[id] и catalog. Шёл он в Node, а не в песочнице: туда expo-router не собирается, а чистой функции телефон не нужен:

ДействиеЧто стало со стеком
GO_BACK на первом экранеnull: действие не обработано
PUSH карточки жимавторая запись с ключом exercise/[id]-…, хвост ключа — nanoid()
PUSH её же ещё разтретья запись: тот же маршрут, те же params, новый ключ
GO_BACKверхняя запись снята
POP с source — ключом второй записи из четырёхснята только она; две записи выше остались на месте
NAVIGATE на карточку жима, когда она сверхуничего не добавилось
NAVIGATE на карточку приседаний, когда сверху жимзапись та же и ключ тот же, а params — { id: "squat" }
POP_TO первого экранавсё выше него снято
POP_TO справочника, которого в стеке нетверхняя запись заменена справочником

router.push, router.back, router.navigate и router.dismissTo из global-state/router.js превращаются ровно в эти действия: PUSH, GO_BACK, NAVIGATE и POP_TO. Два последних — предмет первой и третьей задач.

Разница начинается там, где кончается JavaScript. Нативный стек expo-router — это react-native-screens, и на iOS его RNSScreenStackView держит настоящий UINavigationController — поле _controller в RNSScreenStack.mm. Нижние экраны остаются в списке его контроллеров, поэтому живы, как в миниатюре, но прячет их система, а не display. И система умеет снимать экраны сама: свайпом от левого края или кнопкой «назад» в нативной шапке. Тогда история меняется сначала в нативном стеке, а JavaScript узнаёт об этом событием и догоняет:

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

onDismissed приходит, когда экран уже снят: состоянию остаётся повторить за системой — POP на dismissCount экранов, последний из которых — запись source. Роутер находит её по ключу — state.routes.findIndex((r) => r.key === action.source), — а не берёт верхнюю, и строка из таблицы выше показывает, что это значит: снимается названная запись, даже если над ней что-то лежит. Ещё одна причина, по которой у записи есть ключ: снять надо тот экран, который убрала система, а не тот, что оказался сверху в JavaScript. Остановить такой свайп подпиской на BackHandler нельзя: это вообще не hardwareBackPress, да и сам BackHandler на iOS — заглушка: в BackHandler.ios.js React Native 0.86 addEventListener возвращает пустую отписку, а exitApp — пустая функция. Свайп останавливает другой рычаг — свойство нативного экрана preventNativeDismiss. expo-router включает его, когда экран запретил своё удаление, — к этому ведёт вторая задача.

Миниатюраexpo-router 57
историямассив записей { key, url } в состоянии корняroutes и index в состоянии StackRouter
ключ записиадрес и номер счётчикаимя маршрута и nanoid()
переходыnav.push, nav.back, nav.replacerouter.push, router.back, router.replace
нижние экраныв дереве, display: "none"в дереве; на телефоне их прячет нативный стек, в вебе — display: 'none'
системная «назад»одна подписка в корнеuseBackButton в контейнере навигации
свайп и кнопка шапки на iOSнетэкран снимает система, JavaScript догоняет по onDismissed

Следующая статья разложит те же экраны «Подхода» по файлам настоящего expo-router: <Stack /> в app/_layout.tsx, хуки параметров и служебные файлы с плюсом. Миниатюра при этом не выбрасывается: в песочнице переходы «Подхода» до конца части будет делать она.

Всё, что собрано в этой статье, одним куском: записи с ключами, push, back и replace, одна подписка на системную «назад», Stack на месте Slot и навигатор, который держит смонтированными все записи истории. Маршрутизатор прошлых статей — routesFrom, match, Slot и renderRoute — лежит в подложке. Этот кусок уезжает в подложку следующих статей.

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

С вкладки «Справочник» вторая системная «назад» ушла сразу к exitApp(): запись со вкладками в стеке одна, и снимать нечего. Настоящие вкладки сначала вернули бы на «Тренировку» — об этом плашка в разделе «Stack на месте Slot».

  • История экранов — массив записей: push кладёт адрес сверху, back снимает верхний и отвечает false, если снимать нечего. Карточке больше не нужно знать, откуда её открыли.
  • У записи свой ключ, а не адрес: один адрес может лежать в истории дважды. В StackRouter ключ — имя маршрута и nanoid().
  • Корень держит смонтированными все записи, видна только верхняя. Нижний экран не размонтируется, поэтому прокрутка, состояние и ввод переживают переход; сносится только то, с чего ушли назад.
  • Системной «назад» хватает одной подписки на стек: nav.back() возвращает ровно то, чего ждёт BackHandler. С первого экрана нажатие уходит системе — так же работает useBackButton в react-navigation.
  • Стек рисует корневая раскладка на месте Slot. Смена вкладки меняет адрес записи, но не ключ, и раскладка вкладок остаётся под карточкой.
  • На телефоне история живёт в двух местах: свайп и кнопка шапки на iOS снимают экран в нативном UINavigationController, а JavaScript догоняет по onDismissed. Подпиской на BackHandler такой уход не остановить.

1/4В стеке ["/", "/exercise/bench"], на BackHandler подписан только корень с обработчиком () => nav.back(). Человек на Android дважды жмёт системную «назад». Что будет?

2/4Почему ключ записи в стеке — не адрес экрана?

3/4Справочник прокрутили, открыли карточку и вернулись. Почему в стеке прокрутка уцелела?

4/4На iOS человек свайпом от левого края снял карточку. Кто первым узнал, что экрана больше нет?

Двойное нажатие

Палец в зале дрогнул: два нажатия на строку пришли в одном такте, и в стеке оказались две карточки жима — «‹ Назад» ведёт из жима в жим. Напишите navigate — переход, который не кладёт адрес, если он и так сверху. Строка тренировки уже зовёт nav.navigate, но пока это просто push.

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

Проверять надо внутри функции-обновителя, а не по снимку:

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

Вывод становится таким: «в стеке: /», «в стеке: / › /exercise/bench», «в стеке: /». Напрашивается и проверка попроще — if (stack[stack.length - 1]!.url === url) return; перед push, — но она не помогает: прогон даёт те же две карточки. Оба нажатия пришли в одном такте и видят один снимок, где сверху ещё тренировка; отрисовки между ними не было — обычная пачка обновлений React, как в части IV. А обновитель получает стек, в котором первое нажатие уже применено.

В expo-router двойное нажатие лечат переходом router.navigate: он становится действием NAVIGATE, а StackRouter, как показал прогон в последнем разделе, не добавляет запись, если сверху тот же маршрут. Только «тот же» он понимает по имени маршрута, а не по адресу: NAVIGATE на карточку приседаний с карточки жима не положит новую запись, а сменит params у верхней. Если нужна именно новая карточка поверх — это router.push.

Черновик не отпускает

На карточке появилось поле заметки, а строка «похожее» по-прежнему открывает следующую карточку. Сделайте так, чтобы, пока в заметке черновик, первая системная «назад» не уводила с карточки, а печатала предупреждение; вторая уводит, как обычно. Подписку корня не трогайте — меняйте только CardScreen.

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

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

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

Первая «назад» печатает предупреждение и остаётся на карточке, вторая уводит на тренировку, третья доходит до exitApp(). Здесь и видно, почему корень подписан один раз, а не на каждой отрисовке: переподписка ставила бы его в конец списка, и корень спрашивали бы раньше карточки.

У решения есть ловушка, и прогон её показывает. Запишите черновик в карточке жима, откройте похожее упражнение — fireEvent.press("similar") — и нажмите «назад»: у верхней карточки черновика нет, она отвечает false, следующей спрашивают спрятанную карточку жима — и та перехватывает нажатие. В консоли появляется предупреждение про черновик, которого человек не видит, а строки «экран:» нет: он остался на наклонном жиме. Нижний экран в стеке жив, и его подписки тоже. Поэтому на BackHandler подписываются через useFocusEffect — он есть и в expo-router — только пока экран сверху. А на iOS и этого мало: свайп и кнопка в шапке BackHandler не трогают вовсе. Для «не уходить с черновиком» в react-navigation, на которой стоит expo-router, есть usePreventRemove: он перехватывает снятие экрана, а нативному стеку его запрет доезжает как preventNativeDismiss.

dismissTo: назад сквозь несколько экранов

Кнопка «Завершить тренировку» глубоко в карточках должна вернуть на первый экран одним переходом, сняв всё, что выше. В expo-router это router.dismissTo(href). Напишите dismissTo: срезать стек до последней записи с этим адресом, а если такой нет — заменить верхнюю запись новой, как делает POP_TO в StackRouter.

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

Запись ищем с конца — как POP_TO, который идёт от текущей записи вниз:

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

К выводу добавляются две строки: «в стеке: /» и «в стеке: /catalog». Первая — всё выше тренировки снято одним переходом, и обе карточки размонтированы разом. Вторая — справочника в стеке не было, и он встал на место верхней записи, а не лёг сверху. Так поступает и настоящий POP_TO: в прогоне из последнего раздела он заменил справочником первый экран. Искать с конца, а не с начала, важно, когда адрес лежит в стеке дважды: вернуться надо к ближайшему, а не к самому первому.