Вкладені навігатори та модалки
Вкладені навігатори та модалки
Вступ: Еволюція навігаційного дерева до багаторівневих ієрархій
У попередньому розділі було розглянуто базові принципи функціонування Expo Router: базову структуру між каталогами app/ та src/, кореневий стековий навігатор Stack, панель нижніх вкладок Tabs на базі групи маршрутів (tabs), а також переходи через Link та useRouter. Створена структура задовольняла потреби базового інтерфейсу формату «три незалежні вкладки + один окремий екран форми».
Проте в реальних мобільних додатках простої лінійної структури швидко стає недостатньо. Більшість популярних додатків використовують складнішу поведінку:
- Контекстні переходи всередині розділу: Натискання на елемент списку у вкладці новин чи каталогу відкриває екран детального перегляду, при цьому нижня навігаційна панель залишається видимою та активною, дозволяючи користувачеві в будь-який момент перемкнутися на сусідній розділ і повернутися назад без втрати точки перегляду.
- Модальні контекстні сесії: Форми швидкого створення сутностей, фільтрації чи налаштувань з'являються поверх активного інтерфейсу у вигляді спливаючих карток (Modal Sheets), тимчасово перекриваючи вкладки та акцентуючи увагу користувача на завершенні конкретного завдання.
- Захист від випадкової втрати даних (Navigation Interception): Якщо користувач вніс зміни у форму й випадково ініціював жест повернення, навігаційна система перехоплює подію та запитує підтвердження на вихід.
- Розділення просторів екранів (Auth Flows): Екрани неавторизованої зони (вхід, реєстрація, відновлення доступу) функціонують в ізольованому навігаційному контурі, повністю відокремленому від робочої зони авторизованого користувача.
Даний розділ присвячений проєктуванню складених ієрархічних навігаційних систем:
- Вкладені навігатори (Nested Navigators): конфігурація стекового контейнера
Stackвсередині конкретної вкладкиTabs. - Модальні представлення (Modal Presentations): відображення екранів у
Stackу вигляді модальних вікон через параметрpresentation: 'modal'. - Групи маршрутів для авторизації
(auth): сегрегація публічних та захищених контекстів інтерфейсу. - Навігаційні гарди (Navigation Guards): перехоплення подій закриття екрана з незбереженими даними за допомогою хука
usePreventRemove.
Навчальні цілі розділу:
- Зрозуміти структуру дерева навігаторів (Navigation Tree) і навчитися визначати, яка гілка графа повинна обробляти виклики
router.push()таrouter.back(). - Спроєктувати макет для потоку «список → деталі» зі збереженням видимості нижньої панелі вкладок (
Bottom Tab Bar). - Налаштувати модальний режим показу
presentation: 'modal'та освоїти параметри конфігурації платформних анімацій на iOS та Android. - Засвоїти механізм декларативного прив'язування початкового маршруту через конфігурацію
unstable_settings.initialRouteName. - Реалізувати патерн надійного захисту форми від випадкового закриття (Dirty Form Guard) через хук
usePreventRemoveта системні діалогиAlert. - Побудувати автономний міні-проєкт «Нотатки» з вкладеним стеком і модалкою та масштабувати навігаційну архітектуру проєкту Nomad, додавши повноцінний екран деталей поїздки
[id].tsx.
_layout.tsx обгортає дочірні сегменти, кожен з яких сам по собі може бути повноцінним автономним навігатором. Модальне вікно є лише способом візуального представлення екрана в стеку, а навігаційний гард — обробником події закриття екрана.::react-native-preview. Для валідації жестів використовуйте середовище Expo Go або нативний емулятор.Репозиторій наскрізного проєкту Nomad: github.com/arakviel/nomad.
Чому простого плоского стека недостатньо
Розглянемо практичний сценарій, коли вся навігація застосунку організована виключно через єдиний кореневий стековий навігатор Root Stack без використання вкладених контейнерів усередині розділів.
ПЛОСКИЙ ROOT STACK ВКЛАДЕНА ІЄРАРХІЯ (NESTED)
+------------------------------------+ +-----------------------------------------+
| Root Stack | | Root Stack |
| ├── (tabs) [Поїздки, Місця, Ще] | vs | ├── (tabs) [Tabs Layout] |
| ├── trip-details.tsx (Full Screen)| | │ ├── trips/ [Вкладений Stack] |
| └── create-trip.tsx (Full Screen)| | │ │ ├── index.tsx (Список) |
+------------------------------------+ | │ │ └── [id].tsx (Деталі + Tabs!) |
| • Вкладки зникають на деталях | | │ ├── places.tsx |
| • Втрата просторового контексту | | │ └── about.tsx |
| • Форма змішується зі стрічкою | | └── create-trip.tsx [Modal Presentation]|
+------------------------------------+ +-----------------------------------------+
Якщо розмістити екран деталей поїздки trip-details.tsx безпосередньо в кореневому стеку поруч із групою (tabs), перехід на деталі викличе наступні системні наслідки:
- Зникнення панелі вкладок: Кореневий стек накладає новий екран на всю площу дисплея поверх усього контейнера
(tabs). Користувач втрачає можливість миттєвого переходу в розділ «Місця» або «Ще». Щоб переглянути збережені локації, йому доведеться спочатку повернутися назад, втративши відкритий контекст поїздки. - Спотворення UX модальних операцій: Якщо форму створення
create-tripрозмістити як звичайний горизонтальний перехід усередині вкладки, користувач сприймає її як черговий рівень заглиблення контенту, а не як тимчасову сфокусовану транзакцію введення даних. - Відсутність контролю за життєвим циклом введення: Випадковий свайп повернення на екрані редагування без навігаційного гарда призводить до миттєвого розмонтування компонента та безповоротної втрати набраного тексту в оперативній пам'яті.
Вирішення цих проблем вимагає комбінування трьох інструментів: вкладених навігаторів, модальних представлень та гардінг-хуків.
Теорія вкладених навігаторів (Nested Navigators)
Навігатор (Navigator) — це структурний компонент вищого порядку, який інкапсулює стан групи екранів, визначає поточний видимий екран і реалізує платформовий алгоритм перемикання між ними (стек LIFO, паралельні вкладки, модальний оверлей).
Вкладений навігатор (Nested Navigator) — це архітектурна конфігурація, за якої певний навігатор є дочірнім вузлом (екраном або сегментом) іншого батьківського навігатора.
Структура навігаційного дерева проєкту Nomad:
ГЛОБАЛЬНЕ ДЕРЕВО НАВІГАЦІЇ
+---------------------------------------------------------------------------------+
| Root Stack Navigator (app/_layout.tsx) |
| |
| +---------------------------------------------------------------------------+ |
| | (tabs) Bottom Tabs Navigator (app/(tabs)/_layout.tsx) | |
| | | |
| | +---------------------------------------------------------------------+ | |
| | | trips/ Nested Stack Navigator (app/(tabs)/trips/_layout.tsx) | | |
| | | | | |
| | | [index.tsx (Стрічка)] ==== push ====> [[id].tsx (Деталі поїздки)]| | |
| | +---------------------------------------------------------------------+ | |
| | | |
| | [places.tsx (Вкладка «Місця»)] [about.tsx (Вкладка «Ще»)] | |
| +---------------------------------------------------------------------------+ |
| |
| +---------------------------------------------------------------------------+ |
| | create-trip.tsx (Root Stack Screen, presentation: 'modal') | |
| +---------------------------------------------------------------------------+ |
+---------------------------------------------------------------------------------+
- Рівень 1 (Кореневий Stack
app/_layout.tsx): Керує двома основними просторами: глобальним контейнером вкладок(tabs)та окремим модальним екраном створенняcreate-trip. - Рівень 2 (Контейнер вкладок
app/(tabs)/_layout.tsx): Організовує три паралельні розділи застосунку:trips,placesтаabout. - Рівень 3 (Вкладений Stack
app/(tabs)/trips/_layout.tsx): Функціонує виключно всередині вкладки «Поїздки», ізольовано керуючи переходами між стрічкоюindex.tsxта карткою деталей[id].tsx.
Архітектурний вибір: Вкладений Detail проти Кореневого Detail
У мобільній розробці вибір місця розташування екрана деталей у навіційному дереві є класичним інженерним компромісом:
| Критерій порівняння | Вкладений стек: app/(tabs)/trips/[id].tsx | Кореневий стек: app/trips/[id].tsx |
|---|---|---|
| Видимість Tab Bar | Панель вкладок залишається видимою під час перегляду деталей. | Панель вкладок повністю приховується. |
| UX-призначення | Дослідження контенту як природного розширення поточної вкладки. | Повне занурення в контент (відеоплеєр, детальна інтерактивна карта, фотогалерея). |
| Поведінка при зміні вкладки | Стан перегляду зберігається у вкладці при перемиканні туди й назад. | Користувач не бачить вкладок; для зміни розділу зобов'язаний закрити екран. |
| Багатоточкова навігація | Відкриття з інших вкладок перемикає активну вкладку на trips. | Може викликатися як універсальний оверлей із будь-якої точки застосунку. |
Для проєкту Nomad обрано архітектуру вкладеного стека: щоденник подорожей передбачає часте зіставлення опису поїздки зі збереженими локаціями у вкладці «Місця», тому збереження таб-бару на екрані деталей є критичною вимогою до зручності використання.
Реалізація вкладеного Stack усередині вкладки
Трансформація файлової структури
Для перетворення простої вкладки на повноцінний вкладений стек ми замінюємо окремий файл app/(tabs)/index.tsx на підкаталог app/(tabs)/trips/, що містить власний файл макета та маршрути:
# Структура статті 11 (Плоска вкладка)
app/(tabs)/
index.tsx ← Головний екран (неможливо додати вкладений стек)
places.tsx
about.tsx
# Структура статті 12 (Вкладений стек)
app/(tabs)/
trips/
_layout.tsx ← Локальний Stack тільки для розділу «Поїздки»
index.tsx ← Маршрут "/trips" (Стрічка поїздок)
[id].tsx ← Маршрут "/trips/:id" (Деталі конкретної поїздки)
places.tsx ← Маршрут "/places"
about.tsx ← Маршрут "/about"
Координація макетів та сегрегація обов'язків
trips, places, about). Для таб-макета сегмент trips є єдиним неподільним екраном; він не володіє інформацією про внутрішню структуру маршрутів trips/[id].headerShown: false) та задає анімації переходу всередині вкладки.(tabs) як єдиним вузлом та модальним вікном create-trip.Лістинг вкладеного макета: app/(tabs)/trips/_layout.tsx
import { Stack } from 'expo-router';
import { useTheme } from '@/shared/theme';
export default function TripsStackLayout() {
const { colors } = useTheme();
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.background },
headerTintColor: colors.primary,
headerTitleStyle: { color: colors.text, fontWeight: '600' },
headerShadowVisible: false,
contentStyle: { backgroundColor: colors.background },
}}
>
{/* Стрічка має власний кастомний заголовок — нативний header вимикаємо */}
<Stack.Screen name="index" options={{ headerShown: false }} />
{/* Екран деталей використовує системний header із кнопкою повернення */}
<Stack.Screen
name="[id]"
options={{
title: 'Поїздка',
headerBackTitle: 'Список',
}}
/>
</Stack>
);
}
Як працює обробник router.back()
Ключовий закон ієрархічної навігації: виклик router.back() завжди знімає верхній екран саме з того стека, в якому безпосередньо розташований активний компонент.
- Якщо користувач перебуває на маршруті
/trips/42(вкладений стекtrips), викликrouter.back()знімає екран деталей і повертає до списку/trips. Нижня панель вкладок не змінює свого стану. - Якщо користувач перебуває на маршруті
/create-trip(кореневий стек), викликrouter.back()закриває модальне вікно та повертає користувача в ту точку графа, звідки було ініційовано відкриття (наприклад, назад на екран деталей або в список).
Модальні представлення (Modal Presentations)
Семантична різниця між Push та Modal
У мобільному UX розрізняють два принципово різні типи переходів:
- Card Push (Ієрархічний перехід): переміщення вглиб структури даних. Екран виїжджає справа наліво, сигналізуючи про перехід на нижчий рівень поточної ієрархії.
- Modal Presentation (Модальне представлення): переривання основного робочого потоку для виконання ізольованої транзакції (створення нотатки, встановлення фільтра, перегляд фото). Екран з'являється знизу вгору, частково або повністю перекриваючи контекст.
Конфігурація модального екрана в Expo Router
В Expo Router модальне вікно не вимагає окремого фреймворку чи зовнішніх бібліотек. Це той самий звичайний файл маршруту в app/, для якого в батьківському Stack встановлено властивість presentation: 'modal'.
// app/_layout.tsx
<Stack.Screen
name="create-trip"
options={{
presentation: 'modal',
title: 'Нова поїздка',
headerShown: true,
headerBackTitle: 'Закрити',
}}
/>
Платформові відмінності модальних вікон
presentation: 'modal' транслюється у нативну модель спливаючої картки, яка залишає видимим затемнений верхній край попереднього екрана. Підтримується нативний жест змахування вниз для закриття (Interactive Pull-Down Dismiss).pop для кореневого стека, закриваючи модальне вікно.Конфігурація якоря початкового маршруту (unstable_settings)
Якщо користувач запускає застосунок за прямим Deep Link одразу на модальний екран (або під час гарячого перезавантаження в режимі розробки), у стеку може бути відсутній базовий екран, що призведе до блокування навігації при спробі закрити модалку.
Для запобігання цьому в кореневому макеті експортується об'єкт конфігурації unstable_settings:
export const unstable_settings = {
initialRouteName: '(tabs)',
};
Цей параметр гарантує, що рушій Expo Router попередньо змонтує групу (tabs) як фундамент навігаційного стека, навіть якщо цільовий запуск відбувся безпосередньо на модальний маршрут /create-trip.
Архітектура авторизаційних потоків: Групи (auth)
У реальних мобільних системах застосунок розділений на взаємовиключні функціональні домени: публічний простір неавторизованого гостя та приватний робочий простір авторизованого користувача.
Організація каталогів за допомогою груп
app/
_layout.tsx ← Кореневий перемикач контекстів (Redirect / Guard)
(auth)/ ← Публічний домен авторизації
_layout.tsx ← Стек без нижніх вкладок
login.tsx ← Маршрут "/login"
register.tsx ← Маршрут "/register"
(app)/ ← Захищений приватний домен
(tabs)/
_layout.tsx ← Вкладки застосунку
Принцип роботи декларативного захисту
- Ізоляція макетів: Група
(auth)має власний файл макета_layout.tsx, який не містить контейнераTabs. Завдяки цьому екран авторизації завжди відображається на весь екран без панелі вкладок. - Атомарне очищення історії: Після успішного входу в систему перехід у робочий простір здійснюється виключно через метод
router.replace('/(app)/(tabs)'). Це повністю очищає стекову історію екранів вводу логіна та пароля, унеможливлюючи повернення назад системною кнопкою.
(auth) та (app) вирішує виключно завдання інтерфейсної ергономіки та порядку показу вікон. Приховування вкладки не захищає дані від витоку. Справжня безпека системи забезпечується валідацією токенів (JWT) на бекенд-сервері та криптографічним зберіганням ключів у нативному сховищі expo-secure-store.Навігаційні гарди: Запобігання втраті даних через usePreventRemove
Під час заповнення складних мобільних форм користувач може випадково ініціювати повернення назад (жестом від краю екрана на iOS або апаратною кнопкою на Android). Без додаткового захисту екран буде миттєво демонтовано, а набрані дані — безповоротно втрачено.
Життєвий цикл перехоплення навігації
Для реалізації патерну захисту використовується хук usePreventRemove з екосистеми React Navigation:
import { useState } from 'react';
import { Alert } from 'react-native';
import { useNavigation } from 'expo-router';
import { usePreventRemove } from '@react-navigation/native';
export function FormScreen() {
const navigation = useNavigation();
const [text, setText] = useState('');
const [isSaved, setIsSaved] = useState(false);
// Стан «забрудненості» форми (Dirty State)
const isDirty = !isSaved && text.trim().length > 0;
usePreventRemove(isDirty, ({ data }) => {
// Викликається тільки тоді, коли isDirty === true під час спроби навігаційного виходу
Alert.alert(
'Незбережені зміни',
'Якщо ви вийдете зараз, усі внесені дані будуть втрачені. Ви дійсно бажаєте вийти?',
[
{ text: 'Залишитися', style: 'cancel' },
{
text: 'Вийти без збереження',
style: 'destructive',
// Відновлення перерваної навігаційної дії
onPress: () => navigation.dispatch(data.action),
},
],
);
});
return (/* JSX розмітка форми */);
}
Покрокова механіка функціонування:
- Користувач вводить текст у поле форми. Значення стану
isDirtyпереходить уtrue. - Користувач виконує жест змахування вниз або натискає системну кнопку «Назад».
- Навігаційний рушій перехоплює намір демонтувати екран і передає керування колбеку
usePreventRemove. - Якщо користувач обирає варіант «Залишитися», навігаційна дія відхиляється, і екран залишається у фокусі.
- Якщо користувач підтверджує вихід, викликається метод
navigation.dispatch(data.action), який примусово виконує відкладену дію закриття.
Динамічні сегменти маршруту: [id].tsx
Файл з назвою app/(tabs)/trips/[id].tsx декларує параметризований динамічний маршрут, де сегмент [id] виступає змінною частиною URL:
import { useLocalSearchParams } from 'expo-router';
import { getTripById } from '@/features/trips';
export default function TripDetailsScreen() {
// Вилучення динамічного параметра з URL
const { id } = useLocalSearchParams<{ id: string }>();
// Нормалізація параметра (захист від випадку string[])
const tripId = Array.isArray(id) ? id[0] : id;
const trip = tripId ? getTripById(tripId) : undefined;
if (!trip) {
return <NotFoundView message={`Поїздку з ID ${tripId} не знайдено`} />;
}
return <TripDetailsView trip={trip} />;
}
Хук useLocalSearchParams здійснює десеріалізацію параметрів поточного маршруту. Нормалізація через Array.isArray(id) є рекомендованою практикою для гарантування строгих типів даних у TypeScript.
Комплексний огляд навігаційних потоків Nomad
Розглянемо наскрізний ланцюг взаємодії користувача з оновленою архітектурою Nomad:
1. Перегляд стрічки у вкладці «Поїздки»
Активним є контейнер Tabs. Контент рендериться з маршруту (tabs)/trips/index.tsx усередині вкладеного стека. Нижній таб-бар видимий.
2. Вибір поїздки для детального ознайомлення
Виклик router.push('/trips/1') ініціює операцію push у внутрішньому стеку вкладки. Екран плавно зсувається, відображаючи нативний заголовок з назвою поїздки. Нижня панель вкладок залишається видимою.
3. Відкриття форми створення поїздки
Натискання кнопки «Нова поїздка» викликає router.push('/create-trip'). Оскільки цей маршрут зареєстрований у кореневому стеку з опцією presentation: 'modal', форма виїжджає знизу на весь екран, перекриваючи таб-бар.
4. Збереження форми та синхронізація стану
Після валідації форма викликає метод addTrip(newTrip) глобального контексту TripsProvider та ініціює виклик router.back(). Модальне вікно закривається, повертаючи користувача назад на екран деталей або стрічку, де нова картка автоматично з'являється завдяки підписці на React Context.
5. Перехід із вкладки «Місця» на поїздку
Натискання на чіп збереженої локації у вкладці «Місця» викликає router.push('/trips/' + place.tripId). Навігатор автоматично активує вкладку «Поїздки» та відкриває відповідний екран деталей у вкладеному стеку.
Архітектурні антипатерни у складених навігаційних системах
1. Винесення деталей у кореневий стек без усвідомленої мети приховати Tab Bar
- Симптом: Користувач скаржиться, що під час перегляду деталей неможливо швидко перемкнутися на сусідню вкладку.
- Причина: Файл деталей помилково розміщено в
app/trips/[id].tsxзамість вкладеної структуриapp/(tabs)/trips/[id].tsx.
2. Реєстрація модальної форми як постійної вкладки в Tab Bar
- Симптом: Форма створення постійно присутня в нижній панелі як окрема іконка, порушуючи модель персистентних розділів.
- Причина: Нерозуміння різниці між довгоживучими розділами (Tabs) та тимчасовими контекстними операціями (Modal).
3. Спроба закриття модалки через виклик router.push('/')
- Симптом: Навігаційний стек роздувається дублікатами домашнього екрана, а подальше натискання системної кнопки «Назад» повертає користувача назад на заповнену форму.
- Причина: Використання операції
pushзамість обов'язкового викликуrouter.back()абоrouter.replace().
4. Відсутність навігаційного гарда на формі з великим обсягом вводу
- Симптом: Випадковий дотик до краю дисплея на iOS викликає закриття модалки та повну втрату введеного тексту.
- Причина: Нехтування хуком
usePreventRemoveпід час проєктування критичних інтерфейсів введення.
5. Розміщення важкої бізнес-логіки безпосередньо у файлах макетів _layout.tsx
- Симптом: Повторні небажані мережеві запити та падіння продуктивності при переході між екранами.
- Причина: Файл макета повинен займатися виключно зв'язуванням навігаційних контейнерів та конфігурацією опцій, делегуючи отримання даних спеціалізованим хукам та контекстним провайдерам.
Міні-проєкт: «Нотатки» (від А до Я)
Мета
Окремий застосунок поза Nomad. Увесь код — у collapsible нижче.
- Tabs: Нотатки | Про застосунок.
- У «Нотатки» — nested Stack: список → деталі.
- Modal «Нова нотатка» + guard на back.
- Збережені нотатки з’являються в списку.
Структура
notes-app/
app/
_layout.tsx
(tabs)/
_layout.tsx
notes/
_layout.tsx
index.tsx
[id].tsx
about.tsx
modal/
new.tsx
src/data/notesStore.ts
Крок 1. Створити проєкт
1. Скарфолд
npx create-expo-app@latest notes-app -t tabs
cd notes-app
2. Іконки (за потреби)
npx expo install @expo/vector-icons
3. Після підстановки файлів
npx expo start
Видаліть зайві екрани шаблону (explore тощо), залиште структуру як вище.
Крок 2. src/data/notesStore.ts
export type Note = {
id: string;
title: string;
body: string;
createdAt: number;
};
type Listener = () => void;
let notes: Note[] = [
{
id: '1',
title: 'Список речей',
body: 'Павербанк, дощовик, копія квитків.',
createdAt: Date.now() - 86400000,
},
{
id: '2',
title: 'Ідеї маршруту',
body: 'Ранок — музей, обід — ринок, вечір — набережна.',
createdAt: Date.now() - 3600000,
},
];
const listeners = new Set<Listener>();
function emit() {
listeners.forEach((l) => l());
}
export function getNotes(): Note[] {
return [...notes].sort((a, b) => b.createdAt - a.createdAt);
}
export function getNote(id: string): Note | undefined {
return notes.find((n) => n.id === id);
}
export function addNote(title: string, body: string): Note {
const note: Note = {
id: String(Date.now()),
title: title.trim() || 'Без назви',
body: body.trim(),
createdAt: Date.now(),
};
notes = [note, ...notes];
emit();
return note;
}
export function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
Крок 3. app/_layout.tsx
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
export const unstable_settings = {
initialRouteName: '(tabs)',
};
export default function RootLayout() {
return (
<>
<StatusBar style="dark" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#F8FAFC' },
headerTintColor: '#2563EB',
headerTitleStyle: { color: '#0F172A', fontWeight: '600' },
contentStyle: { backgroundColor: '#F8FAFC' },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="modal/new"
options={{
presentation: 'modal',
title: 'Нова нотатка',
headerBackTitle: 'Закрити',
}}
/>
</Stack>
</>
);
}
Крок 4. app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#2563EB',
tabBarInactiveTintColor: '#64748B',
tabBarStyle: {
backgroundColor: '#F8FAFC',
borderTopColor: '#E2E8F0',
},
tabBarLabelStyle: { fontSize: 12, fontWeight: '600' },
}}
>
<Tabs.Screen
name="notes"
options={{
title: 'Нотатки',
tabBarIcon: ({ color, size }) => (
<Ionicons name="document-text-outline" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="about"
options={{
title: 'Про застосунок',
tabBarIcon: ({ color, size }) => (
<Ionicons
name="information-circle-outline"
size={size}
color={color}
/>
),
}}
/>
</Tabs>
);
}
Крок 5. Nested stack — app/(tabs)/notes/_layout.tsx
import { Stack } from 'expo-router';
export default function NotesStackLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#F8FAFC' },
headerTintColor: '#2563EB',
headerTitleStyle: { color: '#0F172A', fontWeight: '600' },
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen
name="[id]"
options={{ title: 'Нотатка', headerBackTitle: 'Список' }}
/>
</Stack>
);
}
Крок 6. Список — app/(tabs)/notes/index.tsx
import { useEffect, useState } from 'react';
import {
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import { Link, useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { getNotes, subscribe, type Note } from '../../../src/data/notesStore';
export default function NotesListScreen() {
const [notes, setNotes] = useState<Note[]>(() => getNotes());
const router = useRouter();
useEffect(() => subscribe(() => setNotes(getNotes())), []);
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<View style={styles.header}>
<Text style={styles.title}>Нотатки</Text>
<Text style={styles.muted}>
Nested Stack · modal · guard · {notes.length}
</Text>
</View>
<FlatList
data={notes}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ItemSeparatorComponent={() => <View style={{ height: 10 }} />}
ListEmptyComponent={
<Text style={styles.muted}>Порожньо. Створіть першу нотатку.</Text>
}
renderItem={({ item }) => (
<Link href={`/notes/${item.id}`} asChild>
<Pressable style={styles.card}>
<Text style={styles.cardTitle}>{item.title}</Text>
<Text style={styles.cardBody} numberOfLines={2}>
{item.body || 'Без тексту'}
</Text>
</Pressable>
</Link>
)}
/>
<View style={styles.footer}>
<Pressable
style={styles.btn}
onPress={() => router.push('/modal/new')}
>
<Text style={styles.btnText}>Нова нотатка (modal)</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' },
header: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#E2E8F0',
},
title: { fontSize: 24, fontWeight: '800', color: '#0F172A' },
muted: { marginTop: 4, color: '#64748B', fontSize: 13 },
list: { padding: 16, paddingBottom: 24, flexGrow: 1 },
card: {
backgroundColor: '#fff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#E2E8F0',
padding: 14,
},
cardTitle: { fontSize: 17, fontWeight: '700', color: '#0F172A' },
cardBody: { marginTop: 6, color: '#64748B', fontSize: 14 },
footer: {
padding: 16,
borderTopWidth: 1,
borderTopColor: '#E2E8F0',
},
btn: {
backgroundColor: '#2563EB',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
btnText: { color: '#fff', fontWeight: '800' },
});
Крок 7. Деталі — app/(tabs)/notes/[id].tsx
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { getNote } from '../../../src/data/notesStore';
export default function NoteDetailsScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const noteId = Array.isArray(id) ? id[0] : id;
const note = noteId ? getNote(noteId) : undefined;
const router = useRouter();
if (!note) {
return (
<View style={styles.center}>
<Stack.Screen options={{ title: 'Не знайдено' }} />
<Text style={styles.title}>Нотатку не знайдено</Text>
<Text style={styles.muted} onPress={() => router.back()}>
← Назад до списку
</Text>
</View>
);
}
return (
<ScrollView contentContainerStyle={styles.body}>
<Stack.Screen options={{ title: note.title }} />
<Text style={styles.title}>{note.title}</Text>
<Text style={styles.muted}>
{new Date(note.createdAt).toLocaleString('uk-UA')}
</Text>
<Text style={styles.bodyText}>{note.body || 'Порожня нотатка.'}</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { padding: 16, paddingBottom: 40 },
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
backgroundColor: '#F8FAFC',
},
title: { fontSize: 24, fontWeight: '800', color: '#0F172A' },
muted: { marginTop: 8, color: '#64748B' },
bodyText: {
marginTop: 16,
fontSize: 16,
lineHeight: 24,
color: '#334155',
},
});
Крок 8. Modal + guard — app/modal/new.tsx
import { useState } from 'react';
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useNavigation } from 'expo-router';
import { useRouter } from 'expo-router';
import { usePreventRemove } from '@react-navigation/native';
import { addNote } from '../../src/data/notesStore';
export default function NewNoteModal() {
const router = useRouter();
const navigation = useNavigation();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
/** Після успішного save — дозволяємо leave без діалогу. */
const [saved, setSaved] = useState(false);
const dirty =
!saved && (title.trim().length > 0 || body.trim().length > 0);
usePreventRemove(dirty, ({ data }) => {
Alert.alert(
'Вийти без збереження?',
'Текст нотатки буде втрачено.',
[
{ text: 'Лишитись', style: 'cancel' },
{
text: 'Вийти',
style: 'destructive',
onPress: () => navigation.dispatch(data.action),
},
],
);
});
const onSave = () => {
if (!title.trim() && !body.trim()) {
Alert.alert('Порожньо', 'Введіть заголовок або текст.');
return;
}
addNote(title, body);
setSaved(true);
// після setState dirty стане false на наступному рендері;
// back у requestAnimationFrame / setTimeout(0) надійніше:
setTimeout(() => router.back(), 0);
};
return (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.body}>
<Text style={styles.hint}>
Якщо є текст і ви тиснете «назад» / dismiss — з’явиться підтвердження
(usePreventRemove).
</Text>
<Text style={styles.label}>Заголовок</Text>
<TextInput
style={styles.input}
value={title}
onChangeText={setTitle}
placeholder="Наприклад, Список речей"
/>
<Text style={styles.label}>Текст</Text>
<TextInput
style={[styles.input, styles.multi]}
value={body}
onChangeText={setBody}
placeholder="Нотатка…"
multiline
textAlignVertical="top"
/>
<Pressable style={styles.btn} onPress={onSave}>
<Text style={styles.btnText}>Зберегти</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
flex: { flex: 1, backgroundColor: '#F8FAFC' },
body: { flex: 1, padding: 16, gap: 8 },
hint: { color: '#64748B', fontSize: 13, marginBottom: 8 },
label: {
marginTop: 8,
fontSize: 12,
fontWeight: '700',
color: '#64748B',
textTransform: 'uppercase',
},
input: {
borderWidth: 1,
borderColor: '#E2E8F0',
backgroundColor: '#fff',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
color: '#0F172A',
},
multi: { minHeight: 140 },
btn: {
marginTop: 20,
backgroundColor: '#2563EB',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
btnText: { color: '#fff', fontWeight: '800' },
});
Крок 9. About
import { ScrollView, StyleSheet, Text } from 'react-native';
import { Link } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
export default function AboutScreen() {
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.title}>Про застосунок</Text>
<Text style={styles.p}>
Міні-проєкт статті про вкладені навігатори: Tabs → Stack нотаток →
деталі; окремо root modal створення з guard на back.
</Text>
<Link href="/notes" style={styles.link}>
← До нотаток
</Link>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' },
body: { padding: 16, gap: 12 },
title: { fontSize: 24, fontWeight: '800', color: '#0F172A' },
p: { fontSize: 16, lineHeight: 24, color: '#334155' },
link: { marginTop: 8, color: '#2563EB', fontWeight: '700', fontSize: 16 },
});
Перевірка міні-проєкту
- Список нотаток + вкладка «Про застосунок».
- Тап по нотатці → деталі, tab bar лишається, back → список.
- «Нова нотатка» → modal.
- Ввести текст, натиснути back → діалог. «Лишитись» / «Вийти».
- «Зберегти» → modal закривається, нотатка в списку.
Критерій «готово»
- Усі файли з повним кодом підставлено
- Nested Stack у
notes/ - Modal
modal/new - Guard на dirty
- Немає
useState('screen')замість Router
Nomad: стек деталей поїздки та модалки
Навіщо користувачу
- Тап по картці поїздки відкриває деталі (опис, мітки, місця поїздки).
- Tab bar лишається — можна перейти в «Місця» / «Ще».
- «Нова поїздка» відкривається як modal (з форми на home, з about Link, з деталей).
- Місця з вкладки «Місця» ведуть на деталі відповідної поїздки.
Нитка
Уже є: tabs Поїздки/Місця/Ще, форма RHF, TripsProvider, тема, FlashList.
Додаємо:
| Зміна | Навіщо |
|---|---|
(tabs)/trips/ + nested Stack | список + [id] |
getTrip, getPlacesForTrip | дані деталей |
create-trip → presentation: 'modal' | модальна форма |
unstable_settings.initialRouteName | якір для modal |
| навігація з places / chips | у деталі поїздки |
Повний знімок
Перевірка Nomad
- Вкладка «Поїздки» — список як раніше.
- Тап по картці → деталі, tabs знизу. Back → список.
- «Нова поїздка» → modal поверх. Зберегти / скасувати → dismiss.
- З деталей кнопка «Нова поїздка (modal)» теж відкриває modal.
- Вкладка «Місця» → тап → деталі поїздки цього місця.
- Тема / форма / списки не зламані.
Коміт
cd /path/to/nomad
git add -A
git commit -m "$(cat <<'EOF'
feat: trip details stack and modals
Material: content/15.react-native/12.nested-navigators-and-modals.md
EOF
)"
git push
Практичні завдання
Базовий
- Своїми словами: card vs modal.
- Чому tabs лишаються на
/trips/1, але ховаються під create-trip modal. - Навіщо
initialRouteName: '(tabs)'.
Середній
- Міні-проєкт «Нотатки» повністю.
- У Nomad додати
headerRightна деталях — кнопка «Поділитись» (покиAlert). - Guard на create-trip, якщо
formState.isDirty.
Професійний
- Порівняти detail у nested stack vs detail на root Stack (tabs hide) — короткий висновок UX.
- Нарис структури
(auth)+Stack.Protectedбез повного SecureStore. presentation: 'formSheet'на iOS (якщо SDK дозволяє) — нотатка з відмінностей.
Часті запитання
trips/ nested. Перенесіть файл під теку вкладки зі Stack layout.presentation: 'modal' vs formSheet / transparentModal — різні режими. Дивіться docs вашого SDK і тестуйте на девайсі.dirty === true, що хук на тому екрані, який знімають, і що після Save вимикаєте dirty (saved flag).useLocalSearchParams інколи дає string | string[]. Нормалізуйте: Array.isArray(id) ? id[0] : id.Що далі
Вкладеність і modal закривають «геометрію» навігації. Далі — deep linking і params: параметри маршруту, scheme, universal links і cold start.
Основи Expo Router
Архітектура File-based routing у Expo — сегрегація app/ та src/, життєвий цикл макетів _layout, навігатори Stack і Tabs, декларативна та імперативна маршрутизація через Link та useRouter, механіка повернення назад, Typed Routes, міні-проєкт «Довідник міст» та навігаційна оболонка Nomad
Deep linking і параметри маршрутів
Архітектура параметризації маршрутів у Expo Router — динамічні сегменти [id], query-параметри, хуки useLocalSearchParams та useGlobalSearchParams, реєстрація Custom URL Scheme, робота з модулем expo-linking, холодний та теплий старт (Cold/Warm Start), Universal Links і Android App Links, міні-проєкт «Купон за лінком» та навігація за посиланням у Nomad