INavigator
INavigator is host-agnostic. ViewModels depend on the interface, never on Shell.Current or INavigation. Every public method returns Outcome and accepts CancellationToken. Current is Type? (the last ViewModel type), not an object instance.
- NavigateToAsync<TViewModel>() — AOT-friendly typed go.
- NavigateToAsync<TViewModel, TArgs>(TArgs) — record args; destination implements IAcceptNavArgs<T>.
- NavigateToAsync(route, query, options) — URI path + dictionary; destination implements IAcceptNavQuery.
- GoBackAsync, PopToRootAsync, ReplaceAsync<T>, ResetAsync<T> / ReplaceRootAsync<T>.
- Stack, ModalStack, CanGoBack, History.
Shell and page hosts
UseNavigationPage registers MauiPageNavigator as INavigator / IPageNavigator and hops to IMainThread before new Page(). ResetAsync / ReplaceRootAsync replace window.Page with a NavigationPage. UseShell is optional. MauiVisualTree unwraps NavigationPage.CurrentPage so guards see the visible BindingContext. Register one navigator per IWindowContext.
| Choose | When |
|---|---|
| UseNavigationPage + ResetAsync / ReplaceRootAsync | Login → home, chat host, or any app that must drop the back-stack so Back cannot return to login. Replaces window.Page with a NavigationPage. |
| UseShell | Flyout / tab catalog, existing Shell routes, or //home as a root ShellContent (AuthApp). ResetAsync only works when the destination is a root ShellContent. |
| Do not register both | Unless you really have two hosts (two windows). One INavigator per IWindowContext. |
var shell = new MauiShellNavigator()
.Map<ProductListViewModel>("//products")
.Map<ProductDetailsViewModel>("details");
await shell.NavigateToAsync<ProductDetailsViewModel, ProductDetailsArgs>(new(42));
await shell.NavigateToAsync("details", new Dictionary<string, object> { ["ProductId"] = 42 });
IPageNavigator pages = new MauiPageNavigator(new WindowContext("main"), services)
.Map<PageStackViewModel, PageStackPage>("stack")
.Map<PageStackItemViewModel, PageStackItemPage>("stack-item");
await pages.NavigateToAsync("stack-item", new Dictionary<string, object> { ["Title"] = "Latte" });
if (pages.CanGoBack)
await pages.GoBackAsync();
await pages.PopToRootAsync();Typed args and URI query
Accept runs before InitializeAsync / OnNavigatedToAsync. Typed records are the default. URI / dictionary exists for deep links and interop — NavigationRouteTable.FormatQuery / ParseQuery / Split. There is no INavigationParameters type.
public sealed record ProductDetailsArgs(int ProductId);
public sealed class ProductDetailsViewModel : PageViewModel,
IAcceptNavArgs<ProductDetailsArgs>, IAcceptNavQuery
{
private int _productId;
public void Accept(ProductDetailsArgs args) => _productId = args.ProductId;
public void Accept(IReadOnlyDictionary<string, object> query)
{
if (query.TryGetValue(nameof(ProductDetailsArgs.ProductId), out var raw)
&& int.TryParse(Convert.ToString(raw), out var id))
{
_productId = id;
}
}
}Guards and auth
Dirty forms confirm “Discard changes?” via IDialogs when DirtyNavigation is Confirm (default). Tests set DirtyNavigationMode.SilentBlock. UseAuth<TChallenge>() opens the challenge ViewModel on E_AUTH and resumes the original route after IAuthState.Changed. [RequiresAuth] / [RequiresRole] feed INavigationAuthPolicy via the generated ModuleInitializer.
Apps that must not leak a back-stack use ResetAsync / ReplaceRootAsync. On UseNavigationPage that replaces window.Page with a NavigationPage. On Shell, ResetAsync only works when //home is a root ShellContent (see AuthApp). Child ViewModels attach through IViewModelComposer.Attach. Deep-link mapping is a sample DeepLinkRouteMap — compose Plugin.Maui.DeepLinks in production. MVVMExpress does not ship Prism-style regions.