Runic.Navigation.Wpf
0.8.0-preview.3
dotnet add package Runic.Navigation.Wpf --version 0.8.0-preview.3
NuGet\Install-Package Runic.Navigation.Wpf -Version 0.8.0-preview.3
<PackageReference Include="Runic.Navigation.Wpf" Version="0.8.0-preview.3" />
<PackageVersion Include="Runic.Navigation.Wpf" Version="0.8.0-preview.3" />
<PackageReference Include="Runic.Navigation.Wpf" />
paket add Runic.Navigation.Wpf --version 0.8.0-preview.3
#r "nuget: Runic.Navigation.Wpf, 0.8.0-preview.3"
#:package Runic.Navigation.Wpf@0.8.0-preview.3
#addin nuget:?package=Runic.Navigation.Wpf&version=0.8.0-preview.3&prerelease
#tool nuget:?package=Runic.Navigation.Wpf&version=0.8.0-preview.3&prerelease
Runic.Navigation.Wpf
WPF hosts for Runic.Navigation
regions. It depends on Runic.Navigation and the WPF framework only: no
Runic.Application.Views, generator or web View. The package provides
the following types in the Runic.Navigation.Wpf namespace. The XAML namespace
is https://runic-artifex.eu/xaml/navigation, with the prefix rn.
DispatcherModelContext, anIRunicModelContextthat runs model turns and navigation hooks on the WPF dispatcher.NavigationHost, aContentControlthat presents a region's current entry.NavigationDialogHost, which shows each entry of a region in its own owned dialog window.NavigationSelector.Region, which connects a single-selection control to a region, andViewHost, which presents plain content without navigation.AddRunicWpfNavigation(), which registers the navigator on the dispatcher.
The package targets net10.0-windows and isn't trimmable, because WPF isn't.
# Available from 0.7.0-preview.4.
dotnet add package Runic.Navigation.Wpf --prerelease
Quick start
This page builds the core of the WPF navigation example: a notes list, a detail page that asks before losing edits, and a confirm dialog with a typed result. The example adds nested tabs and headless tests, and its comparison sets the same app against Prism, ReactiveUI and CrissCross.
1. Install and register. Add this package and
Microsoft.Extensions.DependencyInjection. Then register the navigator and one
service that holds the window's regions:
public partial class App : Application
{
private ServiceProvider? _services;
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_services = new ServiceCollection()
.AddRunicWpfNavigation(options => options.UseViewNamingConvention()) // NotesViewModel -> NotesView
.AddSingleton<NoteStore>()
.AddSingleton<AppRegions>()
.BuildServiceProvider();
var regions = _services.GetRequiredService<AppRegions>();
new MainWindow { DataContext = regions }.Show();
await regions.Main.ResetAsync<NotesViewModel>();
}
protected override void OnExit(ExitEventArgs e)
{
_services?.Dispose(); // starts disposal without waiting; see "Disposal at exit"
base.OnExit(e);
}
}
2. A region and its host. A region is a navigation stack. ViewModels
inject AppRegions to navigate, and the window binds to it. The regions start
empty, because an initial ViewModel that injects AppRegions would be a DI
cycle; App resets Main instead.
public sealed class AppRegions
{
public AppRegions(RunicNavigator navigator)
{
Main = navigator.CreateRegion<object>(this);
Dialog = navigator.CreateRegion<object>(this);
}
public NavigationRegion<object> Main { get; }
public NavigationRegion<object> Dialog { get; }
}
<Window xmlns:rn="https://runic-artifex.eu/xaml/navigation" ...>
<DockPanel>
<Button DockPanel.Dock="Top" Content="Back"
Command="NavigationCommands.BrowseBack" CommandTarget="{Binding ElementName=Host}" />
<rn:NavigationDialogHost Region="{Binding Dialog}" />
<rn:NavigationHost x:Name="Host" Region="{Binding Main}" />
</DockPanel>
</Window>
NavigationHost shows the current entry with its View, NotesView for
NotesViewModel: Views are plain UserControls, found by name, whose
DataContext is the ViewModel. Back can't execute while a Back is in flight, so a double
click pops once. NavigationDialogHost shows each entry of Dialog in a
window owned by this one.
3. Push a page with a typed input, and ask before losing its edits.
PushAsync<T>() creates the ViewModel from the container. One that
implements INavigationInitialize<TInput> receives its input before it's
shown. INavigationDepartureGuard is asked on every exit: Back, a push from
code, or a parent closing. LeaveConfirmation.InDialog asks in the dialog
region and runs the discard only when the departure commits, so a cancelled or
superseded Back keeps the edits:
// In NotesViewModel
await regions.Main.PushAsync<NoteViewModel, int>(note.Id);
public sealed class NoteViewModel : ObservableObject, INavigationInitialize<int>, INavigationDepartureGuard
{
private readonly NoteStore _store;
private readonly LeaveConfirmation _leave;
private Note _note = new(0, "");
private string _title = "";
public NoteViewModel(NoteStore store, AppRegions regions)
{
_store = store;
_leave = LeaveConfirmation.InDialog(regions.Dialog,
() => NavigationTarget.Create<ConfirmViewModel, string>("Discard unsaved changes?"),
hasUnsavedChanges: () => Title != _note.Title,
discard: () => Title = _note.Title);
}
public string Title { get => _title; set => SetProperty(ref _title, value); }
public ValueTask InitializeAsync(NavigationEntryContext entry, int id, CancellationToken token)
{
_note = _store.Get(id);
Title = _note.Title;
return ValueTask.CompletedTask;
}
public ValueTask<bool> CanDepartAsync(NavigationDeparture departure, CancellationToken token) =>
_leave.CanDepartAsync(departure, token);
}
4. A dialog with a result. Push the dialog with PushForResult<TResult>
and await its completion. The navigator constructs the dialog and passes its
entry to the constructor; the dialog answers through it:
// In NotesViewModel
var answer = await regions.Dialog.PushForResult<bool>(
NavigationTarget.Create<ConfirmViewModel, string>($"Delete '{note.Title}'?")).Completion;
if (answer is NavigationCompletion<bool>.Completed { Value: true })
store.Delete(note.Id);
public sealed class ConfirmViewModel : ObservableObject, INavigationInitialize<string>
{
public ConfirmViewModel(NavigationEntryContext entry)
{
YesCommand = new AsyncRelayCommand(() => entry.CompleteAsync(true).AsTask());
NoCommand = new AsyncRelayCommand(() => entry.DismissAsync().AsTask()); // Esc and the close button dismiss too
}
public string Message { get; private set; } = "";
public IAsyncRelayCommand YesCommand { get; } // bound to the OK button
public IAsyncRelayCommand NoCommand { get; } // bound to Cancel
public ValueTask InitializeAsync(NavigationEntryContext entry, string message, CancellationToken token)
{
Message = message;
return ValueTask.CompletedTask;
}
}
The same ConfirmViewModel serves the guard in step 3: only Completed(true)
leaves. To push a ViewModel you built, use NavigationTarget.Own<object>(instance);
for a factory, NavigationTarget.Create<object>(services => ...), or
NavigationTarget.Create<object>((services, entry) => ...) to pass the entry
to the constructor. With the
default singleton navigator, each container-built entry gets its own service
scope, which is disposed when the entry retires.
Registration
AddRunicWpfNavigation(configure) calls AddRunicNavigation() and replaces two
of its registrations:
IRunicModelContextbecomes a singletonDispatcherModelContext. It usesRunicWpfNavigationOptions.Dispatcher, orApplication.Current.Dispatcherwhen it's first resolved. Without either, resolution throwsInvalidOperationException. It never creates a dispatcher on the resolving thread.RunicNavigatorgetsNavigatorLifetime(Singletonby default, orScopedfor one navigator per window scope).Transientthrows.CreateEntryScopesdefaults totruefor a singleton navigator.Scopedcombined withCreateEntryScopes = truethrows.
The package assumes one UI thread. An app with a dispatcher per window
thread builds one DispatcherModelContext and one navigator per thread itself.
Presenting a region
NavigationHost creates a new ContentPresenter and View for each entry,
also for two entries of the same type or one borrowed instance pushed twice.
Text, scroll position and selection therefore never leak from one entry to the
next. It looks for a View in this order:
- the host's
ViewLocator; - the
INavigationViewLocatorregistered in the navigator's services, whichMapView<TViewModel, TView>()andUseViewNamingConvention()add; - the implicit
DataTemplatefor the content's type or a base type. WPF doesn't match templates by interface.
When none presents the content, the host logs event 1082 once per type.
- A locator View is created from the entry's services. The first constructor
parameter whose type is a class the content can be assigned to, meaning the
content's own type or one of its base classes but never
object, gets the entry's content, never another instance from the container. SoDocumentView(DocumentViewModel model)binds to the pushed ViewModel. Interface andobjectparameters, such asINotifyPropertyChangedorIDisposable, and all other parameters come from the entry's services, also when the content implements them. When several constructors take the content, the one marked[ActivatorUtilitiesConstructor]wins, then, as withActivatorUtilities, the longest one whose other parameters are all registered services (keyed with[FromKeyedServices]) or have default values.[ServiceKey]parameters aren't supported. A View without a content parameter is created withActivatorUtilities. The View'sDataContextis the content unless the View sets one. UseViewNamingConvention()mapsFooViewModeltoFooView, thenFooPage, in the same assembly, also from a.ViewModelsnamespace to.Views. When the ViewModels live in a separate assembly, pass the View assemblies:UseViewNamingConvention(typeof(MainWindow).Assembly). There a View in another namespace matches by name when exactly one View type has it. The convention uses reflection; preferMapViewfor an app you trim.- The host sets its own
Content. Don't setContent,ContentTemplateorContentTemplateSelectoron it; present entries with implicitDataTemplates or a locator. - Retention keeps the ViewModel, not the View. On Back the host builds a new View for the retained ViewModel, so state that must survive belongs in the ViewModel.
- An unloaded host unsubscribes and never touches entries. It reads the
region again when it's loaded. With
GuardWindowClose, the window still clears the region when it closes (see below). NavigationCommands.BrowseBack(Alt+Left, the mouse back button) goes back whileCanGoBackand notIsTransitioning. SetHandlesBrowseBack="False"to handle it yourself.
Closing the window
Disposing the services on exit retires entries without asking their
guards, so a dirty draft would be lost. Set GuardWindowClose on the main
window's host to ask the same guards as navigation before the window closes:
<rn:NavigationDialogHost Region="{Binding Dialog}" />
<rn:NavigationHost Region="{Binding Main}" GuardWindowClose="True" />
While the region has entries, closing the window (close button, Alt+F4,
Close()) is cancelled, and the region is cleared. Clear asks the
guard of every entry, top down, and of the child regions they own, as any
other navigation would. When the clear commits, the window closes; when a
guard rejects, the window stays open with its entries. A guard can confirm with
LeaveConfirmation.InDialog through a dialog host in the same window.
Set it on each host whose region needs a say. The window clears their regions one after another, in the order the hosts first loaded, and stops at the first clear that doesn't commit.
WPF ignores a cancelled close during Application.Shutdown() and doesn't
raise Closing when the Windows session ends, so neither asks the guards.
Close the main window instead of calling Shutdown(), and handle
Application.SessionEnding yourself if drafts must survive a log-off.
A failed guard also keeps the window open. If the clear request itself throws,
event 1088 is logged.
Tabs and other hosts that unload
The guard belongs to the window, not to the loaded host. When a host loads
with GuardWindowClose, its window starts guarding the region, and keeps
guarding it while the host is unloaded or discarded. So a page region inside a
tab is asked when the window closes even while another tab is selected:
<rn:NavigationDialogHost Region="{Binding Dialog}" />
<TabControl ItemsSource="{Binding Tabs}"
rn:NavigationSelector.Region="{Binding Tab}" DisplayMemberPath="Title" />
<rn:NavigationHost Region="{Binding Pages}" GuardWindowClose="True" />
- The user edits a customer on the Customers tab, then selects Reports.
When the Customers model owns
Pages, the edit page's guard is asked as a retained departure (see "Selecting tabs and other existing models"). ALeaveConfirmationwithoutaskOnRetainallows it: the edit page stays the region's current entry with its draft, and its View is discarded. - The user closes the window. The window clears the Customers page region, so the edit page's guard asks; "keep" leaves the window open, "discard" closes it.
A window guards a region from the first time a guarding host loads with it,
so a tab that was never selected isn't asked. It stops when the region
closes, when the window closes, when a guarding host loads with the region in
another window (the region follows the host), or when a host of the region
sets GuardWindowClose to false. A host whose Region changes keeps the
previous region guarded, because that region and its drafts live on. The
window holds no host, and holds its regions weakly, so neither a discarded
host nor a closed child region is kept alive, and a region keeps no closed
window alive.
Dialogs
NavigationDialogHost is an invisible element that you place in the owner
window. It shows one owned window per entry of its region, bottom to top. It
uses Show() and never ShowDialog(), so a dialog doesn't run a nested
message loop:
Modality="Application"(the default) disables the thread's other visible top-level windows while a dialog is open, asShowDialogdoes.Modality="Owner"disables only the owner chain. Dialog hosts share one disable count per window, so a host never re-enables a window that another host or the app disabled.WindowStylestyles each dialog window: title, chrome, size. Templates come from the host's position, because a dialog window doesn't inherit the owner's resources.- When the host isn't inside a
Window(for example in aPopup), it can't own dialog windows and logs event 1086 once. - Closing a dialog (the close button, Alt+F4, or Esc with
CloseOnEscape) goes Back in the region, or clears a single dialog. The dialog's guards run, and a veto keeps the window open. - When the owner window closes, the host releases the windows it disabled and clears the region once.
A dialog window's DataContext is the entry's content, so the style can bind
the title to the ViewModel:
<rn:NavigationDialogHost Region="{Binding Dialog}">
<rn:NavigationDialogHost.WindowStyle>
<Style TargetType="Window">
<Setter Property="Title" Value="{Binding Title}" />
<Setter Property="ResizeMode" Value="NoResize" />
</Style>
</rn:NavigationDialogHost.WindowStyle>
</rn:NavigationDialogHost>
Return a result through the entry. A dialog pushed with
PushForResult<TResult> completes with entry.CompleteAsync(value) and
cancels with entry.DismissAsync(), using the NavigationEntryContext its
constructor received.
IsDefault and IsCancel buttons work as in a ShowDialog() window, so
a migrated dialog keeps Enter to accept:
<Button Content="Save" IsDefault="True" Command="{Binding SaveCommand}" />
<Button Content="Cancel" IsCancel="True" />
- Enter clicks the
IsDefaultbutton, which runs its command, unless the focused control handles Enter itself (aTextBoxwithAcceptsReturn). - An
IsCancelbutton without a command dismisses the dialog, like the close button. With a command, the button just runs it. - Esc dismisses the dialog while
CloseOnEscapeis on (the default). WithCloseOnEscape="False", Esc clicks theIsCancelbutton instead.
Don't set Window.DialogResult: it throws on a window shown with Show().
Complete or dismiss through the entry instead.
Asking before leaving
A guard can ask with a dialog in the dialog region through
LeaveConfirmation.InDialog. Only Completed(true) confirms:
public sealed class DocumentViewModel : INavigationDepartureGuard
{
private readonly LeaveConfirmation _leave;
public DocumentViewModel(AppRegions regions) =>
_leave = LeaveConfirmation.InDialog(regions.Dialog,
() => NavigationTarget.Create<ConfirmViewModel, string>("Discard the unsaved edits?"),
() => IsDirty, DiscardChanges);
public ValueTask<bool> CanDepartAsync(NavigationDeparture departure, CancellationToken token) =>
_leave.CanDepartAsync(departure, token);
}
The guard runs on the UI thread, and so do its awaits. It doesn't pump. A
guard may also call MessageBox.Show and return the answer, but other
regions can then commit through the message box's nested loop.
Selecting tabs and other existing models
The optional selector adapter uses the same engine with either Toolkit or
ReactiveUI ViewModels. Bind a region to a TabControl, ComboBox or
single-selection ListBox whose items are existing models assignable to the
region's content type and owned by the application or its container:
<TabControl ItemsSource="{Binding Tabs}"
rn:NavigationSelector.Region="{Binding Tab}" DisplayMemberPath="Title" />
The committed Current selects the initial item. User selection requests a
borrowed Replace, so it runs departure guards and builds no Back history.
Keep these items borrowed throughout their navigation lifetime. Do not use
models from navigator-owned entries as selector items: retiring their owning
entry can dispose the model while another borrowed entry still presents it.
Each successful replacement creates a fresh entry, so a reusable model's
INavigationInitialize hook runs for each entry. Keep persistent tab state in
the model and avoid resetting it merely because it was selected again.
The core serializes a region's guards: if a cancelled hook ignores its token,
the next choice waits for that hook to finish.
Vetoes, failures and cancellation restore the committed selection; a late
superseded response cannot restore an older choice. Programmatic navigation
also updates selection. Selecting the current item cancels an outstanding
choice, and deselection alone does not clear navigation. Clear the region
explicitly to make it empty. The items collection should contain the current
model. Multiple selection is not supported.
A TabControl with no application content template gets a default template
containing NavigationHost. It retains entry presentation, per-entry services
and view lifetime semantics. Supplied templates and template selectors are
preserved. Do not also navigate from a SelectedItem property setter: the
adapter owns that coordination. On unload it detaches handlers and cancels its
pending request; on reload it reads the committed region again. The adapter
never owns the items or closes the region.
Selecting another tab is a departure from the tab's page regions. The
selection asks the current page of each region the tab's model owns, and of
the regions below it, as a retained departure, then the guard of the tab's
own model. A guard that keeps its page keeps the tab selected. The page
regions keep their entries, and their drafts, while another tab is selected.
A region the tab only refers to, owned by the application or another model,
is not asked, so create a tab's page regions with the tab's model as their
owner. A LeaveConfirmation asks on a tab switch only with askOnRetain.
Which departures ask a child region
lists every case. To ask the pages when the window closes, set
GuardWindowClose on their host (see "Tabs and other hosts that unload").
Plain content without navigation
For content with no entry, guard or navigation lifetime, use ViewHost:
<rn:ViewHost Content="{Binding Inspector}" Services="{Binding ViewServices}" />
ViewServices is the application's service provider. The host asks its own
ViewLocator, then the registered INavigationViewLocator, then implicit WPF
templates. The registered MapView/naming-convention locator also constructs
plain views, using the supplied provider for dependencies and the content for
a constructor parameter of its class or a base class. Services is optional
when using an explicit locator or implicit templates.
Custom locators implement ResolveView(object content, IServiceProvider services)
for plain content. Its default returns null, so an existing entry-only locator
still works with navigation hosts and falls back for plain content. No fake
entry or entry scope is created. ViewHost creates a fresh presentation when
content changes or it reloads, clears stale content on construction failure,
and never disposes the model or provider. The host manages its Child.
Use NavigationHost for a navigation entry's lifecycle and per-entry services;
use ViewHost when the application owns a plain model and its presentation.
Both are WPF integrations; neither adds a second navigation engine or requires
a particular MVVM framework.
Threading
DispatcherModelContext treats all UI-thread code as inside a model turn:
InvokeAsyncon the UI thread runs the turn inline. From another thread it queues the turn on the dispatcher at the context's priority.- Guards,
InitializeAsync,ResumeAsync, factories and owned disposal run on the UI thread, each in its own dispatcher operation, so they can change models and touch controls directly. - A turn must not pump: no
ShowDialog,MessageBoxor.Wait()insideInvokeAsync, a commit'sPropertyChangedhandler or anOnCommittedaction. A turn that starts inside another turn's nested loop is logged once as event 1081. - Never block the UI thread on navigation with
.Resultor.Wait(): the commit needs the dispatcher. - A posted turn that throws goes to
UnhandledTurnException, or is logged as event 1080. It never reachesDispatcher.UnhandledException. - The context closes when the dispatcher starts shutting down, and tells the
navigator through its
Closedtoken. Requests then endRejected(Closed)at once, and transitions in flight are cancelled. - A hook that is still awaiting when the dispatcher shuts down continues on the thread pool, so disposing the navigator doesn't hang. Turns that the close drops are reported on the thread that closes the context.
Decorators must forward the hook scheduler and the close signal. A context
that wraps DispatcherModelContext must implement IRunicModelHookScheduler
and IRunicModelContextLifetime and forward them. Otherwise hooks run on the
thread pool instead of the UI thread, and the navigator learns about a shutdown
only when a turn fails, so a request can wait behind a hook that ignores its
cancellation:
public sealed class TracingContext(DispatcherModelContext inner)
: IRunicModelContext, IRunicModelHookScheduler, IRunicModelContextLifetime
{
// ... forward the IRunicModelContext members ...
public CancellationToken Closed => inner.Closed;
public Task<T> RunHookAsync<T>(Func<Task<T>> hook, CancellationToken cancellationToken) =>
inner.RunHookAsync(hook, cancellationToken);
}
Disposal at exit
Synchronous, as in the quick start: ServiceProvider.Dispose() in
OnExit, also common in Generic Host WPF templates, calls
RunicNavigator.Dispose(). That starts the navigator's disposal and returns
without waiting, so owned cleanup may not finish before the process exits.
Asynchronous, when owned content must finish its cleanup: dispose the
provider after the dispatcher stops, so the context is closed and the navigator
retires the remaining entries at once. Do it after app.Run() in a custom
Main (set the App.xaml build action to Page), or through the Generic
Host's StopAsync:
[STAThread]
public static void Main()
{
var services = new ServiceCollection().AddRunicWpfNavigation() /* ... */.BuildServiceProvider();
new App(services).Run();
services.DisposeAsync().AsTask().GetAwaiter().GetResult(); // the dispatcher has stopped
}
Never block the UI thread on DisposeAsync while the dispatcher runs, for
example in OnExit: the navigator's turns need the dispatcher.
ReactiveUI views
ReactiveUI apps need no extra package. A locator that asks ReactiveUI's
ViewLocator gives each entry its own View:
public sealed class ReactiveNavigationViewLocator : INavigationViewLocator
{
public FrameworkElement? ResolveView(INavigationEntry entry)
{
if (ViewLocator.Current.ResolveView(entry.Content) is not { } view) return null;
view.ViewModel = entry.Content;
return view as FrameworkElement;
}
}
Set it as the host's ViewLocator, or register it as the
INavigationViewLocator singleton after AddRunicWpfNavigation(). Binding
rxui:ViewModelViewHost to Main.Current also works, but it reuses its View
across contents of the same type.
Logging
The hosts log under the category Runic.Navigation.Wpf. DispatcherModelContext
logs through its ILogger<DispatcherModelContext>, so its category is the full
type name, Runic.Navigation.Wpf.DispatcherModelContext. Without an
ILoggerFactory or logger, warnings and errors go to
System.Diagnostics.Trace.
| Event | Level | Meaning |
|---|---|---|
| 1080 | Error | A posted turn threw and the context has no UnhandledTurnException handler. |
| 1081 | Warning | A turn started inside another turn's nested message loop (once per context). |
| 1082 | Warning | No locator or DataTemplate presents a content type (once per type). |
| 1083 | Error | A dialog window failed to show; the host clears the dialog region once. |
| 1084 | Warning | A posted turn was dropped because the context closed, and it has no handler. |
| 1085 | Error | The UnhandledTurnException handler threw. |
| 1086 | Warning | A dialog host was loaded outside a Window (once per host). |
| 1087 | Error | A selector request or UI reconciliation threw. |
| 1088 | Error | Clearing a GuardWindowClose region threw; the window stays open. |
Events up to 1089 are reserved for the package.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0-windows7.0 is compatible. |
-
net10.0-windows7.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- Runic.Navigation (= 0.8.0-preview.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.8.0-preview.3 | 51 | 10/15/2026 |
| 0.8.0-preview.2 | 70 | 10/15/2026 |
| 0.8.0-preview.1 | 95 | 10/15/2026 |
| 0.7.0-preview.9 | 357 | 10/14/2026 |
| 0.7.0-preview.8 | 80 | 10/12/2026 |
| 0.7.0-preview.7 | 54 | 10/12/2026 |
| 0.7.0-preview.6 | 397 | 10/9/2026 |
| 0.7.0-preview.5 | 123 | 10/9/2026 |
| 0.7.0-preview.4 | 68 | 10/8/2026 |