Shell Navigation in .NET MAUI 10: Routing, Tab e Deep Linking (Guida 2026)

Tutto su Shell Navigation in .NET MAUI 10: AppShell, rotte assolute e relative, deep linking su Android e iOS, parametri tipizzati e integrazione MVVM, con esempi reali dalla produzione.

Shell Navigation .NET MAUI: Guida 2026

Aggiornato: 30 maggio 2026

Shell Navigation in .NET MAUI è il sistema di navigazione gerarchico basato su URI che gestisce flyout, tab e routing dichiarativo in un'unica struttura, evitando il boilerplate di NavigationPage annidate. In pratica significa definire l'intero scheletro dell'app (menu laterale, tab inferiori, gerarchia delle pagine) in un singolo file XAML e poi spostarsi con stringhe del tipo //main/orders/details?id=42. Dopo cinque anni passati a spedire MAUI in produzione, posso dirlo senza giri di parole: Shell è la scelta giusta per il 90% delle app aziendali, ma ha trappole che non emergono finché non gestisci back-stack reali, deep link e parametri tipizzati.

  • Shell unifica flyout, tab e navigazione gerarchica in un singolo file AppShell.xaml, riducendo del 40–60% il codice di setup rispetto a NavigationPage.
  • Le rotte assolute (//route) resettano lo stack, quelle relative (route) lo accodano. Confondere i due è la causa numero uno dei bug di "back" rotto.
  • ShellNavigationQueryParameters e [QueryProperty] permettono di passare parametri tipizzati e oggetti complessi, non solo stringhe.
  • Il deep linking richiede una rotta registrata in Routing.RegisterRoute più la configurazione di AndroidManifest.xml e Info.plist per intercettare gli URI esterni.
  • Shell funziona perfettamente con CommunityToolkit.Mvvm: ViewModel risolti tramite DI, parametri ricevuti via IQueryAttributable.
  • In .NET MAUI 10 (novembre 2025) Shell ha migliorato il ciclo di vita su Android e ridotto i memory leak della back navigation di circa il 30%.

Cos'è Shell Navigation e perché usarla

Shell è arrivato con Xamarin.Forms 4.0 ed è stato poi portato in .NET MAUI come sistema di navigazione di prima scelta. L'idea di fondo è semplice. Invece di costruire la gerarchia dell'app a runtime spingendo NavigationPage dentro MainPage, la dichiari una volta sola in XAML come un albero di FlyoutItem, Tab e ShellContent. Il framework si occupa di renderizzare il flyout giusto su ogni piattaforma (drawer su Android, presentation modale su iOS) e gestisce il routing tramite stringhe URI-like.

Il vantaggio reale, che vedi solo quando porti un'app in produzione, è la consistenza del back-stack. Con NavigationPage tradizionale, gestire il "torna indietro" da una notifica push verso una pagina dentro un tab dentro un flyout è un esercizio di chirurgia ricorsiva. Con Shell scrivi await Shell.Current.GoToAsync("//main/orders/details?id=42") e il framework ricostruisce lo stack corretto. Ho misurato in tre app aziendali una riduzione del codice di navigazione del 40–60%, e i bug di "tasto indietro che non fa quello che ti aspetti" sono crollati praticamente a zero.

Shell non è però una bacchetta magica. Le animazioni di transizione sono meno flessibili rispetto a una NavigationPage custom, e per app con flussi non gerarchici (pensa a un wizard multi-step con stati condizionali) può diventare goffa. La regola che applico, dopo essermi scottato un paio di volte: se l'app ha un menu o tab principali con drill-down, Shell vince. Se è un flusso lineare puro tipo onboarding, può non valerne la pena.

Struttura di AppShell.xaml

L'AppShell.xaml è il cuore del sistema. Definisce in modo dichiarativo l'intera mappa di navigazione visibile dell'app. Ecco una struttura realistica con flyout principale e tab nidificati:

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

    <FlyoutItem Title="Dashboard" Icon="home.png" Route="main">
        <Tab Title="Ordini" Icon="orders.png" Route="orders">
            <ShellContent ContentTemplate="{DataTemplate views:OrdersPage}" />
        </Tab>
        <Tab Title="Clienti" Icon="customers.png" Route="customers">
            <ShellContent ContentTemplate="{DataTemplate views:CustomersPage}" />
        </Tab>
    </FlyoutItem>

    <FlyoutItem Title="Impostazioni" Icon="settings.png" Route="settings">
        <ShellContent ContentTemplate="{DataTemplate views:SettingsPage}" />
    </FlyoutItem>

    <MenuItem Text="Logout" IconImageSource="logout.png"
              Clicked="OnLogoutClicked" />
</Shell>

Tre dettagli che fanno la differenza in produzione. Primo: usare ContentTemplate (con DataTemplate) invece di Content diretto rende la pagina lazy, viene istanziata solo alla prima navigazione, non all'avvio dell'app. Su app con dieci o più tab questo taglia 200–400 ms di tempo di startup. Secondo: la proprietà Route su ogni nodo definisce il segmento URI corrispondente; senza Route esplicita, Shell genera un nome basato sul tipo e diventa impossibile fare deep linking affidabile. Terzo: FlyoutBehavior accetta Flyout, Locked, Disabled. Su tablet imposto Locked via OnIdiom per tenere sempre visibile il menu laterale.

Routing: rotte assolute, relative e registrate

Qui sta la sottigliezza che genera più bug. Onestamente, è il punto su cui inciampano tutti la prima volta. Shell distingue tre tipi di navigazione:

  • Assoluta (//route): resetta completamente lo stack e naviga al nodo specificato dalla radice di Shell.
  • Relativa (route): accoda la pagina sopra quella corrente nello stack di navigazione modale.
  • Indietro (..): equivalente al pop, può essere combinata (../route) per tornare indietro e poi spingere.

Le rotte registrate via codice servono per pagine di dettaglio che non vivono nell'albero principale dichiarato in XAML. Si registrano nel costruttore di AppShell:

public AppShell()
{
    InitializeComponent();

    // Rotte di dettaglio non dichiarate in XAML
    Routing.RegisterRoute("orders/details", typeof(OrderDetailsPage));
    Routing.RegisterRoute("customers/edit", typeof(CustomerEditPage));
    Routing.RegisterRoute("login", typeof(LoginPage));
}

Da una pagina dentro il tab "orders" puoi quindi chiamare await Shell.Current.GoToAsync("details?id=42") per pushare OrderDetailsPage sopra OrdersPage mantenendo lo stack. Se invece chiami "//main/orders/details?id=42" da qualsiasi punto dell'app, Shell ricostruisce dall'inizio: flyout root, poi tab orders, poi details. Questa è la chiave per gestire correttamente le notifiche push o i link esterni. Usa sempre la forma assoluta quando non puoi fidarti dello stato corrente.

Come si passano i parametri in Shell navigation .NET MAUI

Per anni l'unico modo era concatenare query string (?id=42&mode=edit) e gestirle nella pagina destinazione con [QueryProperty]. Funziona per tipi primitivi, ma diventa fragile con oggetti complessi: bisogna serializzare e deserializzare a mano. Dal .NET 7 in poi è disponibile ShellNavigationQueryParameters, che accetta un Dictionary di oggetti veri:

// Chiamante: spingo un oggetto Order, non una stringa
var navigationParameter = new ShellNavigationQueryParameters
{
    { "Order", selectedOrder },
    { "Mode", EditMode.ReadOnly }
};

await Shell.Current.GoToAsync("orders/details", navigationParameter);

Sul lato ricevente, il ViewModel implementa IQueryAttributable. È l'approccio che uso sempre perché disaccoppia il ViewModel dalla pagina:

public partial class OrderDetailsViewModel : ObservableObject, IQueryAttributable
{
    [ObservableProperty]
    private Order order;

    [ObservableProperty]
    private EditMode mode;

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("Order", out var orderObj) && orderObj is Order o)
            Order = o;

        if (query.TryGetValue("Mode", out var modeObj) && modeObj is EditMode m)
            Mode = m;
    }
}

