Навигация в .NET MAUI Shell в 2026: маршруты, табы, параметры и deep links

Полное руководство по навигации в .NET MAUI Shell на 2026 год: AppShell, маршруты, GoToAsync, параметры, табы и deep links с рабочими примерами.

Навигация .NET MAUI Shell: гайд 2026

Обновлено: 17 июня 2026

.NET MAUI Shell, если коротко, это иерархическая модель навигации, которая объединяет flyout-меню, табы и URL-подобные маршруты в единое API Shell.Current.GoToAsync, заменяя устаревший NavigationPage из Xamarin.Forms. В этом руководстве я разберу, как настроить Shell в .NET MAUI 9, регистрировать маршруты, передавать параметры, строить вложенные табы и обрабатывать deep links. Всё на рабочих примерах из боевых приложений 2026 года. Если вы только что мигрировали с Xamarin.Forms или начинаете новый проект, гайд закроет типовые вопросы по навигации.

  • Shell централизует всю навигацию приложения в AppShell.xaml через декларативную иерархию FlyoutItemTabShellContent.
  • Регистрация маршрутов через Routing.RegisterRoute("details", typeof(DetailsPage)) позволяет переходить на страницы, не описанные напрямую в визуальном дереве Shell.
  • Передача параметров делается через query-строку GoToAsync($"details?id={id}") плюс атрибут [QueryProperty] или интерфейс IQueryAttributable на странице назначения.
  • Deep links и универсальные ссылки требуют только настройки intent-filter или associated domains. Сам Shell разрезает URI на сегменты и вызывает правильную страницу.
  • В .NET MAUI 9 (ноябрь 2025) добавлена поддержка ShellSection.CurrentItem с анимацией, исправлены утечки памяти при PopAsync и оптимизирован Shell.FlyoutBehavior на iPad.
  • Shell не подходит для одностраничных приложений или экранов авторизации с уникальным дизайном. Для них лучше оставить Application.MainPage = new LoginPage() и переключаться вручную.

Что такое .NET MAUI Shell и зачем он нужен

Shell, это контейнер верхнего уровня для приложения .NET MAUI, реализующий типичный паттерн мобильной навигации: flyout-меню (бургер слева), нижнюю панель табов и стек страниц с back-кнопкой. Класс Shell наследуется от Page, но ведёт себя скорее как навигационный хост. Вы декларативно описываете иерархию разделов приложения, а Shell сам отрисовывает нужные элементы UI под каждую платформу: BottomNavigationView на Android, UITabBarController на iOS.

До Shell в Xamarin.Forms каждый разработчик писал свой собственный MasterDetailPage плюс NavigationPage плюс TabbedPage, склеивая их через DI-контейнер. Помню, как в одном из своих проектов на Forms мы потратили почти неделю только на то, чтобы поведение back-кнопки совпадало между Android и iOS.

Shell унифицирует это: одна точка входа Shell.Current, один API навигации GoToAsync, один формат URI для deep links. По данным официальной документации .NET MAUI 9, более 70% новых MAUI-проектов в 2025 году используют Shell как основной механизм навигации.

Shell особенно хорошо подходит приложениям с тремя и более основными разделами (например, маркетплейс: «Каталог», «Корзина», «Профиль»), приложениям с глубокой иерархией (категории → подкатегории → товар → отзывы) и продуктам, где важна плавная миграция с Xamarin.Forms на .NET MAUI с сохранением UX-паттернов.

Настройка AppShell.xaml: FlyoutItem, Tab, ShellContent

Стандартный шаблон dotnet new maui уже создаёт файл AppShell.xaml с минимальной структурой. Реальное приложение, как правило, требует комбинации flyout-меню (для редко используемых разделов) и нижних табов (для основных). Вот пример структуры с тремя табами и двумя flyout-пунктами:

