Navigation Shell dans .NET MAUI 10 : Guide Complet avec Routing et Deep Links (2026)

Guide complet de la navigation Shell dans .NET MAUI 10. Routes URI, deep links iOS/Android, injection de dépendances et passage de paramètres typés, avec exemples testés en production et pièges courants à éviter.

Shell .NET MAUI 10 : Guide Navigation 2026

Mis à jour : 18 juin 2026

La navigation Shell dans .NET MAUI 10 est un système de routage déclaratif et centralisé qui décrit l'arborescence de votre application (flyout, onglets, pages) en XAML, puis vous laisse naviguer entre les écrans via des URI typés comme //main/products?id=42. C'est la couche de navigation recommandée par Microsoft pour toute application MAUI non triviale, et après l'avoir poussée en production sur trois applications, je peux confirmer qu'elle élimine 80 % du code de navigation manuel, à condition de comprendre ses pièges.

  • Shell remplace NavigationPage et TabbedPage par un graphe de routes unique défini en XAML, ce qui réduit drastiquement le code-behind de navigation.
  • Les routes absolues (//) réinitialisent la pile ; les routes relatives empilent les pages. Confondre les deux est la source numéro un des bugs de navigation.
  • L'injection de dépendances dans ContentPage et ViewModel fonctionne nativement via Shell.Current.GoToAsync depuis .NET 8, et reste la norme en .NET MAUI 10.
  • Le passage de paramètres typés utilise IQueryAttributable côté ViewModel ; les attributs QueryProperty sont à éviter pour des objets complexes.
  • Les deep links iOS/Android se branchent sur Shell via Routing.RegisterRoute et un handler de plate-forme (pas besoin de bibliothèque tierce).
  • Sur Android 14+ et iOS 17+, le comportement de retour matériel a changé : un override explicite de OnBackButtonPressed est nécessaire pour éviter les sorties intempestives.

Qu'est-ce que Shell dans .NET MAUI 10 ?

Shell est l'infrastructure de navigation par défaut de .NET MAUI. Vous décrivez la structure de votre application (flyout latéral, onglets du bas, hiérarchie de pages) dans un unique fichier XAML (AppShell.xaml) qui hérite de la classe Shell. À l'exécution, MAUI lit cet arbre et expose une API de navigation basée sur des URI : chaque écran est adressable par une chaîne, exactement comme une URL web.

Le bénéfice concret ? Vous arrêtez d'écrire await Navigation.PushAsync(new ProductDetailsPage(productId)) partout. À la place, vous publiez une route products/details et vous y allez avec await Shell.Current.GoToAsync("products/details?id=42"). Le ViewModel cible reçoit le paramètre via IQueryAttributable, l'injection de dépendances résout les services, et la pile de navigation reste cohérente même après un changement d'onglet.

Dans .NET MAUI 10, Shell a gagné trois améliorations notables par rapport à .NET 8 : un système de routage strict qui lève une exception explicite si une route n'est pas enregistrée (plutôt qu'un écran blanc silencieux), un meilleur support du modal sur iOS 17+, et une intégration plus propre avec CommunityToolkit.Mvvm. Si vous structurez vos ViewModels avec ce toolkit, jetez un œil à mon guide MVVM dans .NET MAUI 10 avec CommunityToolkit.Mvvm avant de continuer, parce que la navigation Shell repose sur les mêmes conventions de DI.

Configurer une AppShell propre dès le départ

Un Shell mal structuré est très difficile à refactorer une fois l'application en production. Je l'ai appris à mes dépens : j'ai dû le faire deux fois, et les deux fois ça m'a coûté une semaine. La règle que j'applique systématiquement : déclarer toute la structure de navigation dans AppShell.xaml, et enregistrer les routes détail (pages atteignables uniquement par navigation programmatique) dans le code-behind via Routing.RegisterRoute.

Voici la structure minimale que j'utilise sur tous mes projets en .NET MAUI 10 :

<!-- AppShell.xaml -->
<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"
       FlyoutBehavior="Flyout"
       Title="MyApp">

    <TabBar>
        <Tab Title="Accueil" Icon="home.png">
            <ShellContent Route="home"
                          ContentTemplate="{DataTemplate pages:HomePage}" />
        </Tab>
        <Tab Title="Produits" Icon="cart.png">
            <ShellContent Route="products"
                          ContentTemplate="{DataTemplate pages:ProductsPage}" />
        </Tab>
        <Tab Title="Profil" Icon="user.png">
            <ShellContent Route="profile"
                          ContentTemplate="{DataTemplate pages:ProfilePage}" />
        </Tab>
    </TabBar>
</Shell>

Et le code-behind, où j'enregistre les pages de détail :

// AppShell.xaml.cs
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        Routing.RegisterRoute("products/details", typeof(ProductDetailsPage));
        Routing.RegisterRoute("products/edit", typeof(ProductEditPage));
        Routing.RegisterRoute("profile/settings", typeof(SettingsPage));
    }
}

N'utilisez jamais x:Name sur un ShellContent pour le récupérer plus tard. Ce pattern fuit la complexité du graphe Shell dans le code applicatif et empêche le rechargement à chaud (hot reload) de fonctionner correctement. Préférez toujours résoudre les pages via leur route et le conteneur DI.

Routes absolues vs relatives : quelle différence ?

Honnêtement, c'est la confusion la plus fréquente que je vois en revue de code. Shell distingue deux types de routes, et leur comportement diffère radicalement.

Une route absolue commence par // et réinitialise la pile de navigation. Si l'utilisateur est sur products/details et que vous appelez GoToAsync("//home"), toutes les pages intermédiaires sont détruites et Shell vous place sur l'onglet Accueil avec une pile vide. C'est ce que vous voulez après un logout, une notification push qui ouvre une section précise, ou un onboarding terminé.

Une route relative n'a pas de préfixe et empile une page sur la navigation courante. Depuis products, appeler GoToAsync("details?id=42") pousse ProductDetailsPage au-dessus de ProductsPage. Le bouton retour revient à la liste, l'état est préservé.

CaractéristiqueRoute absolue (//)Route relative
Effet sur la pileRéinitialise complètementEmpile par-dessus
Bouton retourQuitte l'app ou onglet précédentRevient à la page précédente
Cas d'usageLogout, notification push, onboarding finiDrill-down standard (liste → détail)
PerformancePlus coûteux (recrée l'arbre)Léger
Préservation de l'étatPerduConservé

Comment passer des paramètres entre pages avec Shell ?

Shell propose deux mécanismes pour passer des données entre pages : les attributs QueryProperty et l'interface IQueryAttributable. Sur du long terme et en production, n'utilisez que IQueryAttributable. Les QueryProperty ne supportent que des types primitifs et déclenchent les setters dans un ordre non garanti, ce qui rend impossible la coordination d'une vraie initialisation asynchrone.

Voici le pattern que j'applique dans tous mes projets, un ViewModel qui reçoit un objet typé et lance le chargement en arrière-plan :

public partial class ProductDetailsViewModel : ObservableObject, IQueryAttributable
{
    private readonly IProductService _productService;

    [ObservableProperty]
    private Product? _product;

    [ObservableProperty]
    private bool _isLoading;

    public ProductDetailsViewModel(IProductService productService)
    {
        _productService = productService;
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("id", out var rawId) &&
            int.TryParse(rawId.ToString(), out var productId))
        {
            _ = LoadProductAsync(productId);
        }
    }

    private async Task LoadProductAsync(int id)
    {
        IsLoading = true;
        try
        {
            Product = await _productService.GetByIdAsync(id);
        }
        finally
        {
            IsLoading = false;
        }
    }
}

Côté navigation, vous construisez l'URI avec des paramètres :

// Depuis n'importe quel ViewModel ou code-behind
var parameters = new Dictionary<string, object>
{
    ["id"] = product.Id,
    ["source"] = "search-results"
};

await Shell.Current.GoToAsync("details", parameters);

Pour les objets complexes (un Product entier plutôt qu'un id), passez-les via le dictionnaire. Shell n'essaie pas de les sérialiser dans l'URI, ce qui évite les bugs de taille de chaîne sur certaines plateformes. Pour aller plus loin sur la décomposition ViewModels/services, consultez l'approche que je recommande pour tester ViewModels et UI.

Injection de dépendances et cycle de vie des pages

Shell respecte le conteneur DI configuré dans MauiProgram.cs. Pour que ça marche, vos pages et ViewModels doivent être enregistrés explicitement ; ils ne sont pas résolus par convention.

// MauiProgram.cs
public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .ConfigureFonts(fonts =>
        {
            fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
        });

    // Services
    builder.Services.AddSingleton<IProductService, ProductService>();
    builder.Services.AddSingleton<IAuthService, AuthService>();

    // ViewModels
    builder.Services.AddTransient<ProductsViewModel>();
    builder.Services.AddTransient<ProductDetailsViewModel>();

    // Pages
    builder.Services.AddTransient<ProductsPage>();
    builder.Services.AddTransient<ProductDetailsPage>();

    return builder.Build();
}

La page reçoit son ViewModel par injection de constructeur :

public partial class ProductDetailsPage : ContentPage
{
    public ProductDetailsPage(ProductDetailsViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

Pour le cycle de vie : Shell instancie la page au moment du GoToAsync, déclenche OnAppearing quand elle devient visible, puis OnDisappearing quand on la quitte. Si l'utilisateur fait un retour, la page est généralement détruite (sauf pour les onglets racine, qui restent vivants). Ne stockez jamais de données critiques dans le code-behind d'une page. Toujours dans un service singleton ou le ViewModel.

Un deep link est une URL externe (par exemple myapp://products/42 ou https://myapp.com/products/42) qui ouvre directement un écran précis de votre application. Shell rend l'implémentation triviale du côté .NET. La vraie complexité est dans la configuration des plates-formes.

Côté .NET MAUI, vous interceptez l'URL dans App.xaml.cs et la passez à Shell :

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

    // uri.AbsolutePath = "/products/42"
    var route = $"//{uri.AbsolutePath.TrimStart('/')}";
    await Shell.Current.GoToAsync(route);
}

Côté Android (Platforms/Android/AndroidManifest.xml), déclarez l'intent-filter sur MainActivity :

<activity android:name=".MainActivity" android:exported="true">
    <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="/products" />
    </intent-filter>
</activity>

Côté iOS, ajoutez les Associated Domains dans Entitlements.plist et hébergez un fichier apple-app-site-association à la racine de votre domaine. Les détails complets sont dans la documentation officielle de la navigation Shell et la spec Supporting Associated Domains d'Apple.

Personnaliser le flyout et les onglets

La structure par défaut de Shell est fonctionnelle mais visuellement neutre. En production, vous voudrez personnaliser le flyout (menu latéral) et la barre d'onglets pour correspondre à votre charte. Trois leviers sont disponibles : FlyoutHeader, FlyoutFooter, et les DataTemplate des FlyoutItem.

<Shell.FlyoutHeader>
    <Grid HeightRequest="180" BackgroundColor="{StaticResource Primary}">
        <VerticalStackLayout VerticalOptions="Center" Padding="20">
            <Image Source="logo.png" HeightRequest="60" />
            <Label Text="{Binding UserName}"
                   TextColor="White"
                   FontSize="18"
                   FontAttributes="Bold" />
        </VerticalStackLayout>
    </Grid>
</Shell.FlyoutHeader>

Pour la barre d'onglets, n'utilisez pas les SVG par défaut. Sur Android, ils peuvent être tintés automatiquement, ce qui casse les icônes multicolores. Préférez des PNG avec un état actif/inactif explicite via le VisualStateManager de Shell, ou utilisez les vector drawables cross-platform désormais correctement supportés en .NET MAUI 10 sans bidouille.

Pour les applications avec une architecture en couches (services, repositories, ViewModels), reliez le flyout à un service d'authentification singleton : c'est la solution la plus propre pour afficher ou cacher des sections selon les droits utilisateur. Ce sujet rejoint l'approche structurée que je détaille dans le guide de migration Xamarin.Forms vers .NET MAUI, où la couche de navigation est l'un des premiers chantiers à reprendre.

Les pièges Shell en production (et comment les éviter)

Après avoir shippé trois apps Shell en production, voici les six bugs récurrents que j'ai vus et leurs corrections.

1. La navigation modale qui ne se ferme pas sur iOS 17+

iOS 17 a changé le comportement de présentation modale. GoToAsync avec presentation:modal doit maintenant être suivi de Shell.Current.GoToAsync("..") pour fermer, pas Navigation.PopModalAsync(). Mélanger les deux APIs cause des crashs aléatoires. Je suis tombé sur ce bug à 22h la veille d'une release, et il m'a fallu trois heures pour comprendre.

2. Les routes non enregistrées qui crashent en release

En debug, une route inconnue affiche un warning ; en release Android avec R8, le linker peut éliminer les types non référencés et provoquer un ArgumentException. Toujours référencer les pages dans AppShell.xaml.cs via Routing.RegisterRoute, jamais par réflexion.

3. Le retour matériel qui quitte l'application

Sur Android 14+, si la pile Shell est vide et qu'on appuie sur retour, l'app se ferme sans confirmation. Implémentez OnBackButtonPressed sur la page racine pour afficher un dialogue de confirmation si vous gérez du contenu non sauvegardé.

4. Les ViewModels qui fuient via les évènements

Shell.Current.Navigating est un singleton ; si vous y abonnez votre ViewModel, désabonnez-vous dans OnDisappearing ou utilisez WeakReferenceMessenger du CommunityToolkit.Mvvm. J'ai diagnostiqué une fuite mémoire de 40 Mo sur une app de tickets à cause de ça (et c'est exactement le genre de bug que personne ne voit avant la prod).

5. Les onglets qui perdent leur état après un GoToAsync absolu

Une navigation //home détruit l'état des autres onglets. Si l'utilisateur attendait que Profil garde sa position de scroll, c'est mort. La solution : préférer GoToAsync("..") pour revenir, et n'utiliser les routes absolues que pour les vrais points de réinitialisation (logout).

6. Les tests unitaires impossibles sur ViewModels couplés à Shell.Current

N'appelez jamais Shell.Current.GoToAsync directement depuis un ViewModel testable. Injectez une INavigationService qui encapsule l'appel : c'est aussi ce qui vous permettra de migrer si Microsoft sort un nouveau système de navigation. Ce pattern est compatible avec toutes les recommandations Microsoft documentées dans le tracker GitHub officiel de .NET MAUI.

Questions fréquentes

Quelle est la différence entre Shell et NavigationPage dans .NET MAUI ?

NavigationPage est une pile de navigation impérative (push/pop manuels) héritée de Xamarin.Forms. Shell est un système déclaratif basé sur des routes URI qui englobe la navigation, les onglets et le flyout dans une seule abstraction. Pour toute application non triviale en .NET MAUI 10, Shell est recommandé.

Peut-on utiliser Shell sans flyout ni onglets ?

Oui. Définissez FlyoutBehavior="Disabled" sur Shell et n'utilisez qu'un seul ShellContent à la racine. Vous bénéficiez quand même du routage URI, de l'injection de dépendances et du passage de paramètres typés.

Comment naviguer en arrière avec Shell ?

Utilisez await Shell.Current.GoToAsync("..") pour revenir d'un niveau, ou "../.." pour revenir de deux. Vous pouvez aussi passer des paramètres en arrière : GoToAsync("..?refresh=true") permet à la page précédente de recharger ses données.

Shell supporte-t-il les transitions personnalisées entre pages ?

Partiellement. .NET MAUI 10 expose Shell.PresentationMode pour le modal et les animations standard, mais pour des transitions vraiment custom (shared element, parallax) vous devez encore implémenter un handler de plate-forme via Microsoft.Maui.Handlers. C'est documenté, mais coûteux à maintenir.

Faut-il migrer une application Xamarin.Forms vers Shell ?

Oui, si vous prévoyez de garder l'app en production plus de 12 mois. La migration vers Shell se fait généralement en parallèle de la migration vers MAUI : c'est l'occasion de simplifier la structure de navigation. Comptez 1 à 2 semaines pour une app moyenne avec 20 à 30 écrans.

Marcus Chen
À propos de l'auteur Marcus Chen

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