.NET MAUI Shell у 2026: маршрути, deep linking та передача параметрів

Практичний посібник з навігації Shell у .NET MAUI 10: як реєструвати маршрути, передавати параметри через GoToAsync, налаштувати deep linking через Android App Links і iOS Universal Links та уникнути п'яти найпоширеніших помилок продакшн-проєктів.

.NET MAUI Shell: маршрути й deep linking 2026

Оновлено: 14 вересня 2026

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 інтерфейсу.

<?xml version="1.0" encoding="UTF-8"?>
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:pages="clr-namespace:MyApp.Pages"
       x:Class="MyApp.AppShell"
       Shell.FlyoutBehavior="Flyout">

    <FlyoutItem Title="Головна" Icon="home.png">
        <Tab Title="Стрічка">
            <ShellContent Route="feed" ContentTemplate="{DataTemplate pages:FeedPage}" />
        </Tab>
        <Tab Title="Замовлення">
            <ShellContent Route="orders" ContentTemplate="{DataTemplate pages:OrdersPage}" />
        </Tab>
    </FlyoutItem>

    <FlyoutItem Title="Профіль" Icon="user.png">
        <ShellContent Route="profile" ContentTemplate="{DataTemplate pages:ProfilePage}" />
    </FlyoutItem>

</Shell>

Зверніть увагу на атрибут 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:

<activity android:name=".MainActivity" android:launchMode="singleTop">
  <intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https"
          android:host="myapp.com"
          android:pathPrefix="/orders" />
  </intent-filter>
</activity>

Атрибут 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.

<!-- Entitlements.plist -->
<key>com.apple.developer.associated-domains</key>
<array>
  <string>applinks:myapp.com</string>
</array>

Обробка URL у коді

Незалежно від платформи, .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:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             x:Class="MyApp.Pages.LoginPage"
             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.

<Shell.FlyoutHeaderTemplate>
    <DataTemplate>
        <Grid HeightRequest="200" BackgroundColor="{StaticResource Primary}">
            <VerticalStackLayout VerticalOptions="Center" Padding="20">
                <Image Source="{Binding CurrentUser.AvatarUrl}"
                       HeightRequest="80" WidthRequest="80" />
                <Label Text="{Binding CurrentUser.DisplayName}"
                       TextColor="White" FontSize="18" />
            </VerticalStackLayout>
        </Grid>
    </DataTemplate>
</Shell.FlyoutHeaderTemplate>

<Shell.MenuItems>
    <MenuItem Text="Вихід" IconImageSource="logout.png"
              Clicked="OnLogoutClicked" />
</Shell.MenuItems>

MenuItem, це «дії», а не сторінки. Вони не мають маршруту і викликають Clicked-обробник. Використовуйте їх для виходу, зміни теми, зворотного зв'язку, тобто для команд, які не приводять до нової сторінки. Плутанина між ShellContent і MenuItem, це ще одна річ, з якою ми регулярно бачимо у код-рев'ю.

Життєвий цикл, охорона маршрутів та тестування

Shell викидає події Navigating і Navigated на кожен перехід. Navigating дає вам можливість скасувати перехід, і саме це є механізмом охорони маршрутів. Використовуйте це для сценаріїв на кшталт «не пускати незалогіненого користувача на екран замовлень».

public AppShell()
{
    InitializeComponent();
    RegisterRoutes();
    Navigating += OnNavigating;
}

private async void OnNavigating(object sender, ShellNavigatingEventArgs e)
{
    if (e.Target.Location.OriginalString.Contains("//orders") &&
        !_authService.IsAuthenticated)
    {
        e.Cancel();
        await GoToAsync("//login");
    }
}

Для тестування навігації ми рекомендуємо тонку абстракцію: інтерфейс INavigationService, який всередині викликає Shell.Current.GoToAsync. Це дозволяє мокати навігацію у юніт-тестах ViewModel і не залежати від живого Shell. У архітектурному пості про SQLite та Repository Pattern ми показували схожий підхід для даних. Принцип той самий, і його варто застосовувати до кожної залежності від фреймворку.

Часті помилки, що ми зробили в продакшні

За два роки роботи з Shell у чотирьох продакшн-проєктах ми зібрали список повторюваних граблів. Ось п'ять найдорожчих:

  1. Незареєстрований маршрут. GoToAsync тихо провалюється (у деяких випадках кидає ArgumentException), і ніхто цього не помічає до якогось релізу. Пишіть інтеграційний тест, який проходить по всіх маршрутах і перевіряє, що кожен переходить успішно.
  2. Завантаження даних у OnAppearing. Як я вже згадував, ApplyQueryAttributes виконується раніше. Якщо ваш ViewModel має LoadAsync, викликайте його з ApplyQueryAttributes, а не з OnAppearing.
  3. Використання Navigation.PushAsync всередині Shell. Це формально працює, але створює тіньовий стек, який Shell не бачить. Кнопка «Назад» починає поводитись непередбачувано. Правило: у Shell-додатку тільки GoToAsync.
  4. Пропущений autoVerify для Android App Links. Без нього ваш domain-based deep link не спрацює автоматично. І assetlinks.json має віддаватись з правильним Content-Type.
  5. Складні об'єкти в 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(".."). Це еквівалент натискання системної кнопки.

Priya Sharma
Про Автора Priya Sharma

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.