Экраны на useState и почему это ломается
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
«Подход» перестаёт быть одним экраном: из справочника открывается карточка упражнения, из сегодняшней тренировки — тоже. Самый короткий путь напрашивается сам и знаком по части IV: имя экрана — в состояние, нужная ветка — в разметку. На паре экранов это работает. В этой статье мы его не чиним, а смотрим, как именно он ломается на телефоне: пропадает история, системная кнопка «назад» уводит из приложения, а список забывает, докуда его долистали. Каждую поломку увидим в консоли — из них и сложится список того, что должен уметь маршрутизатор, который глава соберёт своими руками.
В браузере у второго экрана был адрес: ссылка, адресная строка, история вкладки. В React Native ничего этого нет — экран здесь просто то, что сейчас нарисовано в корне приложения. Поэтому первое решение напрашивается само: завести состояние screen и рисовать ту ветку, которая в нём записана. Нажатие на строку справочника — Pressable с onPress, как в главе про ввод; сам справочник — сорок упражнений в подложке, короткая копия того, что собирала глава про списки.
Чтобы видеть, что происходит с экранами, каждый из них отмечается в эффекте: появился — ушёл. Прогноз: нажимаем на «Приседания со штангой», потом на «‹ Назад». Сколько раз справочник появится на экране и сколько раз уйдёт?
type Exercise = { id: string; name: string; group: string }; declare const EXERCISES: Exercise[]; const { View, Text, Pressable, ScrollView } = ReactNative; // экран — это просто значение состояния: что показывать прямо сейчас type Screen = { name: "catalog" } | { name: "card"; id: string }; interface CatalogProps { open: (id: string) => void; } function Catalog({ open }: CatalogProps) { React.useEffect(() => { console.log("справочник: на экране"); return () => console.log("справочник: ушёл с экрана"); }, []); return ( <ScrollView style={{ height: 110 }}> {EXERCISES.map((ex) => ( <Pressable key={ex.id} testID={"row-" + ex.id} onPress={() => open(ex.id)}> <Text style={{ paddingVertical: 8 }}>{ex.name}</Text> </Pressable> ))} </ScrollView> ); } interface CardProps { id: string; back: () => void; } function Card({ id, back }: CardProps) { React.useEffect(() => { console.log("карточка " + id + ": на экране"); return () => console.log("карточка " + id + ": ушла с экрана"); }, [id]); const ex = EXERCISES.find((e) => e.id === id)!; return ( <View style={{ gap: 8 }}> <Pressable testID="back" onPress={back}> <Text>‹ Назад</Text> </Pressable> <Text style={{ fontSize: 20 }}>{ex.name}</Text> <Text>группа: {ex.group}</Text> </View> ); } function App() { const [screen, setScreen] = React.useState<Screen>({ name: "catalog" }); if (screen.name === "card") { return <Card id={screen.id} back={() => setScreen({ name: "catalog" })} />; } return <Catalog open={(id) => setScreen({ name: "card", id })} />; } mount(<App />); // «‹ Назад» появится только после отрисовки карточки — поэтому нажатия разнесены setTimeout(() => fireEvent.press("row-squat"), 100); setTimeout(() => fireEvent.press("back"), 200);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Появился дважды, ушёл один раз. Когда открылась карточка, ветка справочника исчезла из дерева, и React размонтировал его целиком — ровно по правилу из статьи «Когда состояние сохраняется, а когда сбрасывается» части IV. «‹ Назад» не вернула прежний справочник: она построила новый.
На телефоне за этим размонтированием стоит больше, чем ячейки состояния. Каждый View, Text и ScrollView здесь — настоящий нативный вид: на iOS это UIView и его родня, на Android — android.view.View. Размонтированный компонент уносит с собой свои виды, а вместе с ними всё, что вид хранит сам, не спрашивая React. Держите это в голове до раздела про прокрутку — там это станет главным.
Нажатия в примере разнесены таймерами не для красоты: «‹ Назад» существует только после того, как React отрисует карточку. Нажмите обе кнопки в одном такте — и второй fireEvent не найдёт кнопку вовсе.
Добавим то, без чего «Подход» не нужен: вкладку с сегодняшней тренировкой. Карточку жима теперь открывают из двух мест — из справочника и из тренировки. Экраны переехали в подложку: Catalog и Card — те же, что в первом примере, только без строк в консоли, а Workout — новый, список из четырёх упражнений сегодняшней тренировки. Строку печатает сам App — при каждой смене экрана.
Прогноз: человек в тренировке открыл жим лёжа и нажал «‹ Назад». Где он окажется?
// подложка: справочник, экраны Catalog и Card из первого примера и новый Workout type Screen = { name: "catalog" } | { name: "workout" } | { name: "card"; id: string }; interface ListProps { open: (id: string) => void; } interface CardProps { id: string; back: () => void; } declare function Catalog(props: ListProps): React.ReactElement; declare function Workout(props: ListProps): React.ReactElement; declare function Card(props: CardProps): React.ReactElement; const { View, Text, Pressable } = ReactNative; function App() { const [screen, setScreen] = React.useState<Screen>({ name: "workout" }); console.log("экран:", screen.name === "card" ? "карточка " + screen.id : screen.name); const open = (id: string) => setScreen({ name: "card", id }); if (screen.name === "card") { return <Card id={screen.id} back={() => setScreen({ name: "catalog" })} />; } return ( <View> <View style={{ flexDirection: "row", gap: 16 }}> <Pressable testID="tab-catalog" onPress={() => setScreen({ name: "catalog" })}> <Text>Справочник</Text> </Pressable> <Pressable testID="tab-workout" onPress={() => setScreen({ name: "workout" })}> <Text>Тренировка</Text> </Pressable> </View> {screen.name === "catalog" ? <Catalog open={open} /> : <Workout open={open} />} </View> ); } mount(<App />); // жим открыт из тренировки — туда человек и ждёт вернуться setTimeout(() => fireEvent.press("row-bench"), 100); setTimeout(() => fireEvent.press("back"), 200);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
В справочнике — хотя пришёл из тренировки. Код при этом не ошибся: он сделал ровно то, что написано, — setScreen({ name: "catalog" }). Ошибка в самой форме состояния. В нём лежит одно значение — текущий экран, а где человек был до этого, не записано нигде, и карточке просто не у кого спросить.
Поле «откуда» в объект карточки добавить можно — это первая задача в конце статьи. Но стоит пройти на экран глубже, и «откуда» самого «откуда» тоже придётся хранить: карточка → похожее упражнение → его карточка → назад → назад. Это уже не поле, а список прошлых экранов. У него есть имя — история.
В браузере историю вёл браузер. Свой роутер из статьи «Зачем фреймворк поверх React» опирался на history.pushState и событие popstate, а переходы назад и вперёд делали кнопки браузера. Прогноз: что скажет компилятор про эту строку и что напечатает песочница?
const { Platform } = ReactNative; // на это опирался свой роутер из части IV: журнал переходов браузера console.log(typeof history); console.log(Platform.OS);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Компилятор и рантайм разошлись, и про телефон прав компилятор. Типы в этих примерах — из пакета react-native и без библиотеки DOM, поэтому имени history для них нет: в React Native журнала переходов не существует. Песочница же — браузер с react-native-web внутри; объект там нашёлся, и Platform.OS честно отвечает "web". На телефоне тот же typeof вернёт "undefined": ни истории, ни адресной строки, ни кнопок браузера там нет, и завести их некому, кроме вас или библиотеки навигации.
Есть — и это известная ловушка. React Native при запуске сам кладёт window вторым именем глобального объекта: в Libraries/Core/setUpGlobals.js стоит проверка «если global.window не определён — присвоить ему global». Поэтому typeof window !== "undefined", которым библиотеки отличают браузер от сервера, на телефоне ошибочно отвечает «браузер». Типы react-native такого имени не объявляют, и в песочнице компилятор скажет про window то же, что про history. А вот history среди глобалей, которые React Native подкладывает при запуске (fetch, URL, FormData, WebSocket и ещё с десяток), нет.
«‹ Назад» в карточке — ваша кнопка: нарисовали, повесили onPress. Но у человека с телефоном есть и чужая. На Android это системная кнопка или жест от края экрана, и жмут её не глядя, по привычке. На iOS системной кнопки нет, зато есть свайп от левого края и кнопка в шапке — обе даёт навигационный контроллер системы, а не ваш код.
До JavaScript системная «назад» доезжает событием hardwareBackPress, а слушают его через BackHandler — модуль самого React Native, нижний этаж; Expo здесь ни при чём. В песочнице он тоже есть. Прогноз: что напечатает попытка подписаться?
const { BackHandler } = ReactNative; const sub = BackHandler.addEventListener("hardwareBackPress", () => { console.log("системная «назад» нажата"); return true; }); console.log(typeof sub.remove);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Первая строка — подлинное сообщение react-native-web, красным, как любая ошибка: в браузере системной кнопки нет, поэтому addEventListener там только ругается и возвращает пустую отписку, а exitApp — пустая функция. Нажать системную «назад» в песочнице нечем, и поведение телефона придётся собрать моделью. Правила возьмём из исходника react-native 0.86, Libraries/Utilities/BackHandler.android.js: подписчики хранятся списком, при нажатии их опрашивают с конца, первый вернувший true забирает нажатие себе, а если не ответил никто — вызывается BackHandler.exitApp().
Прогноз: у App из первого примера подписок нет. Человек открыл карточку и нажал системную «назад» — что будет?
// подложка: справочник и экраны Catalog и Card из первого примера type Screen = { name: "catalog" } | { name: "card"; id: string }; interface ListProps { open: (id: string) => void; } interface CardProps { id: string; back: () => void; } declare function Catalog(props: ListProps): React.ReactElement; declare function Card(props: CardProps): React.ReactElement; // модель BackHandler с Android — правила те же, что в react-native 0.86 type BackPress = () => boolean; const handlers: BackPress[] = []; const backHandler = { addEventListener(_event: "hardwareBackPress", handler: BackPress) { handlers.push(handler); return { remove: () => { const i = handlers.indexOf(handler); if (i !== -1) handlers.splice(i, 1); }, }; }, }; function pressSystemBack() { console.log("нажата системная «назад»"); // первым спрашивают того, кто подписался последним for (let i = handlers.length - 1; i >= 0; i--) { if (handlers[i]!()) return; } console.log("никто не ответил true — exitApp()"); } function App() { const [screen, setScreen] = React.useState<Screen>({ name: "catalog" }); console.log("экран:", screen.name === "card" ? "карточка " + screen.id : "справочник"); if (screen.name === "card") { return <Card id={screen.id} back={() => setScreen({ name: "catalog" })} />; } return <Catalog open={(id) => setScreen({ name: "card", id })} />; } mount(<App />); setTimeout(() => fireEvent.press("row-squat"), 100); setTimeout(pressSystemBack, 200);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
| Шаг | Где | Что происходит |
|---|---|---|
| 1 | система Android | кнопка или жест «назад» приходят в активность приложения — ReactActivity |
| 2 | ReactActivity.onBackPressed | нажатие уезжает в JavaScript событием hardwareBackPress |
| 3 | BackHandler.android.js | подписчики опрашиваются с последнего; первый true останавливает обход |
| 4 | там же, если все промолчали | BackHandler.exitApp() зовёт нативный invokeDefaultBackPressHandler |
| 5 | ReactActivity.invokeDefaultOnBackPressed | срабатывает обычное поведение Android для «назад» — экран приложения закрывается |
Для человека это выглядит так: он был в карточке жима, нажал «назад» и оказался на рабочем столе телефона. В приложении два экрана, но для системы экран один — активность, в которой нарисован весь React. Всё, что вы переключаете состоянием, система не видит, и вернуть человека на справочник ей нечем.
Подписаться на BackHandler самому можно — это вторая задача. Только обработчику придётся решать тот же вопрос, что и кнопке в карточке: куда возвращаться. И ответ снова упрётся в историю, которой нет.
Свайп от левого края на iOS — такая же «чужая» назад: его даёт UINavigationController, в который нативный стек навигатора кладёт экраны по одному. Экраны на useState живут в одном нативном виде, и свайпать там нечего.
Третья поломка тише двух первых, и замечают её не в тесте, а в зале: человек долистал справочник до становой тяги, открыл её, вернулся — а список снова начинается с приседаний. Проверим не глазами, а замером. ref на компонент React Native отдаёт не DOM-узел, а нативный вид, и у вида есть measureInWindow: он сообщает в колбэк, где вид находится на экране. В песочнице под ref лежит div, которому react-native-web добавил тот же метод, так что замер работает и здесь. Меряем первую строку относительно верха списка — до перехода и после возврата.
Список прокручен на 720 точек — это двадцать строк по 36. Прогноз: какие два числа напечатает замер?
// подложка: справочник и карточка Card из первого примера type Exercise = { id: string; name: string; group: string }; type Screen = { name: "catalog" } | { name: "card"; id: string }; interface CatalogProps { open: (id: string) => void; } interface CardProps { id: string; back: () => void; } declare const EXERCISES: Exercise[]; declare function Card(props: CardProps): React.ReactElement; const { View, Text, Pressable, ScrollView } = ReactNative; // на телефоне ref даёт не DOM-узел, а нативный вид: у него можно спросить, где он сейчас type NativeView = React.ComponentRef<typeof View>; const refs: { list: NativeView | null; first: NativeView | null } = { list: null, first: null }; function Catalog({ open }: CatalogProps) { return ( <View ref={(node) => { refs.list = node; }}> <ScrollView testID="catalog" style={{ height: 108 }}> {EXERCISES.map((ex, i) => ( <Pressable key={ex.id} testID={"row-" + ex.id} ref={i === 0 ? (node) => { refs.first = node; } : undefined} style={{ height: 36 }} onPress={() => open(ex.id)} > <Text>{ex.name}</Text> </Pressable> ))} </ScrollView> </View> ); } function where(label: string) { refs.list!.measureInWindow((_x, listY) => { refs.first!.measureInWindow((_x2, rowY) => { console.log(label + ": первая строка на " + (rowY - listY) + " от верха списка"); }); }); } function App() { const [screen, setScreen] = React.useState<Screen>({ name: "catalog" }); if (screen.name === "card") { return <Card id={screen.id} back={() => setScreen({ name: "catalog" })} />; } return <Catalog open={(id) => setScreen({ name: "card", id })} />; } mount(<App />); setTimeout(() => fireEvent.scroll("catalog", 720), 100); setTimeout(() => where("до перехода"), 300); setTimeout(() => fireEvent.press("row-deadlift"), 400); setTimeout(() => fireEvent.press("back"), 500); setTimeout(() => where("после возврата"), 600);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
До перехода первая строка уехала вверх ровно на прокрученное расстояние; после возврата она снова на месте, будто списка никто не касался. И это правда: того списка больше нет. Позиция прокрутки — не состояние React, её не было ни в одной ячейке, её держит сам нативный вид — UIScrollView на iOS, ScrollView на Android. Размонтировали компонент — уничтожили вид, а новый, построенный по «‹ Назад», начал с нуля.
В браузере здесь выручал сам браузер: при переходе назад по обычным страницам он восстанавливает прокрутку документа, а в одностраничном приложении этим занимается роутер — пункт «прокрутка» стоял в списке обязанностей своего роутера ещё в «Зачем фреймворк поверх React». На телефоне нет ни документа, ни роутера.
Прокрутка — самое заметное, но не единственное. Вместе с экраном пропадает всё, что жило в нём: состояние его компонентов, загруженные данные, наполовину заполненная форма подхода, фокус в поле ввода. Спасать это по кусочку, поднимая каждое значение в App, можно — третья задача делает так с прокруткой, — но кусочков у настоящего экрана десятки.
Мысль правильная: оставить справочник в дереве и только спрятать его, пока открыта карточка. Вид не уничтожится, прокрутка и состояние уцелеют. Но если прятать так всё, что человек открывал за сессию, в памяти телефона будут жить все экраны со всеми их эффектами и подписками. Нужна дисциплина: держать смонтированными ровно те экраны, по которым можно вернуться назад, и сносить тот, с которого назад ушли. Эта дисциплина называется стеком, и мы соберём его в статье «Собираем стек экранов с историей».
Сведём увиденное в одну таблицу. Каждая строка — не жалоба на useState, а требование к тому, что придёт ему на смену.
| Что сломалось | Почему | Что должен уметь навигатор | Где в главе |
|---|---|---|---|
| «‹ Назад» ведёт не туда | в состоянии только текущий экран | помнить историю — список экранов, а не один | «Собираем стек экранов с историей» |
| системная «назад» закрывает приложение | на hardwareBackPress никто не подписан | отвечать BackHandler самому и отдавать нажатие системе только с первого экрана | там же |
| список забыл прокрутку, форма — ввод | экран размонтирован, его нативные виды уничтожены | держать нижние экраны смонтированными | там же |
| на экран не попасть снаружи | у экрана нет имени-адреса | адрес у каждого экрана и подбор экрана по адресу | «Собираем маршруты» и «Ссылки снаружи» |
Последнюю поломку в этой статье мы не видели — она проявится, когда уведомление «отдых кончился» должно будет открыть карточку упражнения: экран, у которого нет имени, снаружи не назвать. Имя экрана — строка вроде /exercise/squat — нужно и истории: хранить в ней удобнее адреса, чем деревья компонентов.
В настоящем проекте всё это делает готовая библиотека — в «Подходе» это expo-router, этаж Expo; его разберёт статья «expo-router: те же файлы, но со стеком». В песочнице он не соберётся: expo-modules-core тянет TurboModuleRegistry, которого у react-native-web нет. Может, что-то навигационное есть в самом react-native-web? Прогноз: сколько у него экспортов и какие из них совпадут с образцом?
const names = Object.keys(ReactNative); console.log(names.length); console.log(names.filter((name) => /Nav|Stack|Router|Screen|Link/.test(name)));Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Шестьдесят один экспорт, и единственное совпадение — Linking: он про внешние адреса — открыть сайт, принять ссылку, пришедшую в приложение, — а не про экраны; к нему глава вернётся в последней статье. Ни стека, ни экранов, ни истории нет ни в react-native-web, ни в самом React Native 0.86: навигацию из ядра давно вынесли в библиотеки. Поэтому следующие три статьи собирают маршрутизатор своими руками — путь файла как адрес, раскладки, стек с историей, — и он проживёт в подложке до конца части: без него у «Подхода» в песочнице не было бы переходов вовсе.
- Экран в состоянии — это ветка условия: уходя, компонент размонтируется целиком, как в части IV, а на телефоне вместе с ним уничтожаются и его нативные виды.
- Состояние помнит только текущий экран. Откуда пришли, не записано нигде, поэтому «‹ Назад» в карточке, открытой из двух мест, ведёт не туда; честное решение — список прошлых экранов, то есть история.
- Истории браузера в React Native нет:
historyнет ни в типах, ни на телефоне, а песочница находит его только потому, что сама браузер. - Системную «назад» на Android слушает
BackHandler: подписчики опрашиваются с конца, и если никто не вернулtrue,exitApp()отдаёт нажатие системе — человек оказывается на рабочем столе. В песочницеBackHandler— заглушка и говорит об этом сама. - Позицию прокрутки хранит нативный вид, а не React;
refотдаёт этот вид сmeasureInWindow, и замер показывает, что после возврата список снова наверху. - Навигатор должен помнить историю, отвечать на системную «назад» и держать нижние экраны смонтированными. Ни в react-native-web, ни в React Native его нет, поэтому глава соберёт его сама.
1/4Экраны переключаются через useState, на BackHandler никто не подписан. Человек на Android открыл карточку и нажал системную «назад». Что произойдёт?
2/4Список прокрутили до середины, открыли карточку и вернулись. Почему список снова наверху?
3/4В песочнице console.log(typeof history) печатает object, а компилятор пишет «Не удается найти имя "history"». Кто прав насчёт телефона?
4/4Карточку открывают из справочника и из тренировки, в состоянии App лежит { name: "card", id }. Чего не хватает кнопке «‹ Назад»?
«Назад» — туда, откуда пришли
Сделайте так, чтобы «‹ Назад» возвращала на ту вкладку, с которой открыли карточку. Меняйте только тип Screen и App; экраны лежат в подложке, как в статье.
// подложка: справочник и экраны Catalog, Workout, Card из статьи type Tab = { name: "catalog" } | { name: "workout" }; // ваш код: карточке нужно знать, откуда её открыли type Screen = Tab | { name: "card"; id: string }; interface ListProps { open: (id: string) => void; } interface CardProps { id: string; back: () => void; } declare function Catalog(props: ListProps): React.ReactElement; declare function Workout(props: ListProps): React.ReactElement; declare function Card(props: CardProps): React.ReactElement; const { View, Text, Pressable } = ReactNative; function App() { const [screen, setScreen] = React.useState<Screen>({ name: "workout" }); console.log("экран:", screen.name === "card" ? "карточка " + screen.id : screen.name); if (screen.name === "card") { return <Card id={screen.id} back={() => setScreen({ name: "catalog" })} />; } const open = (id: string) => setScreen({ name: "card", id }); return ( <View> <View style={{ flexDirection: "row", gap: 16 }}> <Pressable testID="tab-catalog" onPress={() => setScreen({ name: "catalog" })}> <Text>Справочник</Text> </Pressable> <Pressable testID="tab-workout" onPress={() => setScreen({ name: "workout" })}> <Text>Тренировка</Text> </Pressable> </View> {screen.name === "catalog" ? <Catalog open={open} /> : <Workout open={open} />} </View> ); } mount(<App />); setTimeout(() => fireEvent.press("row-bench"), 100); // сейчас последняя строка — «экран: catalog», а должна быть «экран: workout» setTimeout(() => fireEvent.press("back"), 200);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Карточке нужно поле «откуда», и заполнять его надо в момент открытия: только тогда известно, какой экран сейчас на месте.
type Screen = Tab | { name: "card"; id: string; from: Tab }; // в ветке карточки return <Card id={screen.id} back={() => setScreen(screen.from)} />; // ниже screen уже сужен до Tab: ветку карточки отсекли выше const open = (id: string) => setScreen({ name: "card", id, from: screen });Листинг только для чтения: этот код не запускается в браузере.
Последняя строка становится «экран: workout». Теперь добавьте в карточку «похожее упражнение», которое открывает другую карточку: from придётся расширить с Tab до Screen, и тип станет рекурсивным — карточка, у которой from — карточка, у которой from — тренировка. Это односвязный список прошлых экранов, история, записанная задом наперёд. Развернуть её в массив и назвать стеком — ровно то, чем займётся статья «Собираем стек экранов с историей». Прокрутку справочника from всё равно не спасает: экран, на который вернулись, строится заново.
Системная «назад» для экранов на состоянии
В подложке — модель backHandler и pressSystemBack из статьи. Подпишите App так, чтобы с карточки системная «назад» возвращала в справочник, а со справочника — отдавалась системе. Системную кнопку жмут дважды: сейчас «приложение закрывается» уже с карточки, а должно — только со справочника.
// подложка: справочник, экраны Catalog и Card, модель backHandler и pressSystemBack type Screen = { name: "catalog" } | { name: "card"; id: string }; type BackPress = () => boolean; interface ListProps { open: (id: string) => void; } interface CardProps { id: string; back: () => void; } interface BackSubscription { remove: () => void; } declare function Catalog(props: ListProps): React.ReactElement; declare function Card(props: CardProps): React.ReactElement; declare const backHandler: { addEventListener(event: "hardwareBackPress", handler: BackPress): BackSubscription; }; declare function pressSystemBack(): void; function App() { const [screen, setScreen] = React.useState<Screen>({ name: "catalog" }); console.log("экран:", screen.name === "card" ? "карточка " + screen.id : "справочник"); // ваш код: подпишитесь на backHandler if (screen.name === "card") { return <Card id={screen.id} back={() => setScreen({ name: "catalog" })} />; } return <Catalog open={(id) => setScreen({ name: "card", id })} />; } mount(<App />); setTimeout(() => fireEvent.press("row-squat"), 100); // сейчас приложение «закрывается» уже с карточки, а должно — только со справочника setTimeout(pressSystemBack, 200); setTimeout(pressSystemBack, 300);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Подписка — эффект, отписка — его уборка:
React.useEffect(() => { const sub = backHandler.addEventListener("hardwareBackPress", () => { if (screen.name !== "card") return false; // со справочника — отдаём системе setScreen({ name: "catalog" }); return true; }); return sub.remove; }, [screen]);Листинг только для чтения: этот код не запускается в браузере.
Вывод становится таким: справочник, карточка, нажатие, снова «экран: справочник», второе нажатие и только тогда exitApp(). Эффект обязан стоять выше if с ранним return — иначе на экране карточки хук не вызовется, и это нарушение правил хуков из части IV. В зависимостях screen: обработчик его читает, и без переподписки видел бы тот экран, на котором подписался. На телефоне код тот же, только вместо модели — BackHandler из react-native. Слабое место прежнее: куда возвращаться, обработчик знает из зашитой строки, и на третьем уровне экранов ему понадобится та же история, что кнопке в карточке. Порядок опроса с конца, наоборот, работает на вас: подписка, сделанная позже, спрашивается раньше — так навигатор и перехватывает кнопку раньше остального приложения.
Вернуть прокрутку руками
Позиция списка уже сохраняется: onScroll пишет её в savedY, а savedY живёт в App, который с экрана не уходит. Осталось вернуть список на место, когда он появится снова. Сейчас после возврата первая строка на 0, а должна быть на -720.
// подложка: справочник, карточка Card, а также refs и where из примера про прокрутку type Exercise = { id: string; name: string; group: string }; type Screen = { name: "catalog" } | { name: "card"; id: string }; type NativeView = React.ComponentRef<typeof View>; interface CatalogProps { open: (id: string) => void; savedY: { current: number }; } interface CardProps { id: string; back: () => void; } declare const EXERCISES: Exercise[]; declare function Card(props: CardProps): React.ReactElement; declare const refs: { list: NativeView | null; first: NativeView | null }; declare function where(label: string): void; const { View, Text, Pressable, ScrollView } = ReactNative; function Catalog({ open, savedY }: CatalogProps) { // ваш код: список появился — верните его туда, где его оставили return ( <View ref={(node) => { refs.list = node; }}> <ScrollView testID="catalog" style={{ height: 108 }} scrollEventThrottle={16} onScroll={(e) => { savedY.current = e.nativeEvent.contentOffset.y; }} > {EXERCISES.map((ex, i) => ( <Pressable key={ex.id} testID={"row-" + ex.id} ref={i === 0 ? (node) => { refs.first = node; } : undefined} style={{ height: 36 }} onPress={() => open(ex.id)} > <Text>{ex.name}</Text> </Pressable> ))} </ScrollView> </View> ); } function App() { const [screen, setScreen] = React.useState<Screen>({ name: "catalog" }); // позиция хранится у App: он с экрана не уходит, а Catalog уходит const savedY = React.useRef(0); if (screen.name === "card") { return <Card id={screen.id} back={() => setScreen({ name: "catalog" })} />; } return <Catalog savedY={savedY} open={(id) => setScreen({ name: "card", id })} />; } mount(<App />); setTimeout(() => fireEvent.scroll("catalog", 720), 100); setTimeout(() => fireEvent.press("row-deadlift"), 400); setTimeout(() => fireEvent.press("back"), 500); // сейчас после возврата первая строка на 0, а должна — на -720 setTimeout(() => where("после возврата"), 700);Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
Новому списку нужен свой ref на ScrollView и эффект при появлении:
const list = React.useRef<React.ComponentRef<typeof ScrollView>>(null); React.useEffect(() => { // animated: false — человек должен увидеть список уже на месте, а не езду к нему list.current?.scrollTo({ y: savedY.current, animated: false }); }, []); // и ref={list} на самом ScrollViewЛистинг только для чтения: этот код не запускается в браузере.
Первая строка снова на -720. Цена видна сразу. Эффект срабатывает уже после того, как новый список смонтирован, и между этими моментами он стоит наверху — если телефон успеет показать этот кадр, список дёрнется. Позицию придётся заводить для каждого списка отдельно. А всё остальное, что жило в экране, — ввод, загруженные данные, раскрытые секции — по-прежнему теряется. Надёжнее не восстанавливать уничтоженный вид, а не уничтожать его: держать справочник смонтированным под карточкой, как это делает стек.