Runic.Navigation.Wpf
0.12.1
dotnet add package Runic.Navigation.Wpf --version 0.12.1
NuGet\Install-Package Runic.Navigation.Wpf -Version 0.12.1
<PackageReference Include="Runic.Navigation.Wpf" Version="0.12.1" />
<PackageVersion Include="Runic.Navigation.Wpf" Version="0.12.1" />
<PackageReference Include="Runic.Navigation.Wpf" />
paket add Runic.Navigation.Wpf --version 0.12.1
#r "nuget: Runic.Navigation.Wpf, 0.12.1"
#:package Runic.Navigation.Wpf@0.12.1
#addin nuget:?package=Runic.Navigation.Wpf&version=0.12.1
#tool nuget:?package=Runic.Navigation.Wpf&version=0.12.1
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, which connects a single-selection control, such as aTabControl, to a selection among regions or 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.
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 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>();
regions.Main.Start(NavigationTarget.Create<NotesViewModel>()); // throws if the page can't be created
new MainWindow { DataContext = regions }.Show();
}
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 starts 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. - By default, 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, unless the host keeps views (see below).
- 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, and, in a region created withAdmission = NavigationAdmission.RejectWhileBusy, notIsBusy. SetHandlesBrowseBack="False"to handle it yourself.- The host's read-only
IsTransitioningandIsBusyproperties follow the region's, for bindings such as a progress indicator or disabled tabs.
Keeping the views of retained entries
An application whose pages keep state only in their Views, such as the scroll
position of a long list, expanded tree nodes, a DataGrid's sort order and
column widths, or a text box's caret and selection, can let the host keep the
View of each retained entry and show it again on Back or BackTo:
<rn:NavigationHost Region="{Binding Main}" RetainViews="True" />
- The host keeps the entry's
ContentPresenterwith its View andDataContext, and doesn't ask the locator again. A View still belongs to one entry: an instance pushed twice gets two entries and two Views. RetainViewsis inherited. Set asrn:NavigationHost.RetainViews="True"on a window or container, it applies to every host below it, including the hosts inside a kept View; an inner host can set it toFalse. A host that is unloaded and loads again with the same region and current entry shows the same View, so a page with child regions keeps their Views too.- A kept View is dropped when the host sees that its entry has retired, when
the region empties, when the host's
Regionchanges, and when the host presents an entry whileRetainViewsisFalse. The host doesn't dispose Views. An unloaded host doesn't follow its region: Views of entries that retire meanwhile stay until it loads again or is collected. - A kept View leaves and re-enters the visual tree, so its
UnloadedandLoadedevents are raised again. Each retained entry keeps its visual tree, and a locator-built View the entry-scoped services it was given, alive until the host drops it. - When the View had keyboard focus as it left, the host focuses the same element again once it has loaded, unless focus moved to something outside the host meanwhile. In an inactive window it only sets the window's focused element, which WPF restores on activation; it never activates a window.
- Kept Views get their
DataTemplateset on their presenter, so re-entering the tree never builds a new View from the template.
RetainViews is the setting of the host. To decide it for one entry instead,
push the entry with NavigationRequestOptions.RetainView (since 0.11), the
same idea as WPF's JournalEntry.KeepAlive, Prism's KeepAlive or WinUI's
NavigationCacheMode per page:
// Keep the long list's View, with its scroll position, in a host that rebuilds Views.
await regions.Main.PushAsync<OrdersViewModel>(new NavigationRequestOptions { RetainView = true });
// Rebuild this page's View in a host with RetainViews="True".
await regions.Main.PushAsync<ReportViewModel>(new NavigationRequestOptions { RetainView = false });
truekeeps the entry's View while the entry is retained,falsenever keeps it, andnull, the default, followsRetainViews. Push, Replace, Reset andPushForResultrecord it on the entry they create (NavigationEntry.RetainView); initial entries followRetainViews.- The rules above apply to each entry with the value that decides for it. A
host that loads again shows the same View of a current entry that keeps its
View, and turning
RetainViewsoff keeps the Views of entries pushed withRetainView = true. - The option doesn't change the entry's lifetime. A kept View is dropped as
soon as its entry leaves the stack, also while the navigator still waits for
the page's operations declared
RunicOperationRetirement.CancelorDrainbefore it disposes the page.
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.
The clears pass Admission = NavigationAdmission.Supersede, so a region that
rejects requests while busy is still cleared during a request: the clear
supersedes it, or waits for its commit and then asks the guards.
A close while a clear is still asking joins it and asks nothing more. A close
once that clear has ended (IsTransitioning is false again) starts a new
check, even if the first one hasn't returned yet.
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 "Existing models without stacks"). 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. Tabs that keep their own stacks
avoid this: when a page owns the tab regions and selects among them with
NavigationSelector.Selection (see "Tabs with their own stacks"), the close
of the region that shows the page asks every tab. 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. - These dismissals, and the clear when the owner window closes, pass
Admission = NavigationAdmission.Supersede: in a dialog region created withRejectWhileBusy, the user closing a dialog still wins over its own request.
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.
A guard for a whole region, rather than one page, is an
INavigationRegionGuard registered with region.AddDepartureGuard(guard), or
on the navigator for every region (see
Guards for a whole region).
GuardWindowClose clears the region, so it asks these guards too. A navigator
guard is asked once per guarded region that has entries; for a window-wide
question, use WindowCloseConfirmation (below). A precondition registered
with Order = NavigationRegionGuardOrder.BeforeEntries, such as "no
navigation while an operation runs", refuses the close before any page asks.
Asking once per window close
WindowCloseConfirmation (since 0.11) creates a region guard that a window
close asks once, however many guarded regions the window clears:
// Asked when the window closes, once; every other navigation passes.
navigator.AddDepartureGuard(WindowCloseConfirmation.Create(async (departure, token) =>
{
var dirty = await departure.ModelContext.InvokeAsync(() => documents.HasUnsavedChanges, token);
return !dirty || MessageBox.Show(window, "Discard the unsaved changes in this window?", "Close",
MessageBoxButton.OKCancel) == MessageBoxResult.OK;
}));
// Or wrap a guard that also asks on ordinary navigation, such as a ReactiveUI interaction.
navigator.AddDepartureGuard(WindowCloseConfirmation.OncePerClose(
ReactiveNavigationRegionGuard.WithInteraction(shell.ConfirmDiscard, _ => "Discard?", _ => shell.IsDirty)));
- The guard is asked for the first region the close clears. After a yes, the close's other clears depart without asking it; a no keeps the window open, as any veto does. The next close asks again.
Createlets every departure that isn't a window close pass without asking.OncePerCloseasks the wrapped guard as before outside a close.- Register it on the navigator, or on the regions the window guards. A close is recognized by the region the window clears, so a region guard on a child region that the clear closes is asked as usual.
- An
OnCommittedaction of the guard runs when the clear that asked commits, also when a later region's clear then keeps the window open.
Selecting tabs
NavigationSelector connects a TabControl, ComboBox or single-selection
ListBox to navigation. It has two attached properties. Choose by whether a
tab keeps its own pages while another tab is shown:
| Tabs | Property | Choosing a tab |
|---|---|---|
| Each tab keeps its own stack of pages, such as a tabbed page, a navigation rail or document tabs | Selection (since 0.12) |
NavigationSelection.SelectAsync. Asks the tab left as Retain and changes no stack. |
| Existing models with no stacks of their own, such as settings pages | Region |
A borrowed Replace in the region. Creates a new entry for each choice. |
A control takes one of them. Setting both throws InvalidOperationException.
Both modes have the same selection rules:
- The committed state selects the control's item, initially and after programmatic navigation.
- A user choice runs the departure guards. A veto, failure, cancellation or supersession restores the committed selection. The latest choice wins; a late answer for an older choice can't restore it.
- Selecting the committed item again cancels an outstanding choice. Deselection alone changes nothing.
- On unload, the adapter detaches its handlers and cancels its pending request. On reload, it reads the committed state again.
- A
TabControlwith no content template or template selector gets a default template containing aNavigationHost. It keeps entry presentation, per-entry services and View lifetimes. Supplied templates and selectors are kept. - Don't also navigate from a
SelectedItemorSelectedIndexsetter: the adapter owns that coordination. It never owns the items, and never closes a region or a selection. - Multiple selection is not supported. A selection request that throws, or a failed reconciliation, logs event 1087.
Tabs with their own stacks
Make each tab a region owned by the page, and a NavigationSelection the
choice among them (see
Selecting among sibling regions):
public sealed class CustomersPage
{
public CustomersPage(RunicNavigator navigator)
{
List = navigator.CreateRegion<object>(this, NavigationTarget.Create<CustomerListViewModel>());
Reports = navigator.CreateRegion<object>(this, NavigationTarget.Create<ReportsViewModel>());
Tabs = navigator.CreateSelection(this, [List, Reports]);
}
public NavigationRegion<object> List { get; }
public NavigationRegion<object> Reports { get; }
public NavigationSelection Tabs { get; }
}
<TabControl rn:NavigationSelector.Selection="{Binding Tabs}">
<TabItem Header="Customers" />
<TabItem Header="Reports" />
</TabControl>
- The control's items correspond to
Selection.Regionsby position. StaticTabItems map in declaration order.ItemsSource="{Binding Tabs.Regions}"maps one to one and followsAddandRemoveAsync; give it anItemTemplatefor the headers. AnItemsSourceof header models works too, in the same order as the regions. While the item count differs from the region count, a choice is ignored and the committed tab stays selected. - The default content template binds its
NavigationHosttoSelection.Selected, so it presents the current page of the selected tab. - Choosing a tab calls
SelectAsyncfor its region. The current page of the tab left, and of the regions below it, is asked asRetain, so a page with unsaved edits can refuse.LeaveConfirmationasks on a tab switch only withaskOnRetain. A committed switch changes no stack: the tab left keeps its pages and drafts, and selecting it again presents the same page. NoINavigationInitializeor arrival hook runs again. - The control follows a programmatic
SelectAsync,AddorRemoveAsync. - Retiring the page asks every page of every tab as
Retire, whichever tab is selected. So aGuardWindowClosehost of the region that shows the page asks every tab when the window closes, also tabs that were never selected. Own the tab regions and the selection by a page entry, not by the window's ViewModel.
Moving from tab models selected with Region (0.11) to Selection:
- The page owns the tab regions and creates
Tabs = navigator.CreateSelection(this, [a, b, c]). - Tab header models become plain data, or are replaced by static
TabItems. - Replace
rn:NavigationSelector.Region="{Binding Tab}"withrn:NavigationSelector.Selection="{Binding Tabs}". - Delete hand-written "can I leave this tab?" checks next to the pages' departure guards: the switch asks those guards.
Existing models without stacks
NavigationSelector.Region selects existing models, for example the pages of
a settings dialog. Bind a region to a control 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.
A selection's request completes only after the new tab's arrival hook
(INavigationArrived), such as a data load; reselecting the current tab
meanwhile waits only until the pending request's commit is decided, not for
that hook, and selecting another tab replaces the loading one, which cancels
its hook. Clear the region explicitly to make it empty. The items collection
should contain the current model.
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, as Retire. 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. These regions belong to no page entry, so retiring the page that
shows the tabs asks only the selected tab, and a window close asks only the
regions of hosts with GuardWindowClose (see "Tabs and other hosts that
unload"). For tabs with page stacks, prefer Selection.
Which departures ask a child region
lists every case.
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,OnArrivedAsync, factories and owned disposal run on the UI thread, each in its own dispatcher operation, so they can change models and touch controls directly. - As an
IRunicModelPresentationYield, the context waits for the dispatcher'sLoadedpriority, which runs afterRender, before the navigator runs an arrival hook (INavigationArrived): the new page's view is laid out and rendered, with its loading state, before the hook starts loading. - 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. Forward IRunicModelPresentationYield too, or arrival hooks
start before the new view is rendered:
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(NavigationEntry 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.14)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.14)
- Runic.Navigation (= 0.12.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Runic.Navigation.Wpf:
| Package | Downloads |
|---|---|
|
Runic.Application.Views.Wpf
Optional WPF child WebView presentation for existing Runic ViewModels. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.12.1 | 0 | 10/27/2026 |
| 0.12.0 | 0 | 10/27/2026 |
| 0.11.0 | 1,213 | 10/25/2026 |
| 0.10.0 | 2,503 | 10/22/2026 |
| 0.9.1 | 444 | 10/21/2026 |
| 0.9.0 | 130 | 10/21/2026 |
| 0.8.0-preview.6 | 582 | 10/19/2026 |
| 0.8.0-preview.5 | 183 | 10/17/2026 |
| 0.8.0-preview.4 | 469 | 10/17/2026 |
| 0.8.0-preview.3 | 458 | 10/15/2026 |
| 0.8.0-preview.2 | 92 | 10/15/2026 |
| 0.8.0-preview.1 | 117 | 10/15/2026 |
| 0.7.0-preview.9 | 376 | 10/14/2026 |
| 0.7.0-preview.8 | 87 | 10/12/2026 |
| 0.7.0-preview.7 | 64 | 10/12/2026 |
| 0.7.0-preview.6 | 404 | 10/9/2026 |
| 0.7.0-preview.5 | 132 | 10/9/2026 |
| 0.7.0-preview.4 | 76 | 10/8/2026 |