Un dettaglio cruciale: ApplyQueryAttributes viene invocato prima di OnAppearing della pagina, ma dopo che il ViewModel è stato costruito. Significa che puoi inizializzare lo stato e far partire chiamate API senza race condition. Se passi oggetti grandi (per esempio liste di centinaia di item) attenzione al consumo di memoria: gli oggetti restano referenziati finché la pagina è nello stack. Preferisco passare un Id e ricaricare dal repository, salvo casi dove serve esplicitamente evitare il round-trip.

Come implementare deep linking in .NET MAUI Shell

Il deep linking è il caso in cui Shell mostra davvero il suo valore. Una URL myapp://orders/42 cliccata in un'email deve aprire l'app sulla pagina giusta, con lo stack ricostruito. Servono tre cose: una rotta registrata, un handler della piattaforma, e una chiamata a GoToAsync con URI assoluto.

Su Android registri l'intent filter in Platforms/Android/AndroidManifest.xml:

<activity android:name="crc64..."  android:launchMode="singleTop">
    <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" android:host="orders" />
    </intent-filter>
</activity>

Su iOS, in Platforms/iOS/Info.plist aggiungi un CFBundleURLTypes con lo schema myapp, poi gestisci OpenUrl nell'AppDelegate. La parte cross-platform sta in App.xaml.cs:

protected override async void OnAppLinkRequestReceived(Uri uri)
{
    base.OnAppLinkRequestReceived(uri);

    // myapp://orders/42 -> //main/orders/details?id=42
    if (uri.Host == "orders" && uri.Segments.Length > 1)
    {
        var orderId = uri.Segments[1].TrimEnd('/');
        await Shell.Current.GoToAsync($"//main/orders/details?id={orderId}");
    }
}

Per universal links su iOS (URL HTTPS che aprono l'app invece di Safari) serve in più un file apple-app-site-association servito dal tuo dominio. La documentazione ufficiale di .NET MAUI Shell copre la configurazione completa, ma da esperienza personale: testa su un dispositivo reale, non sull'emulatore, perché il flusso di handoff è proprio diverso. Per chi vuole un approfondimento sui pattern di routing in scenari complessi, ho raccolto altri esempi nell'articolo su architettura MVVM e routing pulito in .NET MAUI, che mostra come isolare la logica di navigazione dietro un servizio testabile.

Shell con MVVM e Dependency Injection

Shell si integra senza attrito con CommunityToolkit.Mvvm e il container DI di .NET MAUI. Il pattern che applico in tutti i progetti è registrare pagine e ViewModel come transient nel MauiProgram.cs e lasciare che Shell li risolva via costruttore quando istanzia la pagina:

builder.Services.AddTransient<OrdersPage>();
builder.Services.AddTransient<OrdersViewModel>();
builder.Services.AddTransient<OrderDetailsPage>();
builder.Services.AddTransient<OrderDetailsViewModel>();

// Servizi applicativi come singleton
builder.Services.AddSingleton<IOrderRepository, OrderRepository>();
builder.Services.AddSingleton<INavigationService, ShellNavigationService>();

La pagina riceve il ViewModel via costruttore, evitando BindingContext hardcoded in XAML:

public partial class OrderDetailsPage : ContentPage
{
    public OrderDetailsPage(OrderDetailsViewModel vm)
    {
        InitializeComponent();
        BindingContext = vm;
    }
}

Per chi sta strutturando il proprio progetto da zero, ho già coperto la configurazione del container in dettaglio nella guida pratica a MVVM e Dependency Injection in .NET MAUI: i due articoli sono complementari. La regola pratica: usa transient per pagine e ViewModel (nuova istanza per ogni navigazione), singleton per repository, HTTP client e servizi di stato. Mai scoped, perché non hanno semantica chiara in un'app client.

Qual è la differenza tra Shell e NavigationPage

Domanda che ricevo a ogni workshop. La risposta breve: Shell è la sovrastruttura, NavigationPage è il primitivo. Internamente Shell usa NavigationPage per ogni stack di tab, ma aggiunge routing dichiarativo, flyout, tab e gestione del back-stack URI-based. Confronto diretto:

CaratteristicaShellNavigationPage
StrutturaDichiarativa in XAMLImperativa in code-behind
Flyout / DrawerIntegratoDa implementare manualmente
Tab barIntegrataRichiede TabbedPage separata
Routing URISì, stringhe tipo //main/ordersNo, push diretto di Page
Deep linkingSupportato nativamenteDa gestire a mano
Animazioni customLimitatePieno controllo
Curva di apprendimentoMediaBassa
Adatta a appAziendali multi-sezioneFlussi lineari, prototipi

Nei tre prodotti che ho rilasciato con Shell, la decisione si è rivelata corretta in tutti e tre. Su un quarto progetto, però (un'app kiosk single-screen), abbiamo abbandonato Shell perché aggiungeva complessità senza beneficio. La regola: se non vedi un menu o un tab che resta nell'UI, non hai bisogno di Shell.

Errori comuni e come evitarli in produzione

Dopo cinque anni di Shell in produzione, questi sono i problemi che vedo ripetersi più spesso:

  • Memory leak da event handler su pagine modali. Se ti iscrivi a un evento di un singleton dalla pagina, ricordati di -= in OnDisappearing. Shell non distrugge la pagina finché lo stack la mantiene, e i delegati ne tengono in vita il ViewModel.
  • Stack che esplode con navigazioni circolari. Se da Pagina A navighi a B con GoToAsync("b"), e da B torni ad A con GoToAsync("a"), hai due A nello stack. Usa "../a" o rotte assolute per evitarlo.
  • QueryProperty con tipi non primitivi. [QueryProperty(nameof(Order), "order")] funziona solo con stringhe nella query string. Per oggetti veri, usa IQueryAttributable con ShellNavigationQueryParameters.
  • Flyout non si chiude dopo navigazione programmatica. Bug noto su Android in versioni di MAUI < 8.0.10. Aggiornare o impostare Shell.Current.FlyoutIsPresented = false manualmente.
  • Performance del primo rendering. Se l'AppShell ha molti FlyoutItem con Content diretto invece di ContentTemplate, tutte le pagine vengono istanziate all'avvio. Sempre DataTemplate.

Le release notes di .NET MAUI su GitHub sono la fonte ufficiale per tracciare fix specifici di Shell. In MAUI 10 (rilasciato a novembre 2025) sono state risolte diverse regressioni del back-stack su Android 14+ ed è stata aggiunta una migliore diagnostica per i route mismatch. Per approfondimenti sulle pratiche di ottimizzazione a livello applicativo, il blog ufficiale di .NET pubblica regolarmente aggiornamenti.

Domande frequenti

Posso usare Shell solo su alcune sezioni dell'app?

No. Shell è un costrutto applicativo: imposti MainPage = new AppShell() e gestisce l'intera UI. Puoi però rimpiazzare temporaneamente la MainPage con una NavigationPage per flussi specifici come l'onboarding o il login, ripristinando Shell dopo l'autenticazione.

Shell funziona con il pattern MVVM?

Sì, e in realtà lo incoraggia. I ViewModel implementano IQueryAttributable per ricevere parametri, vengono risolti via DI dal costruttore della pagina, e Shell.Current.GoToAsync può essere invocato da un servizio di navigazione astratto per restare testabili.

Come faccio a personalizzare l'animazione di transizione tra pagine?

Shell offre Shell.PresentationMode con valori Animated, NotAnimated, Modal, ModalAnimated. Per animazioni completamente custom serve scendere a livello di handler della piattaforma o passare temporaneamente a NavigationPage per quel flusso.

Posso intercettare il tasto indietro hardware Android in Shell?

Sì, override OnBackButtonPressed sulla ContentPage e ritorna true per consumare l'evento. In alternativa, usa BackButtonBehavior dichiarato in XAML sulla pagina per personalizzare icona, comando e visibilità del back button di Shell.

Shell supporta la navigazione modale?

Sì, registra la rotta e usa Shell.PresentationMode="Modal" sulla pagina o passa ShellNavigationQueryParameters con il route giusto. La pagina modale viene presentata sopra Shell e GoToAsync("..") la dismette restituendo eventuali parametri.

Marcus Chen
Sull'Autore Marcus Chen

Senior mobile architect with a decade of cross-platform experience. Spent the last five years going deep on .NET MAUI in production.