.NET MAUI Shell у 2026: маршрути, deep linking та передача параметрів
Практичний посібник з навігації Shell у .NET MAUI 10: як реєструвати маршрути, передавати параметри через GoToAsync, налаштувати deep linking через Android App Links і iOS Universal Links та уникнути п'яти найпоширеніших помилок продакшн-проєктів.
Shell у .NET MAUI — це верхньорівневий контейнер навігації, який об'єднує flyout, таби та URI-подібні маршрути в єдину модель, тож замість того, щоб штовхати сторінки у стек, ви пишете Shell.Current.GoToAsync("//orders/details?id=42"). Це найшвидший спосіб побудувати структуру навігації для .NET MAUI-додатка у 2026 році, і саме на ньому ми будували наш останній продукт для 1,2 млн активних користувачів. У цьому посібнику я покажу, як налаштувати маршрути, передавати параметри, робити deep linking і не наступити на граблі, які нам коштували двох спринтів.
Shell поєднує flyout, таби і маршрутизацію в одну модель. Це стандарт де-факто для нових MAUI-додатків у 2026 році.
Маршрути реєструються через Routing.RegisterRoute та ієрархічну структуру ShellContent; глобальні маршрути дозволяють переходити на будь-яку сторінку з будь-якого місця.
Параметри передаються через query string у GoToAsync та приймаються через IQueryAttributable або атрибут [QueryProperty].
Deep linking у .NET MAUI 10 працює через Android App Links і iOS Universal Links; URI мапиться безпосередньо на маршрут Shell.
Модальна навігація і повернення вгору по стеку виконуються через префікси modal та ".." у маршруті (синтаксис нагадує файлову систему).
Найпоширеніші баги: незареєстровані маршрути, гонки при передачі параметрів у OnAppearing та неправильна конфігурація autoVerify для App Links.
Чим Shell відрізняється від NavigationPage
Коли ми мігрували з Xamarin.Forms до .NET MAUI разом з .NET Aspire, перше питання команди було: «А чи потрібен нам взагалі Shell?». Коротка відповідь: так, якщо у вас більше двох секцій навігації або якщо ви плануєте deep linking. NavigationPage — це імперативний стек, ви викликаєте Navigation.PushAsync(page) і виштовхуєте назад через PopAsync. Це працює для лінійних сценаріїв (майстер, форма, підтвердження), але швидко ламається, коли у вас є flyout з чотирма розділами та TabBar усередині одного з них.
Shell, натомість, декларативний. Ви описуєте всю топологію додатка у XAML (flyout, таби, вкладені секції), а потім переходите між ними через URI-подібні маршрути. Це дає три великі переваги: єдине місце для навігаційної логіки, автоматичне керування стеком повернення та готовий механізм deep linking. Мінус: Shell має свою криву навчання, і не всі патерни з NavigationPage переносяться механічно. У 2026 році команда .NET рекомендує Shell як стандарт для нових проєктів, а офіційна документація Microsoft Learn оновила приклади саме на нього.
Практична порада: якщо у вас 1–2 сторінки без табів і без flyout, залишайтесь на NavigationPage. Якщо більше, беріть Shell і не озирайтесь.
Структура Shell: flyout, таби та ShellContent
Кореневий елемент, це клас, що успадковує Shell. Усередині XAML ви складаєте його з трьох будівельних блоків: FlyoutItem (пункт у бічному меню), TabBar (нижні таби без flyout) і ShellContent (посилання на конкретний ContentPage). Один FlyoutItem може містити кілька Tab, а один Tab, у свою чергу, кілька ShellContent. Ця вкладеність дозволяє описати навіть складні застосунки на кшталт банківського або e-commerce інтерфейсу.
Зверніть увагу на атрибут Route: саме він стає сегментом URL. У прикладі вище //feed, //orders та //profile, це абсолютні маршрути, доступні з будь-якого місця додатка. Префікс // означає «від кореня Shell», тоді як без нього маршрут інтерпретується відносно поточної сторінки. Ми зазвичай пишемо всю топологію в одному AppShell.xaml, бо це стає єдиним джерелом істини для навігації, і будь-який новий інженер у команді може за 30 секунд зрозуміти структуру.
Реєстрація маршрутів у .NET MAUI 10
Не всі сторінки повинні бути частиною Shell-топології. Деталі, форми редагування, модальні вікна, це сторінки, які викликаються тимчасово і не мають місця у flyout. Такі сторінки реєструються глобально через Routing.RegisterRoute у конструкторі AppShell або у MauiProgram.CreateMauiApp. У .NET MAUI 10 ми зазвичай робимо це через розширювальний метод, щоб тримати список маршрутів у одному місці.
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
RegisterRoutes();
}
private static void RegisterRoutes()
{
Routing.RegisterRoute("orders/details", typeof(OrderDetailsPage));
Routing.RegisterRoute("orders/edit", typeof(OrderEditPage));
Routing.RegisterRoute("profile/settings", typeof(SettingsPage));
Routing.RegisterRoute("profile/settings/notifications", typeof(NotificationsPage));
}
}
Ієрархічні маршрути (orders/details), це не просто косметика. Вони визначають, як Shell будує стек повернення. Коли користувач натискає системну кнопку «Назад» на Android або свайпає з лівого краю на iOS, Shell знає, куди його повернути, бо стек побудований відповідно до сегментів маршруту. Ми навчилися цьому важким способом: колись зареєстрували все як плоскі маршрути (details, edit, settings) і отримали купу тікетів на кшталт «додаток кидає мене на головну після редагування замовлення».
У .NET MAUI 10 з'явилась підтримка типізованих маршрутів через source generators: можна декларувати маршрут атрибутом [Route("orders/details")] прямо на класі сторінки, і генератор створить сильно-типізований метод переходу. Це зменшує кількість magic strings у коді і робить рефакторинг набагато безпечнішим.
GoToAsync та відносна навігація
Уся навігація у Shell проходить через один метод, Shell.Current.GoToAsync. Він приймає рядок маршруту (або ShellNavigationState) і асинхронно виконує перехід. Синтаксис маршруту нагадує URL: "//orders" означає перехід від кореня, "details" це відносний перехід уперед, ".." робить крок назад по стеку, "../.." робить два кроки назад.
// Абсолютний перехід від кореня Shell
await Shell.Current.GoToAsync("//orders");
// Відносний перехід на дочірню сторінку (додає в стек)
await Shell.Current.GoToAsync("details");
// Крок назад
await Shell.Current.GoToAsync("..");
// Перехід назад з параметром для попередньої сторінки
await Shell.Current.GoToAsync("..?refresh=true");
// Заміна поточної сторінки замість додавання (немає ".." назад)
await Shell.Current.GoToAsync("//orders/details", animate: false);
Найпоширеніша помилка новачків, це плутанина між // та /. Один слеш означає «переходь від кореня Shell, зберігаючи поточну секцію», два слеші означають «переходь від кореня та скидай стек». Різниця важлива, коли ви маєте дві секції у flyout і хочете переключатись між ними без побудови стеку повернення.
Ще одна тонкість: GoToAsync повертає Task, який завершується після завершення анімації. Якщо ви пишете тест і одразу перевіряєте новий стан сторінки, обов'язково await. Я колись мав флейкі-тести, які проходили локально і падали на CI саме через це, знайти причину зайняло півдня.
Як передати параметри в Shell.Current.GoToAsync
Параметри передаються як query string у маршруті: await Shell.Current.GoToAsync("orders/details?id=42&source=push");. На стороні цільової сторінки або її ViewModel є два способи прийняти параметри: атрибут [QueryProperty] і інтерфейс IQueryAttributable. Другий варіант ми використовуємо в 90% випадків, бо він працює краще з CommunityToolkit.Mvvm та ObservableObject.
public partial class OrderDetailsViewModel : ObservableObject, IQueryAttributable
{
[ObservableProperty]
private int orderId;
[ObservableProperty]
private string source = "unknown";
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("id", out var idValue) &&
int.TryParse(idValue?.ToString(), out var id))
{
OrderId = id;
}
if (query.TryGetValue("source", out var src))
{
Source = src?.ToString() ?? "unknown";
}
_ = LoadOrderAsync();
}
private async Task LoadOrderAsync()
{
// Викликається один раз, коли параметри отримані.
// OnAppearing може спрацювати РАНІШЕ, тому не покладайтесь на нього.
}
}
Важливий момент: ApplyQueryAttributes викликається передOnAppearing. Якщо ви завантажуєте дані у OnAppearing на основі параметра, у вас гонка. Наш патерн такий: завжди ініціювати завантаження всередині ApplyQueryAttributes, а OnAppearing залишити для UI-специфічних речей (аналітика, оновлення хедера).
Для передачі складних об'єктів (не примітивів) використовуйте ShellNavigationQueryParameters. Це словник, який передається окремо від URI і не з'являється в query string:
var parameters = new ShellNavigationQueryParameters
{
{ "order", complexOrderObject }
};
await Shell.Current.GoToAsync("orders/details", parameters);
Але майте на увазі: такі параметри не зберігаються при deep linking, тож для сценаріїв, куди користувач може повернутись через push-нотифікацію або external URL, використовуйте лише примітивні query-параметри та підвантажуйте об'єкт за id.
Як реалізувати deep linking у .NET MAUI
Deep linking, це коли зовнішній URL (https://myapp.com/orders/42 або кастомна схема myapp://orders/42) відкриває конкретну сторінку у вашому додатку. У .NET MAUI 10 це працює автоматично, якщо URL мапиться на зареєстрований маршрут Shell. Вам потрібно налаштувати три речі: intent filter на Android, associated domains на iOS, та обробник App.OnAppLinkRequestReceived.
Android App Links
У Platforms/Android/AndroidManifest.xml додайте intent filter до вашої MainActivity:
Атрибут autoVerify="true" вимагає, щоб на https://myapp.com/.well-known/assetlinks.json лежав файл із підтвердженням володіння доменом. Це стандартна перевірка Google для App Links, без неї Android покаже користувачу диспетчер вибору браузера замість вашого додатка. Повний формат файлу описаний у офіційній документації Android App Links.
iOS Universal Links
На iOS вам потрібно два кроки: увімкнути Associated Domains у Entitlements.plist та розмістити файл apple-app-site-association на кореневому шляху вашого домена (без розширення, з Content-Type: application/json). Формат JSON описано у документації Apple з Associated Domains.
Незалежно від платформи, .NET MAUI викликає App.OnAppLinkRequestReceived(Uri uri). Оскільки шлях URI співпадає з маршрутом Shell, зазвичай достатньо просто перекласти URI на маршрут:
protected override void OnAppLinkRequestReceived(Uri uri)
{
base.OnAppLinkRequestReceived(uri);
// uri = https://myapp.com/orders/42
// Перетворюємо на Shell-маршрут: //orders/details?id=42
var route = $"//orders/details?id={uri.Segments.Last()}";
MainThread.BeginInvokeOnMainThread(async () =>
{
await Shell.Current.GoToAsync(route);
});
}
У продакшні ми обгортаємо цю логіку в окремий сервіс IDeepLinkResolver, щоб мати можливість юніт-тестувати парсинг URI без Shell-а. Це особливо корисно, коли у вас багато різних форматів URL (кампанії, реферали, share-links).
Модальна навігація та повернення
Модальна сторінка, це сторінка, яка перекриває всю навігацію Shell і не показує стандартну кнопку «Назад». Класичні приклади: логін, чекаут, повноекранний фотопоказ. У Shell ви робите сторінку модальною, додаючи Shell.PresentationMode="ModalAnimated" у самому ContentPage:
Після цього await Shell.Current.GoToAsync("login") відкриває сторінку модально. Закриття робиться тим же "..". Якщо ви хочете передати результат назад (наприклад, «користувач залогінився»), використовуйте query string при поверненні: await Shell.Current.GoToAsync("..?loggedIn=true"), і попередня сторінка отримає це у своєму ApplyQueryAttributes.
Один нюанс: якщо модальна сторінка має власний стек навігації (наприклад, майстер із трьох кроків), робіть кожен наступний крок звичайним GoToAsync("step2"), а вихід через GoToAsync("../.."). У нас працює правило: якщо модальний потік має більше ніж 3 екрани, це вже не модалка, а окрема секція Shell. Модалки для довгих флоу, це поганий UX (перевірено на трьох продакшн-релізах).
Налаштування flyout меню
Flyout за замовчуванням генерується автоматично з FlyoutItem: заголовок і іконка з властивостей. Це підходить для 80% додатків, але коли клієнт просить «а зробіть аватарку користувача у хедері і секцію "Вихід" внизу», потрібен кастомний шаблон. Shell дає три точки розширення: Shell.FlyoutHeaderTemplate, Shell.ItemTemplate, Shell.MenuItemTemplate.
MenuItem, це «дії», а не сторінки. Вони не мають маршруту і викликають Clicked-обробник. Використовуйте їх для виходу, зміни теми, зворотного зв'язку, тобто для команд, які не приводять до нової сторінки. Плутанина між ShellContent і MenuItem, це ще одна річ, з якою ми регулярно бачимо у код-рев'ю.
Життєвий цикл, охорона маршрутів та тестування
Shell викидає події Navigating і Navigated на кожен перехід. Navigating дає вам можливість скасувати перехід, і саме це є механізмом охорони маршрутів. Використовуйте це для сценаріїв на кшталт «не пускати незалогіненого користувача на екран замовлень».
Для тестування навігації ми рекомендуємо тонку абстракцію: інтерфейс INavigationService, який всередині викликає Shell.Current.GoToAsync. Це дозволяє мокати навігацію у юніт-тестах ViewModel і не залежати від живого Shell. У архітектурному пості про SQLite та Repository Pattern ми показували схожий підхід для даних. Принцип той самий, і його варто застосовувати до кожної залежності від фреймворку.
Часті помилки, що ми зробили в продакшні
За два роки роботи з Shell у чотирьох продакшн-проєктах ми зібрали список повторюваних граблів. Ось п'ять найдорожчих:
Незареєстрований маршрут.GoToAsync тихо провалюється (у деяких випадках кидає ArgumentException), і ніхто цього не помічає до якогось релізу. Пишіть інтеграційний тест, який проходить по всіх маршрутах і перевіряє, що кожен переходить успішно.
Завантаження даних у OnAppearing. Як я вже згадував, ApplyQueryAttributes виконується раніше. Якщо ваш ViewModel має LoadAsync, викликайте його з ApplyQueryAttributes, а не з OnAppearing.
Використання Navigation.PushAsync всередині Shell. Це формально працює, але створює тіньовий стек, який Shell не бачить. Кнопка «Назад» починає поводитись непередбачувано. Правило: у Shell-додатку тільки GoToAsync.
Пропущений autoVerify для Android App Links. Без нього ваш domain-based deep link не спрацює автоматично. І assetlinks.json має віддаватись з правильним Content-Type.
Складні об'єкти в query string. Це працює на моках, але ламається на deep link. Передавайте лише id, підвантажуйте на цільовій сторінці.
Якщо ваша команда мігрує з Xamarin.Forms, то Shell буде найбільшим ментальним стрибком. Заплануйте окремий тиждень на переосмислення структури навігації, не намагайтесь просто замінити PushAsync на GoToAsync. Наш досвід показує, що додатки, які потрапили у Shell «дослівним перекладом», через рік доводиться переписувати. А продукти, де навігаційну модель спроєктували з нуля під Shell, стабільно живуть і масштабуються.
Часті запитання
Що таке Shell навігація в .NET MAUI?
Shell, це декларативна модель навігації, яка описує flyout, таби і маршрути в одному XAML-файлі, а переходи виконує через URI-подібні маршрути (Shell.Current.GoToAsync("//orders/details?id=42")). Вона замінює імперативний NavigationPage і включає готову підтримку deep linking.
Як передати параметри в Shell.Current.GoToAsync?
Додайте query string до маршруту: GoToAsync("orders/details?id=42"). На стороні цільового ViewModel реалізуйте IQueryAttributable або застосуйте атрибут [QueryProperty]. Для складних об'єктів використовуйте ShellNavigationQueryParameters, але враховуйте, що вони не переживуть deep link.
Чим Shell відрізняється від NavigationPage?
NavigationPage, це імперативний стек (PushAsync/PopAsync), який добре працює для лінійних сценаріїв. Shell, натомість, це декларативна URI-модель з flyout, табами і deep linking «з коробки». У 2026 році команда .NET рекомендує Shell як стандарт для нових MAUI-проєктів.
Як налаштувати deep linking у .NET MAUI 10?
Додайте intent filter із autoVerify="true" в AndroidManifest.xml для Android App Links, увімкніть Associated Domains у Entitlements.plist для iOS Universal Links, і в App.OnAppLinkRequestReceived перетворіть отриманий URI на маршрут Shell через GoToAsync.
Як обробити подію повернення (Back button) у Shell?
Підпишіться на подію Navigating на рівні Shell і викличте e.Cancel(), якщо потрібно заблокувати перехід. Для навігації назад програмно використовуйте await Shell.Current.GoToAsync(".."). Це еквівалент натискання системної кнопки.
Cross-platform engineering lead who's shipped apps to millions on both Play Store and App Store. Believes shared codebases shouldn't mean shared mediocrity.
HybridWebView у .NET MAUI 10 хостить локальний веб-контент і встановлює типізований двосторонній міст між JavaScript та C#. Розберемо InvokeJavaScriptAsync, InvokeMethodAsync, події ініціалізації, обробку JS-винятків та підводні камені AOT з прикладами коду.