Shell-navigation i .NET MAUI: Routing, Dataoverførsel og MVVM

Lær Shell-navigation i .NET MAUI fra bunden — routing med GoToAsync, tre metoder til dataoverførsel, MVVM-venlig navigationsservice og de mest almindelige faldgruber. Med kodeeksempler til .NET 10.

Hvorfor navigation er fundamentet i enhver mobilapp

Navigation er selve rygraden i din mobilapp. Det er den mekanisme, der forbinder alle dine sider, styrer brugerflowet og — lad os være ærlige — afgør, om din app føles intuitiv eller direkte frustrerende. Og alligevel er det et af de områder, hvor selv erfarne .NET MAUI-udviklere løber ind i problemer.

Det er egentlig ikke fordi navigation i sig selv er raketvidenskab. Problemet er, at .NET MAUI tilbyder flere tilgange — Shell-navigation, NavigationPage, modal navigation — og det kan hurtigt blive uoverskueligt at finde ud af, hvilken strategi der passer bedst. Når du så tilføjer dataoverførsel mellem sider, dependency injection og MVVM-kompatibilitet oveni, ja, så har du en opskrift på forvirring.

Denne guide giver dig det komplette overblik. Vi starter med det grundlæggende i Shell-navigation, bevæger os videre til avanceret dataoverførsel, og slutter med en MVVM-venlig navigationsarkitektur, der faktisk skalerer med dit projekt. Alt med praktiske kodeeksempler, der fungerer med .NET MAUI i .NET 10.

Shell vs. NavigationPage: Hvad skal du vælge?

.NET MAUI giver dig to primære navigationsmønstre: Shell og NavigationPage. Lad os slå det fast med det samme: Shell er den anbefalede tilgang til moderne .NET MAUI-apps. Microsoft anbefaler det eksplicit, og det er den retning hele frameworket bevæger sig i.

Her er den afgørende forskel:

  • NavigationPage bruger en stak-baseret model (push/pop). Det er simpelt, men begrænset. Du navigerer fremad med PushAsync og tilbage med PopAsync.
  • Shell bruger en URI-baseret model med ruter. Du navigerer med GoToAsync, og du kan springe direkte til enhver side uden at skulle bygge en stak op først.

Vigtigt: NavigationPage er ikke kompatibel med Shell. Forsøger du at bruge NavigationPage inde i en Shell-app, får du en runtime-undtagelse. Vælg én tilgang og hold dig til den — blanderi ender altid i tårer (eller i hvert fald i mystiske crashes).

Shell giver dig desuden en hel del ud af boksen:

  • Flyout-menuer og fane-navigation uden ekstra konfiguration
  • URI-baseret deep linking
  • Automatisk integration med dependency injection
  • Bedre understøttelse af NativeAOT og trimming

Kom i gang med Shell-navigation

Konfigurér AppShell

Hele din navigationsstruktur defineres i AppShell.xaml. Det er her, du fortæller Shell, hvilke sider der udgør din apps visuelle hierarki:

<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:MinApp.Views"
       x:Class="MinApp.AppShell">

    <!-- Faner i bunden af appen -->
    <TabBar>
        <ShellContent Title="Hjem"
                      Icon="home.png"
                      Route="hjem"
                      ContentTemplate="{DataTemplate views:HjemmePage}" />

        <ShellContent Title="Produkter"
                      Icon="products.png"
                      Route="produkter"
                      ContentTemplate="{DataTemplate views:ProdukterPage}" />

        <ShellContent Title="Profil"
                      Icon="profile.png"
                      Route="profil"
                      ContentTemplate="{DataTemplate views:ProfilPage}" />
    </TabBar>
</Shell>

Læg mærke til brugen af ContentTemplate i stedet for Content. Det sikrer, at siderne først oprettes, når brugeren faktisk navigerer til dem — en vigtig optimering for opstartstiden, som mange desværre overser.

Registrér ruter for detailsider

Sider, der ikke er en del af det visuelle hierarki (typisk detailsider), skal registreres som ruter i code-behind:

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

        // Registrér detailsider
        Routing.RegisterRoute(nameof(ProduktDetaljerPage), typeof(ProduktDetaljerPage));
        Routing.RegisterRoute(nameof(IndstillingerPage), typeof(IndstillingerPage));
        Routing.RegisterRoute(nameof(OrdreHistorikPage), typeof(OrdreHistorikPage));
    }
}