<?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.Views"
       x:Class="MyApp.AppShell"
       FlyoutBehavior="Flyout"
       Title="MyApp">

    <!-- Основные разделы, нижние табы -->
    <TabBar>
        <Tab Title="Главная" Icon="home.png">
            <ShellContent ContentTemplate="{DataTemplate pages:HomePage}" Route="home" />
        </Tab>
        <Tab Title="Каталог" Icon="catalog.png">
            <ShellContent ContentTemplate="{DataTemplate pages:CatalogPage}" Route="catalog" />
        </Tab>
        <Tab Title="Профиль" Icon="profile.png">
            <ShellContent ContentTemplate="{DataTemplate pages:ProfilePage}" Route="profile" />
        </Tab>
    </TabBar>

    <!-- Доп. пункты, открываются из flyout -->
    <FlyoutItem Title="Настройки" Icon="settings.png">
        <ShellContent ContentTemplate="{DataTemplate pages:SettingsPage}" Route="settings" />
    </FlyoutItem>
    <FlyoutItem Title="О приложении" Icon="info.png">
        <ShellContent ContentTemplate="{DataTemplate pages:AboutPage}" Route="about" />
    </FlyoutItem>
</Shell>

Обратите внимание на использование ContentTemplate="{DataTemplate ...}" вместо прямого создания страницы. Это включает ленивую инициализацию: страница создаётся только при первой навигации на неё. Без DataTemplate все страницы создаются при старте Shell, что заметно ухудшает время холодного запуска. Атрибут Route задаёт сегмент URI, по которому потом можно перейти через GoToAsync("//home").

Регистрация маршрутов и GoToAsync

Страницы из визуального дерева Shell (ShellContent) автоматически доступны по своему Route. Но детальные страницы, например, экран товара, открывающийся из каталога, в дерево Shell не входят. Их нужно зарегистрировать вручную через Routing.RegisterRoute, обычно в конструкторе AppShell:

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        // Регистрация детальных страниц
        Routing.RegisterRoute("product", typeof(ProductDetailsPage));
        Routing.RegisterRoute("product/reviews", typeof(ReviewsPage));
        Routing.RegisterRoute("checkout", typeof(CheckoutPage));
        Routing.RegisterRoute("checkout/success", typeof(SuccessPage));
    }
}

После регистрации переход выполняется одной строкой:

// Относительный путь, добавляется в стек поверх текущей страницы
await Shell.Current.GoToAsync("product");

// Возврат назад
await Shell.Current.GoToAsync("..");

// Возврат на два уровня
await Shell.Current.GoToAsync("../..");

// Абсолютный путь, сбрасывает стек и переходит на корневой таб
await Shell.Current.GoToAsync("//home");

// Глубокий относительный переход
await Shell.Current.GoToAsync("product/reviews");

Ключевое отличие от NavigationPage.PushAsync в том, что Shell разрешает символьные пути. Вы пишете "checkout/success", а не передаёте instance страницы. Это упрощает рефакторинг, тестирование (можно мокать IShellNavigationService) и работу с Shell Navigation API в целом.

Передача параметров через QueryProperty

Самый частый вопрос на форумах: «Как передать ID товара на детальную страницу?» Shell использует синтаксис query-строки, как в обычных URL:

// В CatalogPageViewModel
await Shell.Current.GoToAsync($"product?id={product.Id}&source=catalog");

На принимающей стороне есть два способа подхватить параметры. Первый, атрибут [QueryProperty] на ViewModel или странице:

[QueryProperty(nameof(ProductId), "id")]
[QueryProperty(nameof(Source), "source")]
public partial class ProductDetailsViewModel : ObservableObject
{
    [ObservableProperty]
    private int productId;

    [ObservableProperty]
    private string source;

    partial void OnProductIdChanged(int value)
    {
        // Параметр уже доступен, можно грузить данные
        _ = LoadProductAsync(value);
    }
}

Второй способ, реализовать интерфейс IQueryAttributable. Он удобнее, когда нужно атомарно обработать сразу несколько параметров (например, чтобы избежать гонок между двумя OnXxxChanged):

public partial class ProductDetailsViewModel : ObservableObject, IQueryAttributable
{
    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        var id = Convert.ToInt32(query["id"]);
        var source = query["source"]?.ToString();
        _ = LoadProductAsync(id, source);
    }
}

Табы, FlyoutItem и нижняя панель навигации

