Вкладені навігатори та модалки
Вкладені навігатори та модалки
З чого почати
У попередній статті ми зібрали базову карту маршрутів Expo Router: кореневий Stack, група (tabs) із трьома вкладками, окремий файл create-trip, переходи через Link і useRouter. Для навчального «три розділи + одна форма» цього вже вистачало.
Але варто відкрити майже будь-який зрілий застосунок на телефоні — і картина складніша. На вкладці «Стрічка» тап по картці відкриває деталі, при цьому нижня панель вкладок часто лишається (або зникає — залежно від продукту). Форма «щось швидко додати» виїжджає зверху / як sheet, а не «вклеюється» в ту саму історію, що й глибокий перегляд. Якщо користувач набрав текст і випадково свайпнув «назад», з’являється питання: «Вийти без збереження?» Окремо існує «світ гостя» (вхід / реєстрація) і «світ авторизованого користувача» (вкладки, профіль) — це вже не один плоский список екранів.
Ця стаття якраз про другий поверх навігації:
- Вкладені навігатори (nested navigators) — Stack усередині вкладки Tabs.
- Модальні екрани — той самий Stack, але з іншим
presentation. - Групи маршрутів на кшталт
(auth)— організація «різних світів» UI. - Guard на back — перехоплення виходу з екрана з незбереженими даними.
Глибокий розбір query-параметрів, cold start і universal links лишаємо наступній статті. Динамічний сегмент [id] тут з’являється лише в обсязі, потрібному для екрана деталей.
Після матеріалу ви зможете:
- Пояснити людською мовою, навіщо вкладати Stack у Tabs, а не тримати все в одному стеку.
- Намалювати на папері дерево layout для list → detail зі збереженим tab bar.
- Налаштувати
presentation: 'modal'і пояснити, чим це відрізняється від звичайногоpush. - Сказати, який саме стек реагує на
router.back()у різних ситуаціях. - Описати ідею груп
(auth)/ «захищених» маршрутів без плутанини з безпекою API. - Реалізувати guard «форма брудна → підтвердження».
- Зібрати міні-проєкт «Нотатки» за повним кодом і перенести ідеї в Nomad.
::react-native-preview на сайті. У статті — послідовне пояснення, схеми й код; перевірка — у Expo Go або симуляторі.Код наскрізного застосунку: github.com/arakviel/nomad.
Життєва сцена: що ламається без вкладеності
Уявіть Nomad лише з «плоским» root Stack і без вкладеного стека у «Поїздках».
Користувач на стрічці тапає «Карпати на вихідні». Якщо деталі покласти на root Stack поруч із вкладками, часто виходить так: екран деталей відкривається на весь застосунок, нижня панель зникає. Це нормальний і навіть бажаний UX для деяких продуктів (читалка, відео). Але для щоденника подорожей часто хочуть інше: «я всередині розділу Поїздки, можу швидко глянути Місця, не гублячи контекст».
Інший провал — навпаки: форму створення теж запхати всередину вкладки як звичайний push. Тоді «Нова поїздка» виглядає як ще один «глибокий» екран розділу, tab bar може лишатись під довгою формою, а жест «закрити задачу» менш очевидний, ніж у модалки «відкрив → зберіг / закрив».
Третій провал — ігнорувати історію. Користувач відкрив форму, почав друкувати, випадково свайпнув dismiss — і все зникло. Людина не каже «не було usePreventRemove»; каже «додаток з’їв мою нотатку».
Отже, тема статті — не «ще три API», а три різні задачі UX, які розв’язують вкладеністю, modal і guard.
Що таке «вкладений навігатор» простими словами
Навігатор (navigator) — компонент, який вирішує, який із набору екранів зараз видимий і як між ними перемикатися (стопка, вкладки, drawer…).
У статті 11 ми вже мали:
- Tabs — кілька розділів «поруч»;
- Stack — екрани «один на одному» з історією back.
Вкладений навігатор (nested navigator) — ситуація, коли один навігатор є екраном (або гілкою) іншого.
Приклад, який ми збираємо в Nomad:
- Корінь —
Stack(файлapp/_layout.tsx). - Один із «екранів» кореня — ціла група вкладок
(tabs)(усередині —Tabs). - Одна з вкладок — не «просто сторінка», а знову Stack (тека
trips/зі своїм_layout.tsx). - У цьому внутрішньому Stack:
index(список) і[id](деталі). - Окремо на кореневому Stack —
create-tripзpresentation: 'modal'.
Користувач цього дерева «не бачить». Він бачить: вкладки, стрічку, деталі, модалку. Але розробник зобов’язаний тримати дерево в голові: інакше router.back() «не туди», tab bar «зник», modal «не закривається».
Навіщо не тримати «все в одному Stack»
Можна уявити радикально простий варіант: один кореневий Stack і жодних Tabs — Home, TripDetail, Places, About, Create, Login як послідовні екрани. Технічно це працює. Продуктово — ні, щойно з’являються постійні розділи.
Типові болі «плоского» стека:
- Немає «дому» для розділу. Користувач звик, що «Поїздки / Місця / Ще» завжди внизу.
- Back плутає розділи. З «Місць» back може вийти на «деталі поїздки», хоча людина вже «змінила тему».
- Довга історія. Десять push підряд — і системний back довго «відмотує» те, чого користувач уже не пам’ятає.
- Складно зберігати стан розділу. Вкладки як раз і дають паралельні «гілки» з власною (вкладеною) історією.
Tabs розв’язують паралельність.
Stack усередині вкладки розв’язує глибину в одній гілці.
Це не догма. Іноді деталі свідомо виносять на root Stack, щоб сховати tab bar і дати більше місця. Важливо обрати свідомо, а не отримати побічний ефект «так вийшло з файлами».
Місток зі статті 11 (не таблицею «для своїх»)
Після статті 11 у вас уже є:
- розуміння
app/,_layout.tsx,index, груп(tabs); - Tabs і root Stack;
Link,router.push,router.back;- уявлення про typed routes.
Що змінюється зараз — глибина дерева, а не «інший Router».
| Було (типово після ст. 11) | Стає (ця стаття) |
|---|---|
Вкладка = один файл (index.tsx) | Вкладка = тека з власним Stack (trips/) |
| Усі «другі екрани» на root | Деталі — в гілці вкладки; modal — на root |
create-trip як звичайний card | create-trip як modal |
back «просто назад» | Питання: назад у якому стеку? |
| Auth «колись потім» | Карта груп (auth) / app (без повного login у Nomad) |
| — | Guard на leave з брудної форми |
Як влаштований вкладений Stack у вкладці
Від одного файлу до каталогу
У статті 11 вкладка «Поїздки» могла бути просто app/(tabs)/index.tsx. Цього мало, щойно з’являється другий екран того ж розділу.
Трансформація:
# було
app/(tabs)/
index.tsx ← і список, і «куди ж діти деталі?»
# стало
app/(tabs)/
trips/
_layout.tsx ← Stack тільки для гілки «Поїздки»
index.tsx ← список → маршрут /trips
[id].tsx ← деталі → маршрут /trips/42
places.tsx
about.tsx
У app/(tabs)/_layout.tsx замість name="index" з’являється name="trips" — бо вкладка тепер тека, а не файл.
Що відчуває користувач
- Відкриває вкладку «Поїздки» — бачить список (як раніше).
- Тапає картку — анімація push всередині розділу; внизу лишаються Поїздки / Місця / Ще.
- Тисне «назад» (header або жест) — знову список цієї вкладки, а не «виліт» із розділу.
- Перемикає на «Місця» — інший розділ; повернувшись на «Поїздки», часто знову бачить той самий вкладений стан (залежить від того, чи екран розмонтували — але ідея гілки саме така).
Порівняйте з modal «Нова поїздка»: там вкладки під модалкою, задача відчувається як оверлей, не як «глибше в список».
Коли обирати nested detail, а коли root detail
Nested Stack (tabs видно) доречний, коли:
- деталі — природне продовження стрічки того ж розділу;
- користувач часто стрибає між вкладками;
- не потрібен «кіношний» повноекранний режим.
Detail на root Stack (tabs ховаються) доречний, коли:
- потрібна максимальна площа (медіа, карта на весь екран);
- flow «зайшов у деталі = тимчасово вийшов з tab-світу»;
- той самий detail відкривають з кількох вкладок і хочуть одну повноекранну реалізацію.
У Nomad для поїздок обрано nested: щоденник — це розділ зі стрічкою й карткою, tabs корисні поруч. Форма створення — modal на root: окрема задача, не ще один рядок стрічки.
Мінімальний код вкладеного layout
// app/(tabs)/trips/_layout.tsx
import { Stack } from 'expo-router';
export default function TripsStackLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen
name="[id]"
options={{ title: 'Поїздка', headerBackTitle: 'Список' }}
/>
</Stack>
);
}
Список відкриває деталі:
router.push(`/trips/${trip.id}`);
// або <Link href={`/trips/${trip.id}`} asChild>…
Зверніть увагу на шлях: /trips/..., бо сегмент теки trips входить у URL (на відміну від групи (tabs), яка в URL не видно).
Хто ким керує
trips, places, about. Він не перелічує екрани trips/[id] — для нього вкладка trips — «один слот», усередині якого вже живе свій навігатор.index і [id]. Саме він малює header деталей і обробляє back зі списку на деталі й назад.(tabs) як ціле і, наприклад, create-trip як modal. Root не займається push між списком і деталями поїздки — це робота nested Stack.Який router.back() спрацьовує
Це місце, де «суха» документація найчастіше підводить.
- Стоїте на
/trips/3(деталі у nested Stack) →router.back()зазвичай веде на/trips(список). Tab bar як був. - Стоїте на
/create-trip(modal на root) →router.back()закриває modal і повертає туди, звідки відкрили (список, about, навіть деталі). - Якщо зробити
router.push('/')«аби як закрити» після форми — можете накласти зайвий екран в історію; back потім відкриє порожню форму знову. Після modal майже завждиback()абоreplace, не «push home».
Правило: back знімає верхівку того стека, у якому цей екран лежить. Nested detail і root modal — різні стеки.
Modal докладніше: не «інший Router», а інший показ
Що таке modal у термінах UX
Модальний екран (modal screen) — UI, який перериває поточний контекст і вимагає дії (заповнити, підтвердити, закрити), після чого людина повертається туди, де була.
Приклади з життя телефону: «Новий контакт», «Фільтри», «Поділитись», «Швидка нотатка».
Це відрізняється від:
- вкладки — довгоживучий розділ;
- detail push — поглиблення в контент того ж розділу.
Що таке modal у термінах Expo Router / React Navigation
У Stack у кожного екрана є опції показу. Ключова — presentation:
card(часто за замовчуванням) — «звичайний» перехід у стос екранів;modal— модальний стиль (на iOS часто card-over-card / sheet-подібна метафора; на Android — свій material-перехід).
Тобто файл create-trip.tsx — той самий тип маршруту, що й інші. Відрізняється options на Stack.Screen у layout.
<Stack.Screen
name="create-trip"
options={{
presentation: 'modal',
title: 'Нова поїздка',
headerBackTitle: 'Закрити',
}}
/>
Відкриття не потребує окремого API:
router.push('/create-trip');
Закриття:
router.back(); // після submit або «Скасувати»
Де фізично лежить файл modal
Практичне правило курсу:
(tabs)/...— довгоживучі розділи й їхня внутрішня глибина;- поруч із
(tabs)на root Stack — modal і інші «оверлейні» задачі.
Якщо покласти modal всередину trips/ Stack, він стане частиною історії вкладки. Інколи це потрібно; для «Нова поїздка» в Nomad зручніше root: форма одна, відкривається з home, about, деталей — і завжди виглядає як глобальна задача.
unstable_settings і якір
export const unstable_settings = {
initialRouteName: '(tabs)',
};
Навіщо: якщо застосунок перезавантажили або відкрили deep link одразу на modal, навігатору потрібен якір — базовий екран, «від якого» існує історія. Інакше кнопка «закрити» може повести себе дивно (немає куди back). Назва unstable_settings у Expo — історична; опція широко використовується. Слідкуйте за docs вашого SDK.
Після submit: хто оновлює список
Modal не «повертає props» батькові як React-колбек між деревами файлів. У Nomad список живе в TripsProvider (Context) вище за Stack. Форма викликає addTrip(trip) і router.back(). Вкладка зі списком уже підписана на той самий Context — картка з’являється без «перезавантаження маршруту».
Це важливий архітектурний урок: стан, спільний для кількох гілок навігації, піднімають над навігаторами (Context зараз, RTK пізніше).
iOS і Android
Stack.Screen options у layout (хоча ім’я файлу = name маршруту).headerShown: true, title «Нова поїздка», back/close. Можна сховати header і намалювати свій — тоді не забудьте доступний спосіб закрити (кнопка + Back).ThemeProvider / TripsProvider мають обгортати root Stack, а не лише (tabs). Інакше modal «не бачить» useTrips() або тему.Групи (auth): два світи інтерфейсу
У Nomad повноцінний login ще попереду (SecureStore, сесія). Але картографію маршрутів закладають рано, щоб потім не переписувати все дерево.
Проблема
Екрани «Увійти» / «Зареєструватись» не повинні сидіти всередині (tabs) як «ще одна вкладка». І навпаки: стрічку поїздок не показують гостю так само, як користувачу, який увійшов (або показують, але з іншим guard — залежить від продукту).
Ідея груп
Використовують групи тек (дужки, як (tabs)):
app/
_layout.tsx
(auth)/
login.tsx
register.tsx
(app)/ # або одразу (tabs)
(tabs)/...
Група не додає сегмент у URL, але дозволяє:
- окремі layout (без tab bar на логіні);
- умовний редірект: «немає сесії → login»;
replaceпісля успішного входу, щоб Back не повернув на форму пароля.
Stack.Protected і класичний редірект
У новіших версіях Expo Router з’являється декларативний Stack.Protected з guard={isLoggedIn} — екрани всередині рендеряться / доступні лише коли guard істинний. Класичний підхід: у root layout читаєте сесію і робите router.replace('/login') або replace('/(tabs)').
Обидва підходи — про UI-маршрути. Вони не замінюють:
- перевірку JWT на сервері;
- secure storage;
- захист API.
У цій статті для Nomad повний (auth) не впроваджуємо — лише закладаємо розуміння. Міні-проєкт зосереджений на nested + modal + guard.
Guard на «назад»: не втратити введене
Сценарій
Користувач відкрив modal «Нова нотатка», написав абзац, випадково:
- свайпнув dismiss на iOS;
- натиснув системний Back на Android;
- тапнув «закрити» в header.
Без додаткової логіки екран просто знімається, текст зникає. Це не «баг React», а відсутність продуктового правила.
Терміни
Dirty (брудна форма) — користувач уже щось змінив порівняно з початковим станом. У React Hook Form це formState.isDirty. У простому useState — наприклад title.trim() || body.trim().
Leave / remove — подія «цей екран хочуть прибрати з навігаційної історії» (back, dismiss, replace тощо).
Guard — логіка «не прибирати одразу, а спитати / перевірити».
usePreventRemove
У React Navigation (Expo Router стоїть на ньому) є хук usePreventRemove з пакета @react-navigation/native.
Ідея API:
usePreventRemove(dirty, ({ data }) => {
// dirty === true → сюди потрапляємо замість миттєвого leave
Alert.alert('Вийти без збереження?', '…', [
{ text: 'Лишитись', style: 'cancel' },
{
text: 'Вийти',
style: 'destructive',
onPress: () => navigation.dispatch(data.action),
},
]);
});
Покроково:
- Користувач ініціює back/dismiss.
- Якщо
dirty === false— хук не блокує; екран іде як завжди. - Якщо
dirty === true— навігація зупиняється, викликається callback. - У callback лежить
data.action— та дія, яку хотіли виконати. - «Лишитись» — нічого не dispatch-имо; екран лишається.
- «Вийти» —
navigation.dispatch(data.action)— дозволяємо відкладений leave.
Після успішного збереження важливо скинути dirty (окремий прапорець saved або reset форми), інакше back() після save знову покаже діалог.
dirty має стати false до або узгоджено з router.back().dispatch(action) після «Вийти» екран не закриється — ви лише показали Alert.dispatch. Імпорт з expo-router / React Navigation — за docs вашої версії; у міні-проєкті показано робочий варіант.У create-trip Nomad guard у цій статті не додаємо (форма вже велика; Pro-завдання — перенести патерн). У міні-проєкті «Нотатки» — повна реалізація.
Динамічний сегмент [id] — рівно стільки, скільки треба для деталей
Файл:
app/(tabs)/trips/[id].tsx
Маршрути:
/trips/1/trips/local-1710000000(локально створена поїздка)
Читання параметра:
import { useLocalSearchParams } from 'expo-router';
const { id } = useLocalSearchParams<{ id: string }>();
// інколи приходить string[] — нормалізуємо:
const tripId = Array.isArray(id) ? id[0] : id;
Далі:
const trip = tripId ? getTrip(tripId) : undefined;
if (!trip) {
// екран «не знайдено» + кнопка назад
}
Чому параметр може бути масивом — нюанси парсингу URL/Router; захист Array.isArray — добра звичка.
Чого ми свідомо не робимо в цій статті: query string, universal links, cold start з пуша. Це стаття 13. Тут [id] — лише ключ, щоб відкрити конкретну поїздку з уже відомого списку в пам’яті.
Зв’язок ідей на одному прикладі (Nomad)
Пройдімо шлях користувача й позначимо який механізм працює.
1. Вкладка «Поїздки»
Працює Tabs. Контент — trips/index у nested Stack. Header списку свій; tab bar видимий.
2. Тап по картці
router.push('/trips/' + id) — push у вкладеному Stack. Анімація «вперед»; tab bar лишається.
3. Екран деталей
Той самий nested Stack. Header системний («назад» → список). Можна відкрити modal «Нова поїздка» — це вже root Stack.
4. Modal create-trip
presentation: 'modal' на root. Tabs під модалкою. Submit → addTrip у Context → router.back() → знову деталі або список (звідки відкрили).
5. Вкладка «Місця» → тап
push на /trips/{tripId} — знову nested detail. Користувач потрапляє в гілку «Поїздки» на екрані деталей.
Якщо на кроці 2 помилково зробити detail root-екраном без nested layout, на кроці 2 tab bar зникне — і ви отримаєте інший продукт, навіть «з тим самим» React-компонентом картки.
Антипатерни (і чому вони болять)
1. Деталі винесено на root без свідомого рішення про tab bar
Симптом: «чомусь пропали вкладки». Насправді файл лежить не в trips/ Stack, а на root. Або навпаки — хотіли full screen, а вклали в tabs. Спочатку UX, потім шлях файлу.
2. Modal як п’ята вкладка
«Нова поїздка» в tab bar завжди висить і скидає стан. Modal-задача — тимчасова; їй місце в Stack options, не в Tabs.
3. Закрити modal через push('/')
Псує історію: back повертає на форму. Після успіху — back() або replace осмислено.
4. Плутати back nested і back root
З деталей чекаєте «закрити застосунок», а отримуєте список — або навпаки. Малюйте дерево навігаторів, перш ніж звинувачувати Router.
5. Dirty-форма без guard
Тиха втрата тексту = втрата довіри. Навіть простий Alert рятує.
6. «Auth = сховали кнопку Увійти»
Без розділення маршрутів / redirect користувач з deep link може опинитись «всередині» без сесії. Групи й guard маршрутів — про послідовність екранів, плюс потім справжня сесія.
7. Товста бізнес-логіка в кожному layout
Layout має з’єднувати навігатори й options, а не тягнути fetch на 200 рядків. Дані — у features / providers (як TripsProvider).
Міні-проєкт: «Нотатки» (від А до Я)
Мета
Окремий застосунок поза 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/, layout, Stack і Tabs, Link і useRouter, назад, typed routes; довідник міст і навігаційна оболонка Nomad
Deep linking і параметри маршрутів
Параметри в Expo Router — динамічні сегменти, query, useLocalSearchParams; scheme, universal links і cold start