Warum Navigation in .NET MAUI so viele Entwickler verwirrt
Hand aufs Herz: Wer zum ersten Mal mit .NET MAUI arbeitet, stößt verdammt schnell auf eine grundlegende Frage — wie navigiere ich eigentlich zwischen Seiten? Die Antwort fällt leider nicht so einfach aus wie erhofft, denn .NET MAUI bietet gleich mehrere Navigationsansätze, die sich in Philosophie und Einsatzzweck deutlich unterscheiden.
Shell-Navigation, NavigationPage, modale Seiten, Tabs, Flyouts — die Auswahl kann anfangs überwältigend wirken.
Genau hier setzt dieser Leitfaden an. Sie erfahren, wann Sie welchen Ansatz verwenden, wie Shell-Routing korrekt konfiguriert wird, wie Daten zwischen Seiten übergeben werden und wie die Navigation MVVM-konform in Ihre ViewModels passt. Alle Beispiele basieren auf .NET MAUI 10 mit .NET 10 und berücksichtigen aktuelle Best Practices — einschließlich NativeAOT-Kompatibilität und Trim-Sicherheit.
Shell vs. NavigationPage: Wann was verwenden
Bevor Sie auch nur eine Zeile Navigationscode schreiben, lohnt es sich, die beiden Hauptansätze zu verstehen.
Shell — der empfohlene Standard
Shell ist der moderne und von Microsoft empfohlene Ansatz für .NET MAUI. Im Kern bietet Shell eine URI-basierte Navigationserfahrung mit integrierter Unterstützung für Flyout-Menüs, Bottom-Tabs, Top-Tabs und sogar einer Suchfunktion. Ein netter Bonus: Shell erstellt Seiten erst bei Bedarf (Lazy Loading), was die Startzeit der App spürbar verbessert.
Verwenden Sie Shell, wenn Ihre App:
- Mehrere Navigationsebenen hat (Flyout, Tabs, Detail-Seiten)
- Deep Linking unterstützen soll
- Eine konsistente Navigationsstruktur über alle Plattformen hinweg benötigt
- Von integrierter Suche profitiert
NavigationPage — der klassische Ansatz
NavigationPage verwendet einen Stack-basierten Ansatz mit PushAsync und PopAsync. Dieser Ansatz stammt noch aus Xamarin.Forms und wird in .NET MAUI weiterhin unterstützt — ist aber ehrlich gesagt nicht mehr der empfohlene Standard.
Verwenden Sie NavigationPage wirklich nur, wenn:
- Ihre App eine sehr einfache, lineare Navigation hat
- Sie ein bestehendes Xamarin.Forms-Projekt migrieren und schrittweise umstellen
- Sie spezielle Navigationsmuster benötigen, die Shell nicht abdeckt
Der entscheidende Unterschied
GoToAsync ist routen-basiert und arbeitet mit URIs, während PushAsync stack-basiert arbeitet und Seiten direkt auf den Navigationsstack legt. In einer Shell-App sollten Sie immer Shell.Current.GoToAsync() verwenden und niemals das globale Navigation.PushAsync dazumischen. Ich habe das in einem Projekt mal gemacht — das Ergebnis war ein separater Navigationsstack, bei dem der Zurück-Button komplett ausgestiegen ist. Abstürze inklusive.
AppShell.xaml: Routen, Tabs und Flyout richtig konfigurieren
Das Herzstück der Shell-Navigation ist die AppShell.xaml-Datei. Hier definieren Sie die visuelle Hierarchie Ihrer App und die zugehörigen Routen.
Grundstruktur mit Bottom-Tabs
Wenn Ihre App mit Bottom-Tabs startet und kein Flyout-Menü braucht, ist TabBar der richtige Einstieg:
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:MeineApp.Views"
x:Class="MeineApp.AppShell">
<TabBar>
<Tab Title="Startseite"
Icon="home.png"
Route="startseite">
<ShellContent
ContentTemplate="{DataTemplate views:StartPage}" />
</Tab>
<Tab Title="Produkte"
Icon="products.png"
Route="produkte">
<ShellContent
ContentTemplate="{DataTemplate views:ProduktePage}" />
</Tab>
<Tab Title="Profil"
Icon="profile.png"
Route="profil">
<ShellContent
ContentTemplate="{DataTemplate views:ProfilPage}" />
</Tab>
</TabBar>
</Shell>
Wichtig: TabBar deaktiviert automatisch das Flyout-Menü. Wenn Sie sowohl Tabs als auch ein Flyout brauchen, verwenden Sie stattdessen FlyoutItem mit verschachtelten Tab-Elementen.
Flyout mit Tabs kombinieren
Für eine komplexere Navigationsstruktur mit seitlichem Menü und Tabs sieht das Ganze so aus:
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:MeineApp.Views"
x:Class="MeineApp.AppShell"
FlyoutBehavior="Flyout">
<FlyoutItem Title="Dashboard"
Icon="dashboard.png"
Route="dashboard">
<ShellContent
ContentTemplate="{DataTemplate views:DashboardPage}" />
</FlyoutItem>
<FlyoutItem Title="Shop"
Icon="shop.png"
Route="shop"
FlyoutDisplayOptions="AsMultipleItems">
<Tab Title="Alle Produkte" Route="alle">
<ShellContent
ContentTemplate="{DataTemplate views:ProduktePage}" />
</Tab>
<Tab Title="Angebote" Route="angebote">
<ShellContent
ContentTemplate="{DataTemplate views:AngebotePage}" />
</Tab>
</FlyoutItem>
<FlyoutItem Title="Einstellungen"
Icon="settings.png"
Route="einstellungen">
<ShellContent
ContentTemplate="{DataTemplate views:EinstellungenPage}" />
</FlyoutItem>
</Shell>
Der Unterschied zwischen AsMultipleItems und AsSingleItem ist entscheidend und wird oft übersehen: Mit AsMultipleItems erscheint jede Tab-Seite als eigener Eintrag im Flyout-Menü. Mit AsSingleItem (das ist der Standard) wird die Gruppe unter einem einzigen Flyout-Eintrag zusammengefasst — die Tabs erscheinen erst nach der Auswahl.
Routen für Detail-Seiten registrieren
Seiten, die nicht direkt in der Shell-Hierarchie leben (typischerweise Detail-Seiten), müssen im Code-Behind der AppShell registriert werden:
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute("produktdetail", typeof(ProduktDetailPage));
Routing.RegisterRoute("bestellung", typeof(BestellungPage));
Routing.RegisterRoute("bewertung", typeof(BewertungPage));
}
}
Definieren Sie Routennamen immer explizit. Automatisch generierte Routen können sich zwischen App-Sitzungen ändern — und das führt dann zu schwer nachvollziehbarem Verhalten.
Navigation mit GoToAsync: Absolute und relative Routen
Die GoToAsync-Methode ist das eigentliche Arbeitstier der Shell-Navigation. Sie unterstützt verschiedene Routen-Formate, und jedes hat einen anderen Navigationseffekt. Schauen wir uns das genauer an.
Absolute Routen mit dem Doppelslash-Präfix
Der //-Präfix setzt den kompletten Navigationsstack zurück und navigiert zur angegebenen Route. Man kann sich das wie einen Neustart der Navigation vorstellen:
// Navigiert zur Startseite und setzt den Stack zurück
await Shell.Current.GoToAsync("//startseite");
// Navigiert zum Dashboard — vorherige Seiten werden entfernt
await Shell.Current.GoToAsync("//dashboard");
Absolute Routen sind ideal nach dem Login, beim Logout oder wenn Sie sicherstellen wollen, dass der Benutzer nicht per Zurück-Button zu vorherigen Seiten gelangt.
Relative Routen für Detail-Seiten
Relative Routen fügen die Zielseite dem aktuellen Navigationsstack hinzu — das dürfte der häufigste Fall in der Praxis sein:
// Öffnet die Detail-Seite auf dem aktuellen Stack
await Shell.Current.GoToAsync("produktdetail");
// Verschachtelte relative Navigation
await Shell.Current.GoToAsync("produktdetail/bewertung");
Rückwärts navigieren
Für die Rückwärtsnavigation verwenden Sie einfach zwei Punkte:
// Eine Seite zurück
await Shell.Current.GoToAsync("..");
// Zwei Seiten zurück
await Shell.Current.GoToAsync("../..");
Datenweitergabe zwischen Seiten: IQueryAttributable richtig nutzen
Die Übergabe von Daten zwischen Seiten gehört zu den häufigsten Aufgaben — und gleichzeitig zu den fehleranfälligsten, wenn man es falsch angeht.
Warum nicht QueryPropertyAttribute?
In früheren Versionen von .NET MAUI wurde das [QueryProperty]-Attribut empfohlen. Ab .NET MAUI 10 sollten Sie das aber vermeiden: Es ist nicht Trim-sicher und inkompatibel mit NativeAOT-Kompilierung. Microsoft empfiehlt stattdessen die Implementierung des IQueryAttributable-Interface — und das aus gutem Grund.
Einfache Parameter per Query-String
Für einfache Werte wie IDs oder Strings können Sie Query-Parameter direkt in der URL mitgeben:
// Navigation mit Query-Parametern
await Shell.Current.GoToAsync($"produktdetail?id={produkt.Id}");
Im Ziel-ViewModel implementieren Sie dann IQueryAttributable:
public class ProduktDetailViewModel : ObservableObject, IQueryAttributable
{
private readonly IProduktService _produktService;
[ObservableProperty]
private Produkt _produkt;
public ProduktDetailViewModel(IProduktService produktService)
{
_produktService = produktService;
}
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("id", out var idObj)
&& int.TryParse(idObj?.ToString(), out var id))
{
LadeProduktAsync(id).ConfigureAwait(false);
}
}
private async Task LadeProduktAsync(int id)
{
Produkt = await _produktService.HoleProduktNachIdAsync(id);
}
}
Komplexe Objekte übergeben
Für komplexe Objekte greifen Sie zur Dictionary-basierten Überladung von GoToAsync:
// Komplexes Objekt übergeben
var parameter = new Dictionary<string, object>
{
{ "Produkt", ausgewaehltesProdukt }
};
await Shell.Current.GoToAsync("produktdetail", parameter);
Im Ziel-ViewModel empfangen Sie das Objekt dann direkt aus dem Dictionary:
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("Produkt", out var obj)
&& obj is Produkt produkt)
{
Produkt = produkt;
}
}
Achtung: Daten bleiben im Speicher
Das hier hat mich bei meinem ersten MAUI-Projekt echt überrascht: Navigationsparameter werden für die gesamte Lebensdauer der Seite im Speicher gehalten. Beim Zurücknavigieren werden die alten Parameter erneut an ApplyQueryAttributes übergeben. Das muss man wissen und in der Logik berücksichtigen:
private int _letzteProduktId;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("id", out var idObj)
&& int.TryParse(idObj?.ToString(), out var id)
&& id != _letzteProduktId)
{
_letzteProduktId = id;
LadeProduktAsync(id).ConfigureAwait(false);
}
}
Ohne diese Prüfung laden Sie beim Zurücknavigieren die Daten unnötig neu. Kein Weltuntergang, aber auch nicht elegant.
Modale Navigation und Präsentationsmodi
Nicht jede Navigation gehört auf den regulären Stack. Modale Seiten eignen sich für Aufgaben, die der Benutzer abschließen oder explizit abbrechen muss — etwa ein Formular, ein Filter-Dialog oder eine Bestätigung.
Präsentationsmodus per Attribut steuern
In .NET MAUI Shell steuern Sie den Präsentationsmodus über das Shell.PresentationMode-Attached Property direkt in der XAML-Definition der Zielseite:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MeineApp.Views.FilterPage"
Shell.PresentationMode="ModalAnimated">
<!-- Seiteninhalt -->
</ContentPage>
Die verfügbaren Modi im Überblick:
Animated— Standard-Navigation mit AnimationNotAnimated— Navigation ohne AnimationModalAnimated— Modale Darstellung mit Animation (der häufigste Fall)ModalNotAnimated— Modale Darstellung ohne Animation
Modale Seite schließen und Daten zurückgeben
Um eine modale Seite zu schließen und dabei Daten an die vorherige Seite zurückzugeben, nutzen Sie die Rückwärtsnavigation mit Parametern. Das funktioniert elegant:
// In der modalen Seite (z. B. FilterPage)
var ergebnis = new Dictionary<string, object>
{
{ "SelectedFilter", _ausgewaehlterFilter }
};
await Shell.Current.GoToAsync("..", ergebnis);
Zurück-Button-Verhalten anpassen
Das Zurück-Button-Verhalten ist einer der häufigsten Stolpersteine in MAUI-Apps. Besonders auf Android wird es knifflig, weil dort neben dem Software-Button auch der physische (bzw. Gesten-basierte) Zurück-Button berücksichtigt werden muss.
BackButtonBehavior in XAML
Shell bietet das BackButtonBehavior-Attached Property, mit dem Sie Aussehen und Verhalten des Zurück-Buttons granular steuern können:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="MeineApp.Views.BestellungPage">
<Shell.BackButtonBehavior>
<BackButtonBehavior
Command="{Binding ZurueckCommand}"
IconOverride="back_arrow.png"
TextOverride="Zurück"
IsEnabled="{Binding KannZurueck}" />
</Shell.BackButtonBehavior>
<!-- Seiteninhalt -->
</ContentPage>
Physischen Zurück-Button auf Android abfangen
Um den physischen Zurück-Button auf Android zu kontrollieren (z. B. um ungespeicherte Änderungen zu schützen), überschreiben Sie OnBackButtonPressed in der Page:
public partial class BestellungPage : ContentPage
{
public BestellungPage()
{
InitializeComponent();
}
protected override bool OnBackButtonPressed()
{
// true = Navigation unterdrücken
// false = Standard-Verhalten (zurück navigieren)
if (BindingContext is BestellungViewModel vm
&& vm.HatUngespeicherteAenderungen)
{
vm.ZeigeVerwerfenDialogCommand.Execute(null);
return true; // Navigation blockieren
}
return base.OnBackButtonPressed();
}
}
Kleiner Tipp am Rande: Vergessen Sie nicht, dass OnBackButtonPressed nur auf Android den physischen Button abfängt. Auf iOS gibt es keinen solchen Hardware-Button, dort müssen Sie das Verhalten über BackButtonBehavior lösen.
MVVM-konforme Navigation mit INavigationService
Jetzt wird es architektonisch interessant. Der direkte Aufruf von Shell.Current.GoToAsync() im ViewModel ist technisch möglich — funktioniert, keine Frage. Aber er verstößt gegen das MVVM-Muster. Das ViewModel wird damit abhängig von der konkreten Navigationsimplementierung, was Unit-Tests erschwert und die Wiederverwendbarkeit einschränkt.
Das INavigationService-Interface definieren
Definieren Sie ein schlankes Interface, das die Navigationsoperationen abstrahiert:
public interface INavigationService
{
Task NavigiereZuAsync(string route,
IDictionary<string, object>? parameter = null);
Task NavigiereZuRootAsync(string route,
IDictionary<string, object>? parameter = null);
Task ZurueckAsync(
IDictionary<string, object>? parameter = null);
}
Shell-basierte Implementierung
Die konkrete Implementierung delegiert an Shell.Current. Hier passiert keine Magie — nur saubere Kapselung:
public class ShellNavigationService : INavigationService
{
public async Task NavigiereZuAsync(string route,
IDictionary<string, object>? parameter = null)
{
if (parameter is not null)
{
await Shell.Current.GoToAsync(route,
new ShellNavigationQueryParameters(parameter));
}
else
{
await Shell.Current.GoToAsync(route);
}
}
public async Task NavigiereZuRootAsync(string route,
IDictionary<string, object>? parameter = null)
{
var vollRoute = $"//{route}";
if (parameter is not null)
{
await Shell.Current.GoToAsync(vollRoute,
new ShellNavigationQueryParameters(parameter));
}
else
{
await Shell.Current.GoToAsync(vollRoute);
}
}
public async Task ZurueckAsync(
IDictionary<string, object>? parameter = null)
{
if (parameter is not null)
{
await Shell.Current.GoToAsync("..",
new ShellNavigationQueryParameters(parameter));
}
else
{
await Shell.Current.GoToAsync("..");
}
}
}
Registrierung und Verwendung
Registrieren Sie den Service als Singleton und die ViewModels als Transient im DI-Container:
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
// Navigation
builder.Services.AddSingleton<INavigationService,
ShellNavigationService>();
// ViewModels
builder.Services.AddTransient<ProduktListeViewModel>();
builder.Services.AddTransient<ProduktDetailViewModel>();
// Pages
builder.Services.AddTransient<ProduktePage>();
builder.Services.AddTransient<ProduktDetailPage>();
return builder.Build();
}
}
Im ViewModel nutzen Sie dann einfach den injizierten Service:
public partial class ProduktListeViewModel : ObservableObject
{
private readonly INavigationService _navigation;
private readonly IProduktService _produktService;
public ProduktListeViewModel(
INavigationService navigation,
IProduktService produktService)
{
_navigation = navigation;
_produktService = produktService;
}
[ObservableProperty]
private ObservableCollection<Produkt> _produkte = new();
[RelayCommand]
private async Task ProduktAuswaehlenAsync(Produkt produkt)
{
if (produkt is null) return;
var parameter = new Dictionary<string, object>
{
{ "Produkt", produkt }
};
await _navigation.NavigiereZuAsync("produktdetail", parameter);
}
}
Typ-sichere Routenverwaltung
Noch ein Punkt, der sich in der Praxis bewährt hat: Statt Routen als Magic Strings durch den Code zu streuen, zentralisieren Sie diese in einer statischen Klasse:
public static class AppRoutes
{
public const string Startseite = "startseite";
public const string Produkte = "produkte";
public const string ProduktDetail = "produktdetail";
public const string Bestellung = "bestellung";
public const string Profil = "profil";
public const string Einstellungen = "einstellungen";
}
// Verwendung
await _navigation.NavigiereZuAsync(AppRoutes.ProduktDetail, parameter);
So bekommen Sie Compiler-Fehler, wenn sich eine Route ändert — statt schwer aufzuspürende Laufzeitfehler. Ein kleiner Aufwand mit großer Wirkung.
Häufige Fehler und Fallstricke vermeiden
Die Shell-Navigation hat einige Tücken, die selbst erfahrene Entwickler auf dem falschen Fuß erwischen. Hier sind die häufigsten Probleme — und wie Sie sie umgehen.
1. PushAsync in Shell-Apps mischen
Das ist wohl der Klassiker. Das Mischen von Navigation.PushAsync() mit Shell-Navigation erzeugt einen separaten Navigationsstack. Der Zurück-Button funktioniert nicht mehr korrekt und die App kann abstürzen. Wenn Sie Shell verwenden, bleiben Sie konsequent bei Shell.Current.GoToAsync().
2. Doppelte Routenregistrierung
Wenn eine Route bereits in AppShell.xaml definiert ist, darf sie nicht zusätzlich per Routing.RegisterRoute() registriert werden. Das führt zu einer Exception — und die Fehlermeldung ist leider nicht immer hilfreich. Registrieren Sie nur Seiten per Code, die nicht in der Shell-Hierarchie enthalten sind.
3. Einzelinstanz-Problem bei gleichem Seitentyp
Shell verwaltet nur eine einzige Instanz pro Seitentyp. Wenn Ihre App es erlauben soll, von einer Produktdetailseite zu einer anderen Produktdetailseite desselben Typs zu navigieren, funktioniert das mit Shell-Routing nicht direkt. Die Lösung: Recyceln Sie die Seite und aktualisieren Sie die Daten in ApplyQueryAttributes.
4. Fehlende explizite Routennamen
Ohne explizite Routennamen generiert Shell zur Laufzeit automatische Routen. Diese können sich aber zwischen App-Sitzungen ändern. Definieren Sie daher immer eine Route-Eigenschaft auf ShellContent, Tab und FlyoutItem.
5. iOS-Back-Button-Label-Bug
Auf iOS setzt Shell den Back-Button-Text automatisch auf den Titel der vorherigen Seite, wenn dieser kurz genug ist — andernfalls wird schlicht „Back" angezeigt. Das sieht in einer deutschsprachigen App natürlich seltsam aus. Verwenden Sie BackButtonBehavior.TextOverride, um das Label konsistent zu halten.
Häufig gestellte Fragen (FAQ)
Was ist der Unterschied zwischen GoToAsync und PushAsync in .NET MAUI?
GoToAsync ist routen-basiert und gehört zum Shell-Navigationssystem. Es navigiert über URIs und unterstützt Deep Linking. PushAsync ist dagegen stack-basiert und kommt aus dem klassischen NavigationPage-Ansatz. Für neue .NET MAUI-Projekte ist GoToAsync der empfohlene Standard, da Shell-Navigation mehr Funktionen bietet und besser skaliert.
Wie übergebe ich komplexe Objekte zwischen Seiten in .NET MAUI?
Verwenden Sie die Dictionary-basierte Überladung von Shell.Current.GoToAsync(). Erstellen Sie ein Dictionary<string, object> mit Ihren Objekten als Werte und implementieren Sie IQueryAttributable im Ziel-ViewModel, um die Objekte in der ApplyQueryAttributes-Methode zu empfangen. Das veraltete [QueryProperty]-Attribut sollten Sie meiden — es ist nicht Trim-sicher.
Kann ich Flyout-Menü und Bottom-Tabs gleichzeitig verwenden?
Ja, das geht. Verwenden Sie dafür FlyoutItem mit verschachtelten Tab-Elementen statt TabBar. Der TabBar-Typ deaktiviert nämlich automatisch das Flyout. Durch die Verschachtelung von ShellContent-Elementen in Tab-Elementen innerhalb eines FlyoutItem erhalten Sie beides: Flyout-Menü und Bottom-Tabs.
Wie verhindere ich die Rückwärtsnavigation auf bestimmten Seiten?
Überschreiben Sie OnBackButtonPressed() in der Page-Klasse und geben Sie true zurück, um die Navigation zu blockieren. Zusätzlich können Sie über BackButtonBehavior in XAML den Zurück-Button ausblenden oder deaktivieren, indem Sie IsVisible oder IsEnabled auf false setzen. Wichtig dabei: OnBackButtonPressed fängt auf Android auch den physischen Zurück-Button ab.
Sollte ich Shell.Current.GoToAsync direkt im ViewModel aufrufen?
Kurz gesagt: Nein. Das verstößt gegen das MVVM-Muster und macht Unit-Tests unnötig kompliziert. Erstellen Sie stattdessen ein INavigationService-Interface, implementieren Sie es mit Shell-Aufrufen, registrieren Sie es im DI-Container und injizieren Sie es in Ihre ViewModels. So bleibt das ViewModel testbar und unabhängig von der konkreten Navigationsimplementierung.