Shell поддерживает два уровня табов: верхние (внутри одного раздела) и нижние (между разделами). Нижние табы описываются через TabBar (как в примере выше). Верхние табы, через вложение нескольких ShellContent внутрь одного Tab:

<Tab Title="Заказы" Icon="orders.png">
    <ShellContent Title="Активные" ContentTemplate="{DataTemplate pages:ActiveOrdersPage}" Route="active" />
    <ShellContent Title="История" ContentTemplate="{DataTemplate pages:OrdersHistoryPage}" Route="history" />
</Tab>

На Android это даст TabLayout с двумя вкладками сверху, на iOS, сегментированный контрол. Свойство FlyoutBehavior на корневом Shell управляет видимостью flyout-меню: Flyout (по умолчанию, бургер видно), Locked (всегда раскрыто, типично для iPad и планшетов), Disabled (полностью отключено, используется на экранах онбординга или оплаты).

Для динамического изменения видимости таба используйте Shell.SetTabBarIsVisible(this, false) в code-behind страницы, на которой нужно скрыть нижнюю панель. Например, на полноэкранном просмотре фотографии. Аналогично, Shell.SetNavBarIsVisible убирает верхний AppBar.

Если ваше приложение раньше использовало CollectionView внутри табов, посмотрите наш материал по оптимизации производительности .NET MAUI с NativeAOT. Пересоздание CollectionView при переключении табов остаётся одной из главных причин дёрганий интерфейса в 2026 году.

Главный приз Shell, это встроенная поддержка deep linking. Любой URI вида myapp://product?id=42 или https://example.com/product/42 Shell умеет разобрать сам. На стороне платформы нужно зарегистрировать схему или домен, на стороне приложения настроить обработчик.

На Android в AndroidManifest.xml добавьте intent-filter:

<activity android:name="crc64xxx.MainActivity">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" />
        <data android:scheme="https" android:host="example.com" />
    </intent-filter>
</activity>

На iOS, в Info.plist зарегистрируйте CFBundleURLSchemes для собственной схемы и Associated Domains в Entitlements для universal links. В код приложения добавьте обработчик в App.xaml.cs:

protected override async void OnAppLinkRequestReceived(Uri uri)
{
    base.OnAppLinkRequestReceived(uri);
    // Преобразуем https://example.com/product/42 в продукт/42
    var path = uri.AbsolutePath.TrimStart('/');
    var query = uri.Query;
    await Shell.Current.GoToAsync($"//{path}{query}");
}

Этот подход покрывает 95% сценариев: пуш-нотификации, рекламные ссылки, share extensions, реферальные программы. Подробности по доменам и валидации описаны в Apple Universal Links и Android App Links.

Shell vs NavigationPage: что выбрать в 2026

NavigationPage не удалён из .NET MAUI и продолжает поддерживаться, но Microsoft явно рекомендует Shell для всех новых проектов. Вот сравнительная таблица:

КритерийShellNavigationPage
URL-навигацияДа, через GoToAsyncНет, только PushAsync с instance
Deep linksВстроеноРучная реализация
Flyout-менюДекларативно в XAMLОтдельный FlyoutPage
ТабыTabBar + ShellContentОтдельный TabbedPage
Анимация переходовПлатформенная по умолчанию плюс кастомБазовая push/pop
ПроизводительностьЛучше (ленивая инициализация)Все страницы инициализируются заранее
Подходит для одностраничных приложенийИзбыточноХорошо подходит
Кривая обученияСредняя (нужно понять роутинг)Низкая

Если у вас приложение с авторизацией плюс 3 и более разделами, берите Shell. Если простая утилита с парой экранов, NavigationPage или вообще Application.MainPage = new MyPage() вполне достаточно. Подробное сравнение паттернов навигации можно посмотреть в нашем разборе CI/CD-настройки для .NET MAUI. Там есть кейсы для обоих архитектурных подходов.

Жизненный цикл страниц и события навигации в Shell

