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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="OutWit.Common.MVVM.Navigation" Version="1.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="OutWit.Common.MVVM.Navigation" Version="1.1.0" />
                    
Directory.Packages.props
<PackageReference Include="OutWit.Common.MVVM.Navigation" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add OutWit.Common.MVVM.Navigation --version 1.1.0
                    
#r "nuget: OutWit.Common.MVVM.Navigation, 1.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package OutWit.Common.MVVM.Navigation@1.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=OutWit.Common.MVVM.Navigation&version=1.1.0
                    
Install as a Cake Addin
#tool nuget:?package=OutWit.Common.MVVM.Navigation&version=1.1.0
                    
Install as a Cake Tool

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

  1. Route and outlet are resolved; unknown → RouteNotFound / OutletNotFound.
  2. Already showing the route with equal parameters (Cached, new navigation) → Unchanged.
  3. The outlet's slot is taken. A navigation still before its point of no return is displaced (Cancelled); one past it is waited for.
  4. Global guards are asked CanNavigateFromAsync, then the current view model.
  5. Global guards are asked CanNavigateToAsync — before the target view model exists.
  6. The target view model is created (or taken from the cache) and asked CanNavigateToAsync.
  7. Point of no return. From here the navigation commits.
  8. Current view model: OnNavigatedFromAsync. Outlet content, route, parameters and journal change. The previous Transient view model is disposed. Navigated is raised.
  9. The slot is released. Only then does the target's OnNavigatedToAsync run.

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.

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] without INotifyPropertyChanged — 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 IDispatcherAddNavigation 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.

Version Downloads Last Updated
1.1.0 32 8/27/2026
1.0.0 170 8/23/2026