Waarom Shell navigatie dé standaard is in .NET MAUI
Als je een .NET MAUI-app bouwt, ontkom je simpelweg niet aan navigatie. Gebruikers willen van scherm naar scherm springen, data meekrijgen, en liefst ook via een link in hun e-mail direct op de juiste pagina landen. .NET MAUI Shell is daarvoor gebouwd — en eerlijk gezegd, in .NET 10 werkt het beter dan ooit.
Shell vervangt de traditionele NavigationPage-aanpak door een URI-gebaseerd navigatiemodel. Geen handmatig pushen en poppen van pagina's meer. In plaats daarvan definieer je routes en navigeer je met URL-achtige paden. Dat levert niet alleen een schonere architectuur op, maar maakt deep linking, tabnavigatie en flyout-menu's ook een stuk eenvoudiger.
In dit artikel lopen we stap voor stap door alles wat je moet weten: van het opzetten van je AppShell tot het doorgeven van complexe objecten, van deep linking op Android en iOS tot een testbare MVVM-navigatieservice. Laten we erin duiken.
AppShell opzetten: de basis van je navigatiestructuur
Elke .NET MAUI Shell-app begint bij de AppShell. Dit is het centrale punt waar je de visuele hiërarchie van je app definieert — tabs, flyout-items, hoofdpagina's. Eigenlijk de blauwdruk van je hele navigatiestructuur.
Een eenvoudige AppShell met tabs
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:MijnApp.Views"
x:Class="MijnApp.AppShell">
<TabBar>
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate views:HomePage}"
Route="home" />
<ShellContent
Title="Instellingen"
ContentTemplate="{DataTemplate views:InstellingenPage}"
Route="instellingen" />
</TabBar>
</Shell>
Let op het Route-attribuut op elk ShellContent. Die route-identifier gebruik je later om programmatisch te navigeren. Shell bouwt automatisch een navigatiehiërarchie op basis van deze structuur — je hoeft daar zelf niets voor te doen.
AppShell met Flyout-menu
Heeft je app meer secties? Dan is een flyout-menu vaak handiger dan tabs:
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:MijnApp.Views"
x:Class="MijnApp.AppShell"
FlyoutBehavior="Flyout">
<FlyoutItem Title="Dashboard" Icon="dashboard.png">
<ShellContent
ContentTemplate="{DataTemplate views:DashboardPage}"
Route="dashboard" />
</FlyoutItem>
<FlyoutItem Title="Bestellingen" Icon="orders.png">
<ShellContent
ContentTemplate="{DataTemplate views:BestellingenPage}"
Route="bestellingen" />
</FlyoutItem>
<FlyoutItem Title="Profiel" Icon="profile.png">
<ShellContent
ContentTemplate="{DataTemplate views:ProfielPage}"
Route="profiel" />
</FlyoutItem>
</Shell>
Een belangrijk voordeel van Shell: pagina's worden on-demand aangemaakt via ContentTemplate. Alleen de pagina die daadwerkelijk zichtbaar is, zit in het geheugen. Dat scheelt flink ten opzichte van eager loading, zeker bij grotere apps.
Routes registreren voor detailpagina's
Pagina's die in je Shell-hiërarchie staan als ShellContent krijgen automatisch een route. Maar detailpagina's — bijvoorbeeld een productdetailpagina waar je naartoe navigeert vanuit een lijst — die moet je zelf registreren.
// In AppShell.xaml.cs
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
// Registreer detailpagina's
Routing.RegisterRoute(nameof(ProductDetailPage), typeof(ProductDetailPage));
Routing.RegisterRoute(nameof(BestelBevestigingPage), typeof(BestelBevestigingPage));
}
}
Belangrijk: routenamen moeten uniek zijn binnen hetzelfde niveau van de hiërarchie. Een dubbele routenaam? Dan gooit .NET MAUI een ArgumentException bij het opstarten. Niet leuk om te debuggen.
Een handige conventie: gebruik nameof() voor routenamen. Voorkomt typefouten en maakt refactoren een stuk makkelijker.
Navigeren met GoToAsync
Alle navigatie in Shell verloopt via één methode: GoToAsync. Vergeet PushAsync en PopAsync — in Shell gebruik je uitsluitend GoToAsync. Even wennen misschien, maar het is verrassend prettig als je het eenmaal gewend bent.
Eenvoudige navigatie
// Navigeer naar de productdetailpagina
await Shell.Current.GoToAsync(nameof(ProductDetailPage));
Terug navigeren
// Eén stap terug
await Shell.Current.GoToAsync("..");
// Twee stappen terug
await Shell.Current.GoToAsync("../..");
Absolute vs. relatieve routes
Dit is een punt dat veel ontwikkelaars in de war brengt (ik heb het zelf ook even moeten uitzoeken). Er zijn twee soorten routes:
- Relatieve routes — voegen een pagina toe aan de huidige navigatiestack:
await Shell.Current.GoToAsync("productdetail"); - Absolute routes — resetten de volledige navigatiestack met het
//-prefix:await Shell.Current.GoToAsync("//dashboard");
Gebruik absolute routes wanneer je de gebruiker naar een compleet andere sectie stuurt, bijvoorbeeld na het inloggen. Relatieve routes zijn ideaal voor drill-down navigatie binnen een sectie.
// Na succesvol inloggen: reset de stack en ga naar dashboard
await Shell.Current.GoToAsync("//dashboard");
// Vanuit een lijst: drill-down naar detail (relatief)
await Shell.Current.GoToAsync(nameof(ProductDetailPage));
Data doorgeven met string query parameters
De simpelste manier om data mee te geven bij navigatie? Query parameters in de URL — precies zoals je dat kent van webapplicaties.
// Navigeer met een product-ID
await Shell.Current.GoToAsync($"{nameof(ProductDetailPage)}?productId={product.Id}");
Data ontvangen met IQueryAttributable
De aanbevolen manier om query parameters te ontvangen is via de IQueryAttributable-interface. Dit is de enige methode die trim-safe én NativeAOT-compatibel is — en dat is essentieel voor productie-apps in .NET 10.
public partial class ProductDetailViewModel : ObservableObject, IQueryAttributable
{
[ObservableProperty]
private Product _product;
private readonly IProductService _productService;
public ProductDetailViewModel(IProductService productService)
{
_productService = productService;
}
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("productId", out var idObj)
&& int.TryParse(idObj?.ToString(), out var productId))
{
// Laad het product op basis van het ID
Task.Run(async () =>
{
Product = await _productService.GetProductAsync(productId);
});
}
}
}
Even een waarschuwing: gebruik niet het [QueryProperty]-attribuut in productie-apps. Het is niet trim-safe, werkt niet met NativeAOT, en Microsoft raadt het zelf expliciet af. Gebruik altijd IQueryAttributable.
Complexe objecten doorgeven met Dictionary
Soms wil je meer dan alleen een ID doorgeven. Een compleet object bijvoorbeeld. Gelukkig ondersteunt Shell dat via een Dictionary<string, object>:
// Geef een compleet Product-object door
var parameters = new Dictionary<string, object>
{
{ "product", geselecteerdProduct }
};
await Shell.Current.GoToAsync(nameof(ProductDetailPage), parameters);
In het ontvangende ViewModel pak je het object er zo uit:
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("product", out var obj) && obj is Product product)
{
Product = product;
}
}
ShellNavigationQueryParameters voor eenmalig gebruik
Sinds .NET 9 is er een handige toevoeging: ShellNavigationQueryParameters. Het verschil met een gewone Dictionary is belangrijk om te begrijpen:
- Dictionary — data blijft behouden zolang de pagina bestaat. Navigeert de gebruiker terug en weer vooruit? Dan ontvangt de pagina dezelfde data opnieuw.
- ShellNavigationQueryParameters — data wordt automatisch gewist na de navigatie. Perfect voor eenmalige overdracht.
// Gebruik ShellNavigationQueryParameters voor eenmalige data
var navigationParams = new ShellNavigationQueryParameters
{
{ "bestelling", nieuweBestelling }
};
await Shell.Current.GoToAsync(nameof(BestelBevestigingPage), navigationParams);
Dit voorkomt een veelvoorkomend (en frustrerend) probleem waarbij stale data opnieuw wordt geladen bij terug-navigatie. In .NET 10 is dit de aanbevolen aanpak voor transient navigatiedata.
Deep linking op Android configureren
Deep linking is echt een gamechanger. Gebruikers kunnen via een externe URL — vanuit een e-mail, QR-code of webpagina — direct op een specifieke pagina in je app landen. Op Android configureer je dit met Intent Filters.
Stap 1: IntentFilter toevoegen aan MainActivity
// 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 = "mijnapp.nl",
DataPathPrefix = "/product",
AutoVerify = true)]
public class MainActivity : MauiAppCompatActivity
{
}
Stap 2: Inkomende links afhandelen
Verwerk de binnenkomende intent in MauiProgram.cs via lifecycle events:
builder.ConfigureLifecycleEvents(lifecycle =>
{
#if ANDROID
lifecycle.AddAndroid(android =>
{
android.OnCreate((activity, bundle) =>
{
var action = activity.Intent?.Action;
var data = activity.Intent?.Data?.ToString();
if (action == Android.Content.Intent.ActionView
&& data is not null)
{
HandleDeepLink(data);
}
});
android.OnNewIntent((activity, intent) =>
{
var data = intent?.Data?.ToString();
if (data is not null)
{
HandleDeepLink(data);
}
});
});
#endif
});
static void HandleDeepLink(string url)
{
// Parse de URL en navigeer naar de juiste pagina
var uri = new Uri(url);
if (uri.AbsolutePath.StartsWith("/product/"))
{
var productId = uri.AbsolutePath.Replace("/product/", "");
Shell.Current.Dispatcher.Dispatch(async () =>
{
await Shell.Current.GoToAsync(
$"{nameof(ProductDetailPage)}?productId={productId}");
});
}
}
Stap 3: Digital Asset Links bestand hosten
Om AutoVerify = true te laten werken, moet je een assetlinks.json-bestand hosten op je domein onder https://mijnapp.nl/.well-known/assetlinks.json. Dit bevestigt aan Android dat jouw app geautoriseerd is om links van dat domein af te handelen. Vergeet deze stap niet — zonder dat bestand werkt AutoVerify gewoon niet.
Deep linking op iOS configureren
Op iOS gebruik je Universal Links voor deep linking. Het concept lijkt op Android, maar de implementatie is (uiteraard) anders.
Stap 1: Entitlements configureren
Voeg de Associated Domains-entitlement toe aan Platforms/iOS/Entitlements.plist:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:mijnapp.nl</string>
</array>
Stap 2: Apple App Site Association bestand hosten
Host een apple-app-site-association-bestand op je domein (let op: zonder bestandsextensie):
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.mijnapp.app"],
"paths": ["/product/*"]
}
]
}
}
Stap 3: Inkomende links afhandelen in AppDelegate
// Platforms/iOS/AppDelegate.cs
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
public override bool ContinueUserActivity(
UIApplication application,
NSUserActivity userActivity,
UIApplicationRestorationHandler completionHandler)
{
if (userActivity.ActivityType == NSUserActivityType.BrowsingWeb
&& userActivity.WebPageUrl is not null)
{
HandleDeepLink(userActivity.WebPageUrl.ToString());
}
return true;
}
private static void HandleDeepLink(string url)
{
var uri = new Uri(url);
if (uri.AbsolutePath.StartsWith("/product/"))
{
var productId = uri.AbsolutePath.Replace("/product/", "");
Shell.Current.Dispatcher.Dispatch(async () =>
{
await Shell.Current.GoToAsync(
$"productdetail?productId={productId}");
});
}
}
}
Een testbare navigatieservice bouwen voor MVVM
In een productie-app wil je navigatielogica niet rechtstreeks in je ViewModels aanroepen met Shell.Current.GoToAsync. Dat maakt je ViewModels haast onmogelijk te testen. De oplossing is simpel: zet een interface voor de navigatieservice.
De interface definiëren
public interface INavigatieService
{
Task NaarAsync(string route);
Task NaarAsync(string route, IDictionary<string, object> parameters);
Task TerugAsync();
}
De Shell-implementatie
public class ShellNavigatieService : INavigatieService
{
public async Task NaarAsync(string route)
{
await Shell.Current.GoToAsync(route);
}
public async Task NaarAsync(string route, IDictionary<string, object> parameters)
{
await Shell.Current.GoToAsync(route, parameters);
}
public async Task TerugAsync()
{
await Shell.Current.GoToAsync("..");
}
}
Registreren in MauiProgram.cs
builder.Services.AddSingleton<INavigatieService, ShellNavigatieService>();
builder.Services.AddTransient<ProductDetailViewModel>();
builder.Services.AddTransient<ProductDetailPage>();
Gebruik in een ViewModel
public partial class ProductenViewModel : ObservableObject
{
private readonly INavigatieService _navigatie;
private readonly IProductService _productService;
public ProductenViewModel(
INavigatieService navigatie,
IProductService productService)
{
_navigatie = navigatie;
_productService = productService;
}
[RelayCommand]
private async Task SelecteerProduct(Product product)
{
var parameters = new Dictionary<string, object>
{
{ "product", product }
};
await _navigatie.NaarAsync(nameof(ProductDetailPage), parameters);
}
}
En nu het mooie: in je unit tests injecteer je gewoon een mock van INavigatieService en verifieer je dat de juiste navigatie-aanroepen plaatsvinden. Geen echte Shell nodig, geen platform-afhankelijkheden. Precies zoals het hoort.
Veelgemaakte fouten (en hoe je ze voorkomt)
Shell navigatie is krachtig, maar er zijn een paar valkuilen waar bijna iedereen wel een keer intrapt.
Fout 1: Dubbele routeregistratie
Registreer je een route die al in de Shell-hiërarchie bestaat? Dan krijg je een ArgumentException. Check dus altijd of een pagina niet al als ShellContent is gedefinieerd voordat je Routing.RegisterRoute aanroept.
Fout 2: CollectionView in ScrollView
Een CollectionView in een ScrollView plaatsen verbreekt niet alleen virtualisatie, maar kan ook rare navigatieproblemen veroorzaken bij het selecteren van items. Gebruik een Grid als container — dat bespaart je een hoop hoofdpijn.
Fout 3: QueryProperty gebruiken met NativeAOT
Het [QueryProperty]-attribuut gebruikt reflectie en is niet compatibel met trimming of NativeAOT. Schakel over naar IQueryAttributable om crashes in productie te voorkomen. Serieus, doe dit meteen als je het nog niet hebt gedaan.
Fout 4: Navigatiedata die terugkomt bij terug-navigatie
Wanneer je een Dictionary gebruikt om data door te geven, wordt die data opnieuw geleverd als de gebruiker terug navigeert en de pagina opnieuw bezoekt. Gebruik ShellNavigationQueryParameters voor eenmalige data, of wis de dictionary handmatig:
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("product", out var obj) && obj is Product product)
{
Product = product;
}
// Voorkom dat data opnieuw wordt geladen bij terug-navigatie
query.Clear();
}
Fout 5: Navigatie buiten de hoofdthread
Shell navigatie moet op de hoofdthread draaien. Navigeer je vanuit een achtergrondtaak? Wrap het dan in Dispatcher.Dispatch:
Shell.Current.Dispatcher.Dispatch(async () =>
{
await Shell.Current.GoToAsync(nameof(ResultatenPage));
});
Navigatie-events monitoren
Shell biedt twee handige events: Navigating en Navigated. Ideaal voor logging, analytics, of het conditioneel blokkeren van navigatie (denk aan onopgeslagen wijzigingen).
// In AppShell.xaml.cs
public AppShell()
{
InitializeComponent();
Navigating += OnNavigating;
Navigated += OnNavigated;
}
private void OnNavigating(object sender, ShellNavigatingEventArgs e)
{
// Voorkom navigatie onder bepaalde condities
if (HeeftOnopgeslagenWijzigingen && e.Source == ShellNavigationSource.Pop)
{
e.Cancel();
// Toon een bevestigingsdialoog aan de gebruiker
}
}
private void OnNavigated(object sender, ShellNavigatedEventArgs e)
{
// Log navigatie voor analytics
Debug.WriteLine($"Genavigeerd naar: {e.Current?.Location}");
}
Veelgestelde vragen
Wat is het verschil tussen Shell.GoToAsync en NavigationPage.PushAsync?
GoToAsync is route-gebaseerd en maakt deel uit van het Shell-navigatiesysteem. Het ondersteunt URI-paden, query parameters en deep linking out of the box. PushAsync is stack-gebaseerd en werkt alleen met NavigationPage. Voor nieuwe .NET MAUI-projecten is Shell met GoToAsync de aanbevolen aanpak.
Kan ik Shell navigatie en NavigationPage combineren?
Technisch kan het, maar ik zou het sterk afraden. De twee navigatiemodellen werken fundamenteel anders en het combineren leidt tot onvoorspelbaar gedrag. Kies één aanpak en blijf daarbij.
Hoe test ik deep linking lokaal op Android?
Met ADB kun je eenvoudig een deep link simuleren: adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://mijnapp.nl/product/123". Dit opent je app alsof de gebruiker op die link heeft geklikt. Super handig tijdens development.
Waarom werkt mijn QueryProperty niet met NativeAOT?
[QueryProperty] gebruikt reflectie om properties te binden, en reflectie wordt verwijderd bij trimming en NativeAOT-compilatie. Implementeer in plaats daarvan IQueryAttributable — dat is compile-time safe en volledig compatibel met NativeAOT in .NET 10.
Hoe voorkom ik dat navigatiedata opnieuw wordt geladen bij terug-navigatie?
Gebruik ShellNavigationQueryParameters in plaats van een gewone Dictionary<string, object>. Die parameters worden automatisch gewist na de navigatie. Als alternatief kun je query.Clear() aanroepen in je ApplyQueryAttributes-methode.