Один из самых частых источников багов, это непонимание того, когда именно вызываются OnAppearing, OnDisappearing и новые события OnNavigatedTo/OnNavigatedFrom. В отличие от классического NavigationPage, Shell не пересоздаёт страницу при возврате через GoToAsync(".."). Он показывает уже существующий instance из стека. Это значит, что подписки в OnAppearing могут срабатывать несколько раз без соответствующей отписки в OnDisappearing.

В .NET MAUI 9 рекомендуемая практика, переопределять OnNavigatedTo для подгрузки данных, которые зависят от параметров навигации, а OnAppearing использовать только для UI-эффектов (анимаций, фокуса на TextField). События приходят в таком порядке: NavigatingFrom на текущей странице → создание или выборка следующей → NavigatedFromNavigatedTo на новой → её OnAppearing.

Если вам нужно отменить навигацию (например, предупредить о несохранённых данных), используйте свойство e.Cancel в обработчике Navigating на самом Shell.Current:

// В AppShell.xaml.cs
protected override void OnNavigating(ShellNavigatingEventArgs args)
{
    if (HasUnsavedChanges && args.Source == ShellNavigationSource.Pop)
    {
        args.Cancel();
        _ = ConfirmExitAsync(args);
    }
    base.OnNavigating(args);
}

Такой централизованный подход избавляет от копипасты «вы уверены, что хотите выйти?» в каждой странице и легко тестируется. Подробности по событиям и порядку вызова смотрите в разделе Shell Lifecycle официальной документации Microsoft Learn.

Частые ошибки и подводные камни

За три года работы с Shell я собрал список грабель, на которые наступает почти каждая команда:

  • Утечка ViewModel при PopAsync. Если вы регистрируете ViewModel как Singleton в DI, при возврате старый instance остаётся в памяти со всеми его подписками. Регистрируйте детальные ViewModels как Transient.
  • QueryProperty не срабатывает. Атрибут работает только на ViewModel, который установлен как BindingContext страницы ДО навигации. Если вы выставляете BindingContext в OnAppearing, query-значения теряются. Выставляйте в конструкторе или используйте IQueryAttributable прямо на странице.
  • Анимация GoToAsync ломает Modal. Если внутри детальной страницы открываете DisplayActionSheet в первой же миллисекунде после прихода, на iOS она не покажется. Я обычно добавляю await Task.Delay(300) или жду OnNavigatedTo.
  • Двойной back-button на Android. Если используете кастомный Shell.BackButtonBehavior, не забудьте установить IsVisible="False" для системной кнопки. Иначе нарисуются две стрелки.
  • Глобальный обработчик аппаратной кнопки Back. Переопределите OnBackButtonPressed в AppShell, а не в каждой странице. Это упростит логику подтверждения выхода и обработки несохранённых данных.

Часто задаваемые вопросы

Можно ли использовать Shell без flyout-меню?

Да. Установите FlyoutBehavior="Disabled" на корневом Shell и оставьте только TabBar. Получите приложение исключительно с нижней навигацией, без бургера.

Как откатиться на предыдущую страницу с результатом?

Используйте параметры query при возврате: await Shell.Current.GoToAsync($"..?selectedId={id}"). На предыдущей странице сработает [QueryProperty] или IQueryAttributable, как при прямом переходе.

Почему GoToAsync кидает ArgumentException на неизвестный маршрут?

Маршрут не зарегистрирован. Убедитесь, что вы вызвали Routing.RegisterRoute("ваш_маршрут", typeof(ВашаСтраница)) в конструкторе AppShell. И помните, что регистр имеет значение.

Работает ли Shell на Windows и macOS Catalyst?

Да, начиная с .NET MAUI 8 Shell полноценно поддерживается на всех таргетах. Но flyout-меню на десктопе превращается в боковую панель, а нижние табы в верхние. Учитывайте это в дизайне.

Как тестировать навигацию Shell в юнит-тестах?

Оборачивайте Shell.Current.GoToAsync в собственный INavigationService и инжектьте его через DI. В тестах подменяйте на мок. Это даст возможность проверить, что ViewModel вызвал переход с правильным URI без запуска MAUI-рантайма.

Editorial Team
Об авторе Editorial Team

Our team of expert writers and editors.