Et vigtigt princip her: brug altid nameof() til rutenavne. Det giver dig compile-time sikkerhed og eliminerer risikoen for de der irriterende stavefejl i strenge, som først dukker op ved runtime — og altid på det værst tænkelige tidspunkt.

Navigér mellem sider med GoToAsync

Grundlæggende navigation

Al navigation i Shell sker gennem Shell.Current.GoToAsync():

// Navigér til en registreret rute
await Shell.Current.GoToAsync(nameof(ProduktDetaljerPage));

// Navigér tilbage
await Shell.Current.GoToAsync("..");

// Navigér to niveauer tilbage
await Shell.Current.GoToAsync("../..");

Forstå rute-præfikser

Okay, det her er et område, der skaber rigtig meget forvirring. Shell bruger præfikser i URI'en til at styre navigationsadfærden, og det er vigtigt at forstå forskellen:

  • GoToAsync("side") — relativ navigation. Skubber siden oven på den nuværende stak.
  • GoToAsync("/side") — bruges sjældent og kan give uventet adfærd. Undgå det.
  • GoToAsync("//side") — absolut navigation. Nulstiller hele navigationsstakken og går direkte til ruten. Perfekt efter login, hvor du vil have en helt ren start.
// Efter succesfuldt login — nulstil stakken og gå til hovedsiden
await Shell.Current.GoToAsync("//hjem");

// Fra en produktliste — skub detailside oven på stakken
await Shell.Current.GoToAsync(nameof(ProduktDetaljerPage));

Tommelfingerregel: Brug // til top-level navigation (skift mellem faner, efter login/logout). Brug relativ navigation til alt andet. Simpelt nok, ikke?

Dataoverførsel mellem sider: De tre metoder

At sende data fra én side til en anden er noget, du gør konstant i en mobilapp. Shell tilbyder tre metoder, og det er værd at kende styrkerne ved hver af dem.

Metode 1: Query-parametre i URI'en

Den simpleste metode — og den du bør vælge for primitive typer som strenge og tal:

// Afsender: Send et produkt-ID via URI
await Shell.Current.GoToAsync(
    $"{nameof(ProduktDetaljerPage)}?produktId=42");

Modtageren implementerer IQueryAttributable for at hente parametren:

public class ProduktDetaljerViewModel : ObservableObject, IQueryAttributable
{
    private readonly IProduktService _produktService;

    [ObservableProperty]
    private Produkt _produkt;

    public ProduktDetaljerViewModel(IProduktService produktService)
    {
        _produktService = produktService;
    }

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("produktId", out var idObj)
            && int.TryParse(idObj?.ToString(), out var produktId))
        {
            // Hent produktet asynkront
            Task.Run(async () =>
            {
                Produkt = await _produktService.HentProduktAsync(produktId);
            });
        }
    }
}

Vigtigt: Brug IQueryAttributable i stedet for den ældre [QueryProperty]-attribut. QueryPropertyAttribute er ikke trim-sikker og vil give dig problemer med fuld trimming eller NativeAOT — noget der er særligt relevant nu med .NET 10.

Metode 2: Dictionary med komplekse objekter

Når du skal sende hele objekter (og det skal du ofte), bruger du en Dictionary<string, object>:

// Afsender: Send et helt produkt-objekt
var parametre = new Dictionary<string, object>
{
    { "ValgtProdukt", valgtProdukt }
};
await Shell.Current.GoToAsync(nameof(ProduktDetaljerPage), parametre);

Modtageren henter objektet i ApplyQueryAttributes:

public void ApplyQueryAttributes(IDictionary<string, object> query)
{
    if (query.TryGetValue("ValgtProdukt", out var obj) && obj is Produkt produkt)
    {
        Produkt = produkt;
    }
}

Bemærk: Data sendt via Dictionary bevares i hukommelsen, så længe siden er på navigationsstakken. Det betyder, at objektet også bliver sendt tilbage, hvis brugeren navigerer tilbage — hvilket kan give nogle overraskende sideeffekter. Hvis du vil undgå det, kan du rydde dictionaryen efter behandling:

public void ApplyQueryAttributes(IDictionary<string, object> query)
{
    if (query.TryGetValue("ValgtProdukt", out var obj) && obj is Produkt produkt)
    {
        Produkt = produkt;
        query.Clear(); // Ryd for at undgå genaflevering ved tilbagenavigation
    }
}

