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
                    
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="Runic.Navigation.Wpf" Version="0.12.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Runic.Navigation.Wpf" Version="0.12.1" />
                    
Directory.Packages.props
<PackageReference Include="Runic.Navigation.Wpf" />
                    
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 Runic.Navigation.Wpf --version 0.12.1
                    
#r "nuget: Runic.Navigation.Wpf, 0.12.1"
                    
#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 Runic.Navigation.Wpf@0.12.1
                    
#: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=Runic.Navigation.Wpf&version=0.12.1
                    
Install as a Cake Addin
#tool nuget:?package=Runic.Navigation.Wpf&version=0.12.1
                    
Install as a Cake Tool

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, an IRunicModelContext that runs model turns and navigation hooks on the WPF dispatcher.
  • NavigationHost, a ContentControl that 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 a TabControl, to a selection among regions or to a region, and ViewHost, 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:

  • IRunicModelContext becomes a singleton DispatcherModelContext. It uses RunicWpfNavigationOptions.Dispatcher, or Application.Current.Dispatcher when it's first resolved. Without either, resolution throws InvalidOperationException. It never creates a dispatcher on the resolving thread.
  • RunicNavigator gets NavigatorLifetime (Singleton by default, or Scoped for one navigator per window scope). Transient throws. CreateEntryScopes defaults to true for a singleton navigator. Scoped combined with CreateEntryScopes = true throws.

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:

  1. the host's ViewLocator;
  2. the INavigationViewLocator registered in the navigator's services, which MapView<TViewModel, TView>() and UseViewNamingConvention() add;
  3. the implicit DataTemplate for 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. So DocumentView(DocumentViewModel model) binds to the pushed ViewModel. Interface and object parameters, such as INotifyPropertyChanged or IDisposable, 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 with ActivatorUtilities, 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 with ActivatorUtilities. The View's DataContext is the content unless the View sets one.
  • UseViewNamingConvention() maps FooViewModel to FooView, then FooPage, in the same assembly, also from a .ViewModels namespace 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; prefer MapView for an app you trim.
  • The host sets its own Content. Don't set Content, ContentTemplate or ContentTemplateSelector on it; present entries with implicit DataTemplates 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 while CanGoBack and not IsTransitioning, and, in a region created with Admission = NavigationAdmission.RejectWhileBusy, not IsBusy. Set HandlesBrowseBack="False" to handle it yourself.
  • The host's read-only IsTransitioning and IsBusy properties 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 ContentPresenter with its View and DataContext, and doesn't ask the locator again. A View still belongs to one entry: an instance pushed twice gets two entries and two Views.
  • RetainViews is inherited. Set as rn: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 to False. 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 Region changes, and when the host presents an entry while RetainViews is False. 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 Unloaded and Loaded events 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 DataTemplate set 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 });
  • true keeps the entry's View while the entry is retained, false never keeps it, and null, the default, follows RetainViews. Push, Replace, Reset and PushForResult record it on the entry they create (NavigationEntry.RetainView); initial entries follow RetainViews.
  • 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 RetainViews off keeps the Views of entries pushed with RetainView = 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.Cancel or Drain before 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" />
  1. 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"). A LeaveConfirmation without askOnRetain allows it: the edit page stays the region's current entry with its draft, and its View is discarded.
  2. 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, as ShowDialog does. 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.
  • WindowStyle styles 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 a Popup), 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 IsDefault button, which runs its command, unless the focused control handles Enter itself (a TextBox with AcceptsReturn).
  • An IsCancel button without a command dismisses the dialog, like the close button. With a command, the button just runs it.
  • Esc dismisses the dialog while CloseOnEscape is on (the default). With CloseOnEscape="False", Esc clicks the IsCancel button instead.
  • These dismissals, and the clear when the owner window closes, pass Admission = NavigationAdmission.Supersede: in a dialog region created with RejectWhileBusy, 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.
  • Create lets every departure that isn't a window close pass without asking. OncePerClose asks 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 OnCommitted action 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 TabControl with no content template or template selector gets a default template containing a NavigationHost. It keeps entry presentation, per-entry services and View lifetimes. Supplied templates and selectors are kept.
  • Don't also navigate from a SelectedItem or SelectedIndex setter: 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.Regions by position. Static TabItems map in declaration order. ItemsSource="{Binding Tabs.Regions}" maps one to one and follows Add and RemoveAsync; give it an ItemTemplate for the headers. An ItemsSource of 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 NavigationHost to Selection.Selected, so it presents the current page of the selected tab.
  • Choosing a tab calls SelectAsync for its region. The current page of the tab left, and of the regions below it, is asked as Retain, so a page with unsaved edits can refuse. LeaveConfirmation asks on a tab switch only with askOnRetain. A committed switch changes no stack: the tab left keeps its pages and drafts, and selecting it again presents the same page. No INavigationInitialize or arrival hook runs again.
  • The control follows a programmatic SelectAsync, Add or RemoveAsync.
  • Retiring the page asks every page of every tab as Retire, whichever tab is selected. So a GuardWindowClose host 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:

  1. The page owns the tab regions and creates Tabs = navigator.CreateSelection(this, [a, b, c]).
  2. Tab header models become plain data, or are replaced by static TabItems.
  3. Replace rn:NavigationSelector.Region="{Binding Tab}" with rn:NavigationSelector.Selection="{Binding Tabs}".
  4. 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:

  • InvokeAsync on 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's Loaded priority, which runs after Render, 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, MessageBox or .Wait() inside InvokeAsync, a commit's PropertyChanged handler or an OnCommitted action. A turn that starts inside another turn's nested loop is logged once as event 1081.
  • Never block the UI thread on navigation with .Result or .Wait(): the commit needs the dispatcher.
  • A posted turn that throws goes to UnhandledTurnException, or is logged as event 1080. It never reaches Dispatcher.UnhandledException.
  • The context closes when the dispatcher starts shutting down, and tells the navigator through its Closed token. Requests then end Rejected(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 Compatible and additional computed target framework versions.
.NET net10.0-windows7.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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