OutWit.Common.MVVM.Navigation
1.1.0
dotnet add package OutWit.Common.MVVM.Navigation --version 1.1.0
NuGet\Install-Package OutWit.Common.MVVM.Navigation -Version 1.1.0
<PackageReference Include="OutWit.Common.MVVM.Navigation" Version="1.1.0" />
<PackageVersion Include="OutWit.Common.MVVM.Navigation" Version="1.1.0" />
<PackageReference Include="OutWit.Common.MVVM.Navigation" />
paket add OutWit.Common.MVVM.Navigation --version 1.1.0
#r "nuget: OutWit.Common.MVVM.Navigation, 1.1.0"
#:package OutWit.Common.MVVM.Navigation@1.1.0
#addin nuget:?package=OutWit.Common.MVVM.Navigation&version=1.1.0
#tool nuget:?package=OutWit.Common.MVVM.Navigation&version=1.1.0
OutWit.Common.MVVM.Navigation
ViewModel-first navigation for MVVM applications — the regions, navigation and dialog part of Prism, without the container, the statics and the region adapters. This is the core package: contracts and the platform-neutral implementation.
| Package | What it adds |
|---|---|
| OutWit.Common.MVVM.Navigation | contracts, navigation service, journal, guards, zones, dialogs |
| OutWit.Common.MVVM.Navigation.Avalonia | view locator, NavigationOutlet control, window/overlay dialog hosts |
| OutWit.Common.MVVM.Navigation.WPF | the same for WPF: locator + template selector, outlet control, ViewPresenter, modal-window host |
| OutWit.Common.MVVM.Navigation.Modules | UI modules loaded from a folder or compiled in |
| OutWit.Common.MVVM.Navigation.Avalonia.DialogHost | optional: dialogs through DialogHost.Avalonia, for Material.Avalonia applications |
Runnable sample, one set of view models bound from both frameworks: Sample.Core · Avalonia · WPF.
Concepts
| Concept | What it is | Prism equivalent |
|---|---|---|
| Outlet | a named place showing one view model, with a journal | region + RequestNavigate |
| Route | key → view model type, creation mode, default outlet, metadata | RegisterForNavigation |
| Group | a named set of routes with a default; navigating to it reopens the page last shown | — |
| NavigationParameters | immutable parameter set | NavigationParameters |
| NavigationContext | outlet, route, parameters and why (New/Back/Forward/Refresh) | NavigationContext |
| Guard | the right to refuse — on a view model, or a global service | IConfirmNavigationRequest |
| Zone | a named, ordered, observable collection of contributions | a region used as region.Add() |
| ContributionItem | a module's menu item / nav bar entry / toolbar button | a view added to a region |
| Dialog | a modal view model with a typed result | IDialogService |
| Progress dialog | a long operation behind a modal, with delay and minimum-duration rules | RunLongProcess-style helpers |
Three principles: view models, never views, drive navigation; there is no service locator and no static entry point; everything is asynchronous and cancellable.
Quick start
services.AddNavigation(nav => // once — a second call replaces the first
{
nav.AddOutlet("Inspector"); // Main is there by default
nav.AddRoute<StudiesViewModel>(Routes.STUDIES);
nav.AddRoute<StudyViewModel>(Routes.STUDY, NavigationRouteMode.Transient);
nav.AddGuard<LicenseGuard>(); // asked about every navigation
nav.HistoryDepth = 20;
});
services.AddAvaloniaNavigation(o => o.UseOverlayDialogs()); // or AddWpfNavigation
var provider = services.BuildServiceProvider();
provider.ValidateNavigation(throwOnProblems: Debugger.IsAttached);
await provider.GetRequiredService<INavigationService>().NavigateAsync(Routes.STUDIES);
public class StudyViewModel : ViewModelBase<ApplicationViewModel>, INavigationAware, INavigationGuard
{
public StudyViewModel(ApplicationViewModel app, IStudyService studies) : base(app) { ... }
public async Task OnNavigatedToAsync(NavigationContext context, CancellationToken cancellation)
{
// the screen is already visible; the token trips if the user moves on
Study = await m_studies.LoadAsync(context.Parameters.Get<int>("id"), cancellation);
}
public Task OnNavigatedFromAsync(NavigationContext context, CancellationToken cancellation) => Task.CompletedTask;
public Task<bool> CanNavigateToAsync(NavigationContext context, CancellationToken cancellation) => Task.FromResult(true);
public async Task<bool> CanNavigateFromAsync(NavigationContext context, CancellationToken cancellation)
{
if (!IsDirty) return true;
var answer = await m_dialogs.ShowAsync<DiscardChangesViewModel, bool>(cancellation: cancellation);
return answer.IsConfirmed && answer.Value;
}
}
View models are created with ActivatorUtilities — their dependencies come from DI, the view
models themselves are not registered. Cached routes keep one instance per outlet for the
life of the application; Transient routes get a fresh instance in its own DI scope for every
navigation, and the instance and the scope are disposed when the next one is shown.
How a navigation runs
- Route and outlet are resolved; unknown →
RouteNotFound/OutletNotFound. - Already showing the route with equal parameters (Cached, new navigation) →
Unchanged. - The outlet's slot is taken. A navigation still before its point of no return is displaced
(
Cancelled); one past it is waited for. - Global guards are asked
CanNavigateFromAsync, then the current view model. - Global guards are asked
CanNavigateToAsync— before the target view model exists. - The target view model is created (or taken from the cache) and asked
CanNavigateToAsync. - Point of no return. From here the navigation commits.
- Current view model:
OnNavigatedFromAsync. Outlet content, route, parameters and journal change. The previous Transient view model is disposed.Navigatedis raised. - The slot is released. Only then does the target's
OnNavigatedToAsyncrun.
Step 9 is the one worth knowing about. A screen that loads for two seconds does not hold the
outlet for two seconds: it is already on screen, the navigation bar is live, and the next
navigation can start immediately. When it does, the abandoned screen's token is cancelled — so
OnNavigatedToAsync should pass it to whatever it awaits, and a navigation that is superseded
mid-load reports Cancelled while the newer one reports Success.
The whole pipeline runs on the UI thread; NavigateAsync may be called from anywhere.
Navigating from inside a navigation
Redirecting from OnNavigatedToAsync — "this screen decided you belong elsewhere" — works,
including when awaited, because the slot is already free by then:
public async Task OnNavigatedToAsync(NavigationContext context, CancellationToken cancellation)
{
if (!m_session.IsSignedIn)
await m_navigation.NavigateAsync(Routes.SIGN_IN); // fine
}
A guard cannot do that for its own outlet: guards run while the outlet is held, so such a
call would wait for a slot its own caller owns. It is refused with Failed and a logged
explanation rather than deadlocking. Guards may navigate other outlets freely.
Zones and contributions
contributions.Add(new ContributionItem
{
Zone = Zones.NAVIGATION_BAR,
Key = "Summary",
Order = 200,
Header = Resources.Summary,
Icon = "ChartBox",
RouteKey = SummaryRoutes.GENERAL
});
<ItemsControl ItemsSource="{Binding NavigationBar.Items}" />
An item with a RouteKey gets a Command that navigates; IsSelected follows what the
outlet shows; ParentKey nests items into menus whichever order the modules arrive in.
Presentation state (Header, IsEnabled, IsChecked, …) belongs to the module and notifies.
Route groups
A section of a navigation bar usually has several pages, and it should open on the one the user was last at — not always on the first. A group is a named set of routes with a default, and its key is an ordinary navigation key:
routes.RegisterGroup("record-info", RecordRoutes.GENERAL,
new[] { RecordRoutes.GENERAL, RecordRoutes.DIARY });
contributions.Add(new ContributionItem
{
Zone = Zones.NAVIGATION_BAR,
Key = "RecordInfo",
RouteKey = "record-info" // the group, not a page
});
await navigation.NavigateAsync("record-info"); // last page shown, or General
Navigating to a group opens the page of it last shown in that outlet, with the parameters it was shown with — or the default when the outlet has not shown one yet. The service remembers on every committed navigation, whatever brought the page in: going Back to a page is being at that page. A refused navigation remembers nothing. The section's item is selected for any page of its group, so it stays lit while the user moves inside the section.
The memory is not the journal: ClearHistory leaves it alone, because where a section was
left is not history. ForgetGroup(key) resets one group to its default, ForgetGroup() all
of them; both take an outlet, and null means every outlet. ResolveGroup(key) tells what a
navigation would open, for hints and tests. NavigationResult.RequestedKey is the key the
caller asked for — the group — and RouteKey the page it resolved to.
Rules: route keys and group keys share one namespace, and a group lists routes only — the
registry throws on both, in either order. A group only grows: AddToGroup before the owner's
RegisterGroup creates it with that route as default, and the declaration keeps every member
already added, because modules register in whichever order they load and never unload. The
group's own Outlet is where its key navigates when the caller names none, exactly as a
route's is; the members' outlets do not enter into it.
Dialogs
public class RenameViewModel : NotifyPropertyChangedBase, IDialogAware<string>
{
public event DialogCloseRequestedEventHandler<string>? CloseRequested;
public Task OnOpenedAsync(NavigationParameters parameters, CancellationToken cancellation) { ... }
public Task<bool> CanCloseAsync(DialogResult<string> result, CancellationToken cancellation) => Task.FromResult(true);
private void Ok() => CloseRequested?.Invoke(DialogResult<string>.Confirmed(Name));
private void Cancel() => CloseRequested?.Invoke(DialogResult<string>.Cancelled());
}
var result = await dialogs.ShowAsync<RenameViewModel, string>(new NavigationParameters(("name", current)));
if (result.IsConfirmed) Rename(result.Value);
Every close attempt — the view model's request, the window's close button, a click on the
overlay backdrop, IDialogService.Close() — goes through CanCloseAsync. Cancellation through
the token does not ask. Whether dialogs nest is the host's property: windows do, an overlay
layer does not.
The base class is not decoration: a dialog's view is built before OnOpenedAsync runs, so
whatever that method sets reaches the screen as a change notification. A [Notify] property on
a class that does not implement INotifyPropertyChanged binds once to its default and then
goes quiet, with no error anywhere.
Long operations
A progress dialog is a dialog with timing rules, so it has its own contract rather than being something every screen re-implements:
var result = await progress.RunAsync(async (reporter, cancellation) =>
{
for (var step = 1; step <= total; step++)
{
cancellation.ThrowIfCancellationRequested();
await ImportAsync(step, cancellation);
reporter.Report($"Importing {step} of {total}…", step / (double)total);
}
return total;
}, new ProgressOptions { Title = "Import" });
if (result.IsCompleted) Show($"imported {result.Value}");
else if (result.IsCancelled) Show("cancelled");
else Show(result.Error!.Message);
The two durations are the point. An operation that finishes within Delay (400 ms by default)
never shows a dialog at all; one that does show it keeps it up for at least MinimumDuration
(600 ms), so a borderline operation does not flash. RunAsync never throws at the caller — a
failure comes back as Error.
Cancel, Escape and a click on the backdrop all mean the same thing: ask the operation to stop.
The dialog stays up until it actually has, so a screen never appears before its work has let
go of whatever it was holding. The work itself runs on the calling context — an operation that
would block the UI thread must do its own Task.Run, because no dialog can repaint a thread
that is busy.
The platform packages ship a plain view for it; register your own for ProgressDialogViewModel
and everything else stays the same.
Start-up validation
provider.ValidateNavigation() runs after the modules have initialized and reports, as logged
warnings and a returned list:
- a route whose outlet nobody declared;
- a route with no view (asking the platform's
IViewFactory, so the naming convention counts); - a contribution pointing at an unregistered route or an undeclared outlet;
- a route view model carrying
[Notify]withoutINotifyPropertyChanged— the silent one.
throwOnProblems: true under Debugger.IsAttached turns all of that into a start-up failure
instead of something the user finds by clicking.
Testing
The core has no UI dependency. Register DispatcherImmediate (from OutWit.Common.MVVM) as
IDispatcher — AddNavigation does so when nothing else is registered — and fake
IViewFactory / IDialogHost for dialog tests. See OutWit.Common.MVVM.Navigation.Tests.
License
Apache-2.0. Part of the OutWit ecosystem.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.10)
- OutWit.Common (>= 1.4.0)
- OutWit.Common.Aspects (>= 1.3.4)
- OutWit.Common.MVVM (>= 2.1.0)
-
net8.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.18)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.18)
- OutWit.Common (>= 1.4.0)
- OutWit.Common.Aspects (>= 1.3.4)
- OutWit.Common.MVVM (>= 2.1.0)
-
net9.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.18)
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.18)
- OutWit.Common (>= 1.4.0)
- OutWit.Common.Aspects (>= 1.3.4)
- OutWit.Common.MVVM (>= 2.1.0)
NuGet packages (4)
Showing the top 4 NuGet packages that depend on OutWit.Common.MVVM.Navigation:
| Package | Downloads |
|---|---|
|
OutWit.Common.MVVM.Navigation.Modules
UI modules for OutWit.Common.MVVM.Navigation: plugins (OutWit.Common.Plugins) that register services, routes, views and contributions. Loaded from a folder next to the application, compiled in, or both — the same two-phase shape as every other OutWit plugin axis. |
|
|
OutWit.Common.MVVM.Navigation.Avalonia
Avalonia half of OutWit.Common.MVVM.Navigation: ViewLocator (an IDataTemplate over the view registry and a naming convention), the NavigationOutlet control that keeps views alive across navigations, window and overlay dialog hosts, application resources for modules, and AddAvaloniaNavigation(). |
|
|
OutWit.Common.MVVM.Navigation.Avalonia.DialogHost
Shows OutWit.Common.MVVM.Navigation dialogs through DialogHost.Avalonia, for applications already themed with Material.Avalonia. A separate package so that the navigation packages themselves keep no dependency on it: pick this host with UseDialogHost<DialogHostAvaloniaAdapter>() and nothing else changes. |
|
|
OutWit.Common.MVVM.Navigation.WPF
WPF half of OutWit.Common.MVVM.Navigation: ViewLocator (a DataTemplateSelector and IViewFactory over the view registry and a naming convention), the NavigationOutlet control that keeps views alive across navigations, ViewPresenter for nested content, a modal-window dialog host, and AddWpfNavigation(). Lets a WPF application drop Prism and Unity while staying on WPF. |
GitHub repositories
This package is not used by any popular GitHub repositories.