Perché la Navigazione è il Cuore di Ogni App Mobile
Avete messo su un'architettura MVVM impeccabile, i vostri ViewModel sono puliti e testabili, la Dependency Injection funziona come un orologio svizzero. Eppure c'è un aspetto che, se gestito male, può far crollare l'intera esperienza utente: la navigazione.
Non è un dettaglio. È il tessuto connettivo dell'intera app — quel meccanismo silenzioso che l'utente dà per scontato finché qualcosa non funziona.
.NET MAUI mette a disposizione un sistema di navigazione potente e flessibile attraverso Shell, un componente che funge da contenitore principale dell'applicazione e gestisce flyout menu, tab, routing URI e molto altro. Se avete letto le nostre guide precedenti su .NET MAUI 10 e su MVVM e Dependency Injection, considerate questo articolo come il terzo pilastro: dopo il motore (prestazioni) e il telaio (architettura), oggi ci occupiamo del sistema di navigazione.
In questa guida esploreremo Shell dalla configurazione iniziale fino ai pattern avanzati: routing, passaggio di parametri, pagine modali, eventi del ciclo di vita, deep linking e il pattern NavigationService per mantenere tutto testabile. Codice reale, pattern concreti, zero teoria astratta. Andiamo.
Shell: L'Hub di Navigazione di .NET MAUI
Prima di tuffarci nel codice, chiariamo cosa fa esattamente Shell e perché dovreste usarlo. Shell è un contenitore UI che fornisce funzionalità fondamentali per la maggior parte delle app mobile:
- Flyout menu: il classico menu laterale "hamburger", completamente personalizzabile
- Tab bar: navigazione a tab in basso o in alto
- Routing basato su URI: navigazione programmatica tramite stringhe di route, simile a un router web
- Barra di ricerca integrata: funzionalità di ricerca senza dover costruire tutto da zero
- Struttura visiva centralizzata: l'intera gerarchia di navigazione è definita in un unico posto
L'alternativa sarebbe usare direttamente NavigationPage, TabbedPage o FlyoutPage, ma onestamente Shell combina il meglio di tutti questi componenti in un'unica API coerente. Per la stragrande maggioranza dei progetti, Shell è la scelta giusta — e il team .NET lo raccomanda esplicitamente.
Struttura di Base: AppShell.xaml
Ogni app .NET MAUI che usa Shell ha un file AppShell.xaml che definisce la struttura di navigazione. Vediamo un esempio concreto:
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:MiaApp.Views"
x:Class="MiaApp.AppShell"
FlyoutBehavior="Flyout">
<!-- Voce del flyout con tab interni -->
<FlyoutItem Title="Home" Icon="home.png">
<ShellContent Title="Dashboard"
ContentTemplate="{DataTemplate views:DashboardPage}"
Route="dashboard" />
</FlyoutItem>
<FlyoutItem Title="Prodotti" Icon="products.png">
<Tab Title="Catalogo">
<ShellContent Title="Lista"
ContentTemplate="{DataTemplate views:ListaProdottiPage}"
Route="listaProdotti" />
<ShellContent Title="Categorie"
ContentTemplate="{DataTemplate views:CategoriePage}"
Route="categorie" />
</Tab>
</FlyoutItem>
<FlyoutItem Title="Profilo" Icon="profile.png">
<ShellContent Title="Il Mio Profilo"
ContentTemplate="{DataTemplate views:ProfiloPage}"
Route="profilo" />
</FlyoutItem>
<!-- Elemento del menu senza pagina associata -->
<MenuItem Text="Esci"
IconImageSource="logout.png"
Clicked="OnEsciClicked" />
</Shell>
Notate alcune cose importanti:
- Ogni
ShellContentha una proprietàRoute— questa è la chiave per la navigazione programmatica ContentTemplateusaDataTemplateper il caricamento lazy delle pagine: la pagina viene creata solo quando l'utente ci naviga sopraFlyoutBehaviorpuò essereFlyout(menu laterale),Locked(sempre visibile, utile su tablet) oDisabled(nessun flyout)MenuItemè diverso daFlyoutItem: esegue un'azione invece di navigare a una pagina
Routing: Il Sistema Nervoso della Navigazione
Il routing in Shell funziona su due livelli: le route implicite definite nella gerarchia visiva di Shell e le route globali registrate programmaticamente. Capire la differenza è fondamentale — e vi risparmierà un bel po' di debugging.
Route Implicite (dalla Gerarchia Shell)
Ogni elemento nella gerarchia di Shell — FlyoutItem, Tab, ShellContent — genera automaticamente una route. Se avete definito la struttura dell'esempio precedente, le route risultanti saranno qualcosa come:
// Route assolute generate dalla gerarchia
//dashboard
//listaProdotti
//categorie
//profilo
Potete navigare a qualsiasi di queste usando la sintassi con doppio slash per indicare una route assoluta:
await Shell.Current.GoToAsync("//dashboard");
Route Globali (Registrate Programmaticamente)
Le pagine di dettaglio — quelle che non appaiono nel flyout o nei tab, ma a cui si arriva navigando da altre pagine — devono essere registrate come route globali. Questo si fa tipicamente nel costruttore di AppShell:
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
// Registrazione delle route globali
Routing.RegisterRoute("dettaglioProdotto", typeof(DettaglioProdottoPage));
Routing.RegisterRoute("modificaProdotto", typeof(ModificaProdottoPage));
Routing.RegisterRoute("nuovoOrdine", typeof(NuovoOrdinePage));
Routing.RegisterRoute("confermaOrdine", typeof(ConfermaOrdinePage));
}
}
Attenzione: le route globali non possono duplicare nomi di route già presenti nella gerarchia visiva. Se provate a registrare una route con un nome già esistente, otterrete un ArgumentException all'avvio dell'app. Meglio scoprirlo in fase di sviluppo che in produzione, fidatevi.
Navigazione Relativa vs Assoluta
La distinzione è importante e influenza il comportamento dello stack di navigazione:
// Navigazione ASSOLUTA: resetta lo stack e va direttamente alla route
await Shell.Current.GoToAsync("//dashboard");
// Navigazione RELATIVA: aggiunge la pagina allo stack corrente
await Shell.Current.GoToAsync("dettaglioProdotto");
// Navigazione INDIETRO: torna alla pagina precedente
await Shell.Current.GoToAsync("..");
// Navigazione INDIETRO multipla + avanti
await Shell.Current.GoToAsync("../../nuovoOrdine");
La navigazione assoluta (con //) è l'equivalente di dire "portami qui, indipendentemente da dove mi trovo ora". Quella relativa aggiunge la pagina allo stack esistente, preservando la possibilità di tornare indietro. La sintassi .. permette di risalire lo stack — esattamente come navigare le directory in un file system. Se avete dimestichezza col terminale, vi sentirete subito a casa.
Passaggio dei Parametri: Tre Approcci a Confronto
Navigare a una pagina è solo metà del lavoro. Nella vita reale, quasi sempre serve passare dei dati: l'ID di un prodotto, un oggetto complesso, dei filtri di ricerca. Shell offre tre approcci distinti, e vale la pena conoscerli tutti.
1. Query String: Semplice e Diretto
Per valori primitivi — stringhe, numeri, booleani — la query string è l'approccio più immediato:
// Navigazione con parametro singolo
await Shell.Current.GoToAsync($"dettaglioProdotto?id={prodotto.Id}");
// Navigazione con parametri multipli
await Shell.Current.GoToAsync(
$"dettaglioProdotto?id={prodotto.Id}&categoria={prodotto.Categoria}");
I valori vengono automaticamente codificati nell'URI, quindi non preoccupatevi di caratteri speciali — Shell se ne occupa per voi.
2. Dictionary: Per Oggetti Complessi
Quando dovete passare oggetti interi — un modello completo, una lista, qualcosa che non si riduce facilmente a una stringa — usate il dizionario:
var parametri = new Dictionary<string, object>
{
{ "prodotto", prodottoSelezionato },
{ "modalitaModifica", true },
{ "categorieDisponibili", listaCategorie }
};
await Shell.Current.GoToAsync("modificaProdotto", parametri);
Questo approccio è potente perché permette di passare qualsiasi tipo di oggetto, non solo stringhe. Ma (e c'è sempre un ma) gli oggetti passati vengono mantenuti in memoria per tutta la vita della pagina di destinazione. Se passate oggetti molto pesanti, tenete d'occhio il consumo di memoria.
3. IQueryAttributable: L'Approccio Raccomandato
Ecco il punto cruciale: il vecchio attributo [QueryProperty] non è compatibile con il trimming e NativeAOT. Se avete letto la nostra guida su .NET MAUI 10 e le ottimizzazioni NativeAOT, sapete quanto questo sia importante. L'approccio ufficialmente raccomandato è implementare l'interfaccia IQueryAttributable:
public class DettaglioProdottoViewModel : ObservableObject, IQueryAttributable
{
[ObservableProperty]
private string _nomeProdotto = string.Empty;
[ObservableProperty]
private decimal _prezzo;
[ObservableProperty]
private Prodotto? _prodotto;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
// Parametri da query string (arrivano come stringhe)
if (query.TryGetValue("id", out var idObj) && idObj is string idStr)
{
// Carica il prodotto dal database usando l'ID
_ = CaricaProdottoAsync(int.Parse(idStr));
}
// Parametri da Dictionary (arrivano come oggetti tipizzati)
if (query.TryGetValue("prodotto", out var prodObj) && prodObj is Prodotto p)
{
Prodotto = p;
NomeProdotto = p.Nome;
Prezzo = p.Prezzo;
}
}
private async Task CaricaProdottoAsync(int id)
{
// Logica di caricamento dal servizio
}
}
IQueryAttributable funziona sia sulle pagine che sui ViewModel. Se il BindingContext della pagina implementa l'interfaccia, Shell chiamerà ApplyQueryAttributes automaticamente anche sul ViewModel — una comodità non da poco. E c'è un altro vantaggio: il metodo viene chiamato anche durante la navigazione all'indietro, permettendovi di gestire il ritorno di dati dalla pagina successiva.
Passare Parametri nella Navigazione all'Indietro
Un caso d'uso comune (e che prima o poi vi capiterà): l'utente naviga a una pagina di selezione, sceglie un valore e torna alla pagina precedente con il risultato. Shell supporta nativamente questo scenario:
// Nella pagina di selezione, quando l'utente conferma la scelta
await Shell.Current.GoToAsync($"..?risultato={valoreSelezionato}");
// Oppure con oggetti complessi
var parametriRitorno = new Dictionary<string, object>
{
{ "elementoSelezionato", elementoScelto }
};
await Shell.Current.GoToAsync("..", parametriRitorno);
La pagina precedente riceverà i parametri attraverso ApplyQueryAttributes, esattamente come se fosse una navigazione in avanti. Elegante, no?
Navigazione Modale: Quando Serve Attenzione Totale
Non tutte le schermate sono uguali. Alcune richiedono l'attenzione completa dell'utente: un form di conferma, una schermata di login, un processo di checkout. Per queste situazioni, la navigazione modale è la scelta giusta.
In Shell, si configura la modalità di presentazione direttamente nella pagina XAML:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MiaApp.Views.ConfermaOrdinePage"
Shell.PresentationMode="ModalAnimated">
<Shell.BackButtonBehavior>
<BackButtonBehavior IsVisible="False" />
</Shell.BackButtonBehavior>
<VerticalStackLayout Padding="20" Spacing="15">
<Label Text="Conferma il tuo ordine"
FontSize="24"
FontAttributes="Bold" />
<Label Text="{Binding RiepilogoOrdine}" />
<Button Text="Conferma"
Command="{Binding ConfermaCommand}" />
<Button Text="Annulla"
Command="{Binding AnnullaCommand}"
BackgroundColor="Transparent"
TextColor="Gray" />
</VerticalStackLayout>
</ContentPage>
Le opzioni di PresentationMode disponibili sono:
- NotAnimated: navigazione standard senza animazione
- Animated: navigazione standard con animazione (il default)
- Modal: modale senza animazione
- ModalAnimated: modale con animazione
- ModalNotAnimated: identico a Modal, alias esplicito
Per chiudere una pagina modale si naviga semplicemente all'indietro:
// Chiudi la modale
await Shell.Current.GoToAsync("..");
// Chiudi la modale e passa risultati
await Shell.Current.GoToAsync("..", new Dictionary<string, object>
{
{ "ordineConfermato", true },
{ "idOrdine", nuovoOrdine.Id }
});
Eventi del Ciclo di Vita della Navigazione
Sapere quando si naviga è altrettanto importante del sapere dove si naviga. Sembra una cosa scontata, ma ve lo assicuro: gestire male il ciclo di vita è una delle fonti più comuni di bug subdoli. Shell espone diversi eventi che vi permettono di reagire ai cambiamenti di navigazione.
Eventi a Livello di Shell
La classe Shell espone due eventi globali:
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
// Evento PRIMA della navigazione
Navigating += OnNavigating;
// Evento DOPO la navigazione
Navigated += OnNavigated;
}
private void OnNavigating(object? sender, ShellNavigatingEventArgs e)
{
// Potete annullare la navigazione!
if (UtenteHaModificheNonSalvate && e.Source == ShellNavigationSource.Pop)
{
e.Cancel();
// Mostra un dialog di conferma...
}
// Informazioni disponibili:
// e.Current - route corrente
// e.Target - route di destinazione
// e.Source - origine della navigazione (Push, Pop, ShellItemChanged, etc.)
}
private void OnNavigated(object? sender, ShellNavigatedEventArgs e)
{
// La navigazione è completata
// Utile per analytics, logging, aggiornamento UI
System.Diagnostics.Debug.WriteLine(
$"Navigato da {e.Previous} a {e.Current}");
}
}
L'evento Navigating è particolarmente prezioso: potete annullare la navigazione chiamando e.Cancel(). Questo è il modo corretto per implementare guardie di navigazione — ad esempio, impedire all'utente di lasciare un form con modifiche non salvate. Un dettaglio che fa tutta la differenza tra un'app professionale e una amatoriale.
Eventi a Livello di Pagina
Ogni pagina ha i propri eventi del ciclo di vita:
public partial class DashboardPage : ContentPage
{
private readonly DashboardViewModel _viewModel;
public DashboardPage(DashboardViewModel viewModel)
{
InitializeComponent();
BindingContext = _viewModel = viewModel;
}
protected override void OnAppearing()
{
base.OnAppearing();
// La pagina sta per apparire
// Ideale per ricaricare dati, avviare animazioni,
// riprendere operazioni in background
_viewModel.CaricaDatiCommand.Execute(null);
}
protected override void OnDisappearing()
{
base.OnDisappearing();
// La pagina sta per scomparire
// Ideale per salvare stato, fermare timer,
// mettere in pausa operazioni costose
_viewModel.SalvaStatoCommand.Execute(null);
}
}
Un punto che vale la pena sottolineare: OnAppearing viene chiamato ogni volta che la pagina appare, non solo alla prima creazione. Se l'utente naviga avanti e poi torna indietro, OnAppearing viene invocato di nuovo. Questo è il punto giusto per ricaricare dati che potrebbero essere cambiati nel frattempo — e, nella mia esperienza, dimenticarsene è la causa di molti "ma i dati non si aggiornano!" in fase di testing.
Il Pattern NavigationService: Navigazione Testabile
Se avete letto la nostra guida su MVVM e Dependency Injection, sapete che i ViewModel non dovrebbero avere dipendenze dirette da componenti del framework. Eppure, Shell.Current.GoToAsync() è esattamente una dipendenza di framework. Come si risolve?
La risposta è il pattern NavigationService: un'astrazione che nasconde i dettagli di Shell dietro un'interfaccia iniettabile e mockabile. Non è nulla di rivoluzionario, ma funziona alla grande.
Definire l'Interfaccia
public interface INavigationService
{
Task NavigateToAsync(string route);
Task NavigateToAsync(string route, IDictionary<string, object> parameters);
Task GoBackAsync();
Task GoBackAsync(IDictionary<string, object> parameters);
Task GoToRootAsync();
}
Implementazione Concreta
public class ShellNavigationService : INavigationService
{
public async Task NavigateToAsync(string route)
{
await Shell.Current.GoToAsync(route);
}
public async Task NavigateToAsync(string route, IDictionary<string, object> parameters)
{
await Shell.Current.GoToAsync(route, parameters);
}
public async Task GoBackAsync()
{
await Shell.Current.GoToAsync("..");
}
public async Task GoBackAsync(IDictionary<string, object> parameters)
{
await Shell.Current.GoToAsync("..", parameters);
}
public async Task GoToRootAsync()
{
await Shell.Current.GoToAsync("//dashboard");
}
}
Registrazione e Utilizzo
// In MauiProgram.cs
builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
// Nel ViewModel
public partial class ListaProdottiViewModel : ObservableObject
{
private readonly INavigationService _navigationService;
private readonly IProdottoService _prodottoService;
public ListaProdottiViewModel(
INavigationService navigationService,
IProdottoService prodottoService)
{
_navigationService = navigationService;
_prodottoService = prodottoService;
}
[RelayCommand]
private async Task ApriDettaglio(Prodotto prodotto)
{
var parametri = new Dictionary<string, object>
{
{ "prodotto", prodotto }
};
await _navigationService.NavigateToAsync("dettaglioProdotto", parametri);
}
[RelayCommand]
private async Task TornaIndietro()
{
await _navigationService.GoBackAsync();
}
}
Testing con Mock
Ora i vostri ViewModel sono completamente testabili. Con un framework di mocking come NSubstitute:
[Fact]
public async Task ApriDettaglio_NavigaAllaRouteCorretta()
{
// Arrange
var mockNavigation = Substitute.For<INavigationService>();
var mockProdottoService = Substitute.For<IProdottoService>();
var viewModel = new ListaProdottiViewModel(mockNavigation, mockProdottoService);
var prodottoTest = new Prodotto { Id = 42, Nome = "Widget" };
// Act
await viewModel.ApriDettaglioCommand.ExecuteAsync(prodottoTest);
// Assert
await mockNavigation.Received(1).NavigateToAsync(
"dettaglioProdotto",
Arg.Is<IDictionary<string, object>>(d =>
d.ContainsKey("prodotto") && d["prodotto"] == prodottoTest));
}
[Fact]
public async Task TornaIndietro_ChiamaGoBack()
{
// Arrange
var mockNavigation = Substitute.For<INavigationService>();
var mockProdottoService = Substitute.For<IProdottoService>();
var viewModel = new ListaProdottiViewModel(mockNavigation, mockProdottoService);
// Act
await viewModel.TornaIndietroCommand.ExecuteAsync(null);
// Assert
await mockNavigation.Received(1).GoBackAsync();
}
Nessun simulatore, nessun emulatore, nessuna dipendenza da Shell — test puri e veloci. Questo è il potere di una buona astrazione.
Deep Linking: Portare l'Utente Direttamente al Contenuto
Il deep linking permette di aprire l'app direttamente su una schermata specifica a partire da un URL esterno — un link in un'email, una notifica push, un QR code. È una di quelle funzionalità che sembra un "nice to have" finché non la implementate, e poi vi chiedete come facevate senza. In molti casi, è anche un requisito obbligatorio per la pubblicazione sugli store.
Configurazione Android: App Links
Su Android, il deep linking si implementa attraverso gli Android App Links (API 23+). Per prima cosa, configurate l'intent filter nel file AndroidManifest.xml o tramite attributi:
// Platforms/Android/MainActivity.cs
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true,
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)]
[IntentFilter(new[] { Intent.ActionView },
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "https",
DataHost = "www.miaapp.com",
DataPathPrefix = "/prodotto",
AutoVerify = true)]
public class MainActivity : MauiAppCompatActivity
{
}
L'attributo AutoVerify = true è fondamentale: dice ad Android di verificare che il vostro dominio confermi l'associazione con l'app attraverso un file assetlinks.json ospitato sul vostro server web. Non saltatelo, o i link non funzioneranno come vi aspettate.
Configurazione iOS: Universal Links
Su iOS, si usano gli Universal Links. La configurazione richiede due passaggi:
1. Aggiungere il dominio associato nel file Entitlements.plist:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:www.miaapp.com</string>
</array>
2. Ospitare un file apple-app-site-association sul vostro dominio:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.miaapp.mobile",
"paths": ["/prodotto/*", "/ordine/*"]
}
]
}
}
Gestire i Link nell'App
Una volta configurate le piattaforme, dovete gestire i link in arrivo nell'app. Il punto di ingresso è il metodo OnAppLinkRequestReceived nella classe App:
public partial class App : Application
{
public App()
{
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
}
protected override async void OnAppLinkRequestReceived(Uri uri)
{
base.OnAppLinkRequestReceived(uri);
// Validazione dell'URI — mai fidarsi ciecamente dell'input esterno!
if (uri.Host != "www.miaapp.com")
return;
var percorso = uri.AbsolutePath.TrimStart('/');
var segmenti = percorso.Split('/');
switch (segmenti.FirstOrDefault())
{
case "prodotto" when segmenti.Length > 1:
var idProdotto = segmenti[1];
await Shell.Current.GoToAsync(
$"dettaglioProdotto?id={Uri.EscapeDataString(idProdotto)}");
break;
case "ordine" when segmenti.Length > 1:
var idOrdine = segmenti[1];
await Shell.Current.GoToAsync(
$"//ordini/dettaglioOrdine?id={Uri.EscapeDataString(idOrdine)}");
break;
default:
// Fallback: vai alla home
await Shell.Current.GoToAsync("//dashboard");
break;
}
}
}
Nota fondamentale sulla sicurezza: validate sempre gli URI in ingresso. Il deep linking è un potenziale vettore di attacco — URL malformati o con parametri malevoli possono causare crash o comportamenti inattesi. Usate Uri.EscapeDataString per i parametri e verificate che l'host e il percorso siano quelli attesi. Può sembrare paranoia, ma credetemi, in produzione vi ringrazierete.
Pattern Avanzati di Navigazione
Navigazione Condizionale con Guardie
Un pattern che mi capita di implementare in quasi tutti i progetti: impedire l'accesso a determinate pagine in base allo stato dell'app (autenticazione, onboarding completato, etc.). Ecco come fare sfruttando l'evento Navigating di Shell:
public partial class AppShell : Shell
{
private readonly IAuthService _authService;
public AppShell(IAuthService authService)
{
InitializeComponent();
_authService = authService;
Navigating += OnNavigating;
}
private async void OnNavigating(object? sender, ShellNavigatingEventArgs e)
{
// Route che richiedono autenticazione
var routeProtette = new[] { "profilo", "ordini", "impostazioni" };
var routeDestinazione = e.Target.Location.OriginalString
.TrimStart('/').Split('/').LastOrDefault();
if (routeProtette.Contains(routeDestinazione) &&
!_authService.IsAuthenticated)
{
// Annulla la navigazione corrente
e.Cancel();
// Naviga alla pagina di login, passando la route originale
// per poter riprendere dopo il login
await Current.GoToAsync($"login?returnRoute={e.Target.Location}");
}
}
}
Navigazione con Costanti di Route
Per progetti di dimensioni medie o grandi, è buona pratica centralizzare le route in costanti. Le stringhe magiche sparse per il codice sono una ricetta per il disastro:
public static class Routes
{
public const string Dashboard = "dashboard";
public const string ListaProdotti = "listaProdotti";
public const string DettaglioProdotto = "dettaglioProdotto";
public const string ModificaProdotto = "modificaProdotto";
public const string NuovoOrdine = "nuovoOrdine";
public const string ConfermaOrdine = "confermaOrdine";
public const string Login = "login";
public const string Profilo = "profilo";
}
// Utilizzo
await Shell.Current.GoToAsync(Routes.DettaglioProdotto);
await _navigationService.NavigateToAsync(Routes.ModificaProdotto, parametri);
Se rinominate una route, il compilatore vi segnalerà tutti i punti da aggiornare. Con le stringhe sparse nel codice, avreste scoperto il problema solo a runtime — e probabilmente direttamente in produzione, il venerdì sera.
Personalizzare la Back Button Behavior
Shell permette di personalizzare il comportamento del pulsante indietro per singola pagina. Utile quando volete aggiungere conferme, cambiare l'icona o gestire la navigazione in modo personalizzato:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MiaApp.Views.ModificaProdottoPage">
<Shell.BackButtonBehavior>
<BackButtonBehavior
IconOverride="freccia_indietro.png"
TextOverride="Annulla"
Command="{Binding AnnullaModificaCommand}" />
</Shell.BackButtonBehavior>
<!-- Contenuto della pagina -->
</ContentPage>
Attenzione però: il Command nel BackButtonBehavior sostituisce completamente il comportamento standard del pulsante indietro. Questo significa che dovete gestire esplicitamente la navigazione all'indietro nel vostro comando — Shell non lo farà automaticamente per voi.
Flyout Personalizzato: Oltre il Menu Standard
Il flyout di Shell è altamente personalizzabile. Potete modificare header, footer, aspetto degli elementi e persino aggiungere contenuti personalizzati. Ecco un esempio più realistico di quello che vedrete tipicamente in un'app di produzione:
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MiaApp.AppShell"
FlyoutBehavior="Flyout"
FlyoutWidth="300"
FlyoutBackgroundColor="{StaticResource SfondoMenu}">
<Shell.FlyoutHeader>
<Grid HeightRequest="200" Padding="20"
BackgroundColor="{StaticResource PrimarioScuro}">
<VerticalStackLayout VerticalOptions="End">
<Image Source="{Binding AvatarUrl}"
HeightRequest="80" WidthRequest="80"
Aspect="AspectFill">
<Image.Clip>
<EllipseGeometry Center="40,40"
RadiusX="40" RadiusY="40" />
</Image.Clip>
</Image>
<Label Text="{Binding NomeUtente}"
TextColor="White" FontSize="18" />
<Label Text="{Binding Email}"
TextColor="LightGray" FontSize="14" />
</VerticalStackLayout>
</Grid>
</Shell.FlyoutHeader>
<!-- Voci del menu -->
<FlyoutItem Title="Home" Icon="home.png">
<ShellContent ContentTemplate="{DataTemplate views:DashboardPage}"
Route="dashboard" />
</FlyoutItem>
<!-- Separatore visivo -->
<FlyoutItem FlyoutDisplayOptions="AsMultipleItems">
<Tab Title="I Miei Ordini" Icon="orders.png">
<ShellContent Title="Attivi" Route="ordiniAttivi"
ContentTemplate="{DataTemplate views:OrdiniAttiviPage}" />
<ShellContent Title="Completati" Route="ordiniCompletati"
ContentTemplate="{DataTemplate views:OrdiniCompletatiPage}" />
</Tab>
</FlyoutItem>
<Shell.FlyoutFooter>
<VerticalStackLayout Padding="20">
<Label Text="Versione 2.1.0"
TextColor="Gray" FontSize="12"
HorizontalOptions="Center" />
</VerticalStackLayout>
</Shell.FlyoutFooter>
</Shell>
L'opzione FlyoutDisplayOptions="AsMultipleItems" è particolarmente utile: invece di mostrare un singolo elemento nel flyout con tab interni, mostra ogni ShellContent come voce separata nel menu. L'utente può navigare direttamente alla sotto-sezione desiderata senza passaggi intermedi.
Problemi Comuni e Come Risolverli
Dopo aver lavorato con Shell su diversi progetti, ecco i problemi che si incontrano più spesso — e le relative soluzioni. Tenetevi questo elenco a portata di mano, perché prima o poi ne avrete bisogno.
1. La Route non Viene Trovata
Se ottenete un'eccezione del tipo "Unable to find route", verificate che:
- La route sia stata registrata con
Routing.RegisterRoute()prima di tentare la navigazione - Il nome della route corrisponda esattamente (è case-sensitive!)
- Non stiate usando il prefisso
//con una route globale — le route registrate conRouting.RegisterRoute()funzionano solo come route relative
2. I Parametri non Arrivano al ViewModel
Questo è classico. Controllate che:
- Il ViewModel implementi
IQueryAttributable(non usate[QueryProperty]se mirate a NativeAOT) - Il
BindingContextsia assegnato prima che Shell tenti di passare i parametri — idealmente nel costruttore della pagina - I nomi delle chiavi nel dizionario corrispondano a quelli che cercate in
ApplyQueryAttributes
3. Il Flyout Indicator non si Aggiorna
È un bug noto (e piuttosto fastidioso): quando si naviga usando route assolute con il prefisso //, l'indicatore di selezione nel flyout potrebbe non aggiornarsi correttamente. Una soluzione pratica è usare la proprietà FlyoutItem.IsChecked per gestire manualmente la selezione dopo la navigazione.
4. Dati Residui nella Navigazione all'Indietro
I parametri passati durante la navigazione vengono mantenuti in memoria per tutta la vita della pagina. Quando l'utente torna indietro, ApplyQueryAttributes viene chiamato di nuovo con i dati originali. La soluzione è semplice: verificate se i dati sono nuovi o residui.
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("id", out var idObj))
{
var nuovoId = idObj?.ToString();
if (nuovoId != _idCorrente)
{
_idCorrente = nuovoId;
_ = CaricaDatiAsync(nuovoId);
}
}
}
5. Prestazioni con Molte Route
Se avete un'app con decine di pagine, registrare tutte le route all'avvio può rallentare il bootstrap. Considerate il caricamento lazy delle registrazioni: raggruppate le route per funzionalità e registratele solo quando l'utente accede a quella sezione dell'app per la prima volta.
Riepilogo: Checklist per una Navigazione Solida
Prima di chiudere, ecco una checklist rapida da tenere sotto mano ogni volta che impostate la navigazione in un progetto .NET MAUI:
- Usate Shell come contenitore principale — è il modo raccomandato per le app .NET MAUI
- Definite tutte le route in modo esplicito, sia nella gerarchia Shell che con
Routing.RegisterRoute() - Centralizzate le route in costanti per evitare errori di battitura e facilitare il refactoring
- Usate
IQueryAttributableper ricevere parametri — è compatibile con NativeAOT e trimming - Passate oggetti complessi tramite Dictionary, non tramite serializzazione in query string
- Implementate un NavigationService per mantenere i ViewModel testabili e disaccoppiati da Shell
- Gestite gli eventi del ciclo di vita (
Navigating,OnAppearing,OnDisappearing) per caricare dati e liberare risorse - Validate sempre gli URI di deep linking — sono input esterno e potenzialmente malevolo
- Testate la navigazione con unit test sui ViewModel usando mock del NavigationService
La navigazione non è l'aspetto più appariscente dello sviluppo mobile, ma è quello che l'utente sperimenta più direttamente. Un'architettura di navigazione ben progettata rende l'app intuitiva, reattiva e soprattutto manutenibile nel tempo. Con Shell e i pattern che abbiamo visto in questa guida, avete tutti gli strumenti per costruirla nel modo giusto.
Nella prossima guida esploreremo le strategie di testing per applicazioni .NET MAUI — perché un'architettura testabile senza test effettivi è, diciamolo, solo una bella teoria.