Metode 3: ShellNavigationQueryParameters (engangsoverførsel)

Min personlige favorit til scenarier, hvor data kun skal overføres én gang:

// Afsender: Engangsdataoverførsel
var parametre = new ShellNavigationQueryParameters
{
    { "Ordre", nyOrdre }
};
await Shell.Current.GoToAsync(nameof(OrdreDetaljerPage), parametre);

Forskellen fra Dictionary er, at ShellNavigationQueryParameters automatisk ryddes efter levering. Det gør dem ideelle til store objekter eller data, der kun er relevante for den initielle visning af siden. Ingen manuel oprydning nødvendig.

Dependency injection og Shell-navigation

En af de ting, jeg virkelig sætter pris på ved Shell, er den dybe integration med .NET MAUIs dependency injection-container. Når Shell opretter en side under navigation, trækker den automatisk afhængigheder fra DI-containeren. Det bare virker.

Registrér sider og ViewModels

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder.UseMauiApp<App>();

    // Services
    builder.Services.AddSingleton<IProduktService, ProduktService>();
    builder.Services.AddSingleton<IOrdreService, OrdreService>();
    builder.Services.AddSingleton<INavigationService, NavigationService>();

    // ViewModels — Transient giver en frisk instans ved hver navigation
    builder.Services.AddTransient<ProdukterViewModel>();
    builder.Services.AddTransient<ProduktDetaljerViewModel>();
    builder.Services.AddTransient<OrdreHistorikViewModel>();

    // Sider
    builder.Services.AddTransient<ProdukterPage>();
    builder.Services.AddTransient<ProduktDetaljerPage>();
    builder.Services.AddTransient<OrdreHistorikPage>();

    return builder.Build();
}

Siderne modtager deres ViewModel via constructor injection — rent og enkelt:

public partial class ProduktDetaljerPage : ContentPage
{
    public ProduktDetaljerPage(ProduktDetaljerViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

Vælg den rigtige levetid

Valget mellem AddSingleton, AddTransient og AddScoped har overraskende stor indflydelse på din apps adfærd:

  • Singleton — én instans for hele appens levetid. Brug til services, der deler tilstand (API-klienter, cache, indstillinger).
  • Transient — ny instans ved hver anmodning. Brug til sider og ViewModels, så du får en frisk tilstand ved hver navigation. Det er den sikre standardindstilling, og det er det, jeg anbefaler som udgangspunkt.
  • Scoped — undgå det i MAUI. Ærligt talt er scope-livstiden ofte misforstået i mobilapps, og den kan føre til uventede hukommelseslækager, der er svære at debugge.

Byg en MVVM-venlig navigationsservice

I en ren MVVM-arkitektur bør dine ViewModels aldrig referere direkte til Shell.Current. Det ville skabe en tæt kobling til frameworket og gøre dine ViewModels umulige at teste isoleret. Og testbarhed er ikke bare "nice to have" — det er noget, der sparer dig for hovedpine i længden.

Løsningen er en navigationsservice bag et interface:

// Interface — definerer kontrakten
public interface INavigationService
{
    Task NavigerTilAsync(string rute);
    Task NavigerTilAsync(string rute, IDictionary<string, object> parametre);
    Task NavigerTilbageAsync();
}

// Implementation — wrapper omkring Shell
public class NavigationService : INavigationService
{
    public async Task NavigerTilAsync(string rute)
    {
        await Shell.Current.GoToAsync(rute);
    }

    public async Task NavigerTilAsync(string rute, IDictionary<string, object> parametre)
    {
        await Shell.Current.GoToAsync(rute, parametre);
    }

    public async Task NavigerTilbageAsync()
    {
        await Shell.Current.GoToAsync("..");
    }
}

Nu kan dine ViewModels navigere uden at kende til Shell overhovedet:

public partial class ProdukterViewModel : ObservableObject
{
    private readonly IProduktService _produktService;
    private readonly INavigationService _navigation;

    public ProdukterViewModel(
        IProduktService produktService,
        INavigationService navigation)
    {
        _produktService = produktService;
        _navigation = navigation;
    }

    [RelayCommand]
    private async Task VisDetaljer(Produkt produkt)
    {
        var parametre = new Dictionary<string, object>
        {
            { "ValgtProdukt", produkt }
        };
        await _navigation.NavigerTilAsync(nameof(ProduktDetaljerPage), parametre);
    }
}

Denne tilgang giver dig tre store fordele: dine ViewModels kan unit-testes med en mock-navigationsservice, du kan skifte navigationsimplementation ud uden at ændre forretningslogik, og din kode følger SOLID-principperne. Det er en af de arkitekturbeslutninger, du ikke fortryder.

Undgå de mest almindelige Shell-fælder

Shell-navigation har nogle velkendte faldgruber. Jeg har selv faldet i de fleste af dem, så lad mig spare dig for besværet.

Fælde 1: Navigationsstak-opblæsning

Hvis du bruger relativ navigation (GoToAsync("side")) gentagne gange uden at rydde stakken, kan du ende med hundredvis af sider i hukommelsen. Det sker hurtigere, end du tror. Monitorer din stak under udvikling:

// Tilføj dette i debug-builds for at overvåge stakken
#if DEBUG
var stakDybde = Shell.Current.Navigation.NavigationStack.Count;
System.Diagnostics.Debug.WriteLine($"Navigationsstak dybde: {stakDybde}");
#endif

Fælde 2: Ikke-unikke rutenavne

Hvis to ShellContent-elementer har samme rutenavn, får du tvetydige navigationsresultater. Shell kan navigere til den forkerte side uden at give nogen fejl — og det er virkelig svært at debugge. Definer altid eksplicitte, unikke ruter for hvert element.

Fælde 3: Blanding af Navigation.PushAsync og Shell.GoToAsync

Det her er en klassiker. Disse to navigationsmodeller har separate stakke. Blander du dem, kan tilbageknappen opføre sig uforudsigeligt eller endda crashe appen. Vælg Shell og hold dig konsekvent til Shell.Current.GoToAsync().

Fælde 4: Glemte side-instanser

Shell genbruger som standard side-instanser. Det lyder effektivt, men det betyder også, at hvis en bruger navigerer til den samme side igen, kan de se forældet data. Registrer dine sider som Transient i DI-containeren for at sikre en frisk instans ved hver navigation.

Fælde 5: QueryPropertyAttribute med NativeAOT

Den ældre [QueryProperty]-attribut bruger reflection og er ikke trim-sikker. Med .NET 10 og fuld trimming eller NativeAOT aktiveret vil den simpelthen fejle ved runtime. Brug altid IQueryAttributable-interfacet i stedet — det tager fem minutter at refaktorere, og det sparer dig for timer med debugging.

Navigation med modale sider

Modale sider er sider, der lægger sig over den nuværende side og kræver brugerens opmærksomhed, før de kan fortsætte. Tænk filtre, dialoger, eller formularer, der skal udfyldes.

// Vis en modal side
await Shell.Current.GoToAsync(nameof(FilterPage), animate: true);

// I FilterPage XAML — vis som modal via Shell.PresentationMode
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="MinApp.Views.FilterPage"
             Shell.PresentationMode="ModalAnimated">

    <VerticalStackLayout Padding="20">
        <Label Text="Filtrer produkter"
               FontSize="24"
               FontAttributes="Bold" />

        <!-- Filter-UI her -->

        <Button Text="Anvend"
                Command="{Binding AnvendFilterCommand}" />
    </VerticalStackLayout>
</ContentPage>

Du kan også sende data tilbage fra en modal side ved at bruge GoToAsync("..") med parametre — super praktisk:

// I FilterPage ViewModel — send filterresultat tilbage
[RelayCommand]
private async Task AnvendFilter()
{
    var parametre = new Dictionary<string, object>
    {
        { "Filter", AktivtFilter }
    };
    await Shell.Current.GoToAsync("..", parametre);
}

Avanceret: Type-sikker navigation

Et tilbagevendende problem med Shell-navigation er manglende type-sikkerhed. Ændrer du en parametertype i din ViewModel men glemmer at opdatere alle afsendere, får du ingen kompileringsfejl. I stedet får du en runtime-fejl, som (naturligvis) først viser sig i produktion.

Her er et mønster, der løser netop det problem:

// Definér navigationsparametre som stærkt typede klasser
public record ProduktNavigationParams(Produkt ValgtProdukt);

// Udvidelsesmetode for type-sikker navigation
public static class NavigationExtensions
{
    public static async Task NavigerTilAsync<TParams>(
        this INavigationService navigation,
        string rute,
        TParams parametre) where TParams : class
    {
        var dict = new Dictionary<string, object>
        {
            { typeof(TParams).Name, parametre }
        };
        await navigation.NavigerTilAsync(rute, dict);
    }
}

// Brug i ViewModel
[RelayCommand]
private async Task VisDetaljer(Produkt produkt)
{
    var parametre = new ProduktNavigationParams(produkt);
    await _navigation.NavigerTilAsync(nameof(ProduktDetaljerPage), parametre);
}

// Modtager i ProduktDetaljerViewModel
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
    if (query.TryGetValue(nameof(ProduktNavigationParams), out var obj)
        && obj is ProduktNavigationParams parametre)
    {
        Produkt = parametre.ValgtProdukt;
    }
}

Ved at bruge record-typer til navigationsparametre får du immutable, type-sikre kontrakter. Ændrer du strukturen, får du kompileringsfejl i stedet for runtime-overraskelser. Det er den slags mønster, der gør en reel forskel i større projekter.

Nyt i .NET MAUI 10 for navigation

.NET MAUI i .NET 10 fokuserer primært på kvalitetsforbedringer, og flere af dem påvirker Shell-navigation direkte:

  • XAML Source Generator — genererer stærkt typet kode fra XAML på byggetidspunktet, hvilket reducerer runtime-overhead for Shell-sider og giver bedre IntelliSense-support.
  • Globalt XAML-namespace — forenkler namespaces i AppShell.xaml, så du slipper for de lange præfikser ved typeopløsning.
  • Android Predictive Back Gesture — forbedrer tilbagenavigationsoplevelsen på Android 13+ med en forhåndsvisning af, hvad brugeren vender tilbage til. En lille detalje, men brugerne bemærker det.
  • Bug fixes til Shell-rendering — løser problemer med layout-brud ved første navigation og TitleView, der dækkede indhold på iOS. Det var en irriterende bug, og det er godt at se den rettet.
  • Forbedrede CollectionView- og CarouselView-handlers — nu standard på iOS og Mac Catalyst, hvilket gavner sider med listebaseret navigation.

Husk også, at .NET MAUI 9 kun understøttes til midt i maj 2026, så migrering til .NET 10 bør stå højt på din prioriteringsliste, hvis du ikke allerede er i gang.

Ofte stillede spørgsmål

Hvad er forskellen mellem Shell.GoToAsync og Navigation.PushAsync i .NET MAUI?

Shell.GoToAsync er URI-baseret og understøtter ruter, deep linking og query-parametre. Navigation.PushAsync er stak-baseret og enklere, men mangler rutesupport. De to har separate navigationsstakke og bør aldrig blandes i samme app. Shell er den anbefalede tilgang for moderne .NET MAUI-apps.

Hvordan sender jeg data mellem sider i .NET MAUI?

Du har tre muligheder: query-parametre i URI'en for simple typer, Dictionary<string, object> for komplekse objekter, og ShellNavigationQueryParameters for engangsoverførsler. Modtagersiden implementerer IQueryAttributable-interfacet for at hente dataen. Undgå den ældre [QueryProperty]-attribut, da den ikke er trim-sikker.

Kan jeg bruge NavigationPage sammen med Shell i .NET MAUI?

Kort svar: nej. NavigationPage er ikke kompatibel med Shell-apps, og et forsøg på at bruge dem sammen resulterer i en runtime-undtagelse. Vælg enten Shell eller NavigationPage fra starten af dit projekt — og i de fleste tilfælde er Shell det rigtige valg.

Hvordan undgår jeg hukommelseslækager ved Shell-navigation?

Registrer dine sider og ViewModels som Transient i DI-containeren, så du får friske instanser ved hver navigation. Monitorer navigationsstakkens dybde under udvikling, brug ShellNavigationQueryParameters til store dataobjekter der kun skal bruges én gang, og ryd query-dictionarien med query.Clear() efter behandling.

Er Shell-navigation kompatibel med MVVM-mønsteret?

Ja, men du bør abstrahere navigationen bag et interface (INavigationService) i stedet for at bruge Shell.Current.GoToAsync direkte i dine ViewModels. Det gør dine ViewModels testbare med mock-services og følger dependency inversion-princippet. Det kræver lidt mere setup, men det er absolut besværet værd.

Om Forfatteren Editorial Team

Our team of expert writers and editors.