Skip to content

View Location

Run the complete page example.

An app built around view models has a router, a navigation stack or a list that holds view models, not views. Something still has to turn each view model into the screen that shows it, and set that screen's ViewModel property before it appears. Writing that lookup by hand for every screen does not scale as an app grows.

A view locator is the object that does this lookup. You give it a view model, and it gives you back the view for it, with the view model already set. Extending IViewFor covers implementing IViewFor<T> on a view; this page covers finding that view once it exists. DefaultViewLocator, ViewLocator, ViewMappingBuilder and ViewLocatorNotFoundException come from ReactiveUI.Binding, the package ReactiveUI builds on. Views documents them, along with the source generator that writes most of the lookup for you. This page shows how an app that uses routing and view models puts them to work. IViewModule and WithViewModule are part of ReactiveUI itself.

%%{init: {"theme": "base", "themeVariables": {"fontFamily": "Roboto, Helvetica, Arial, sans-serif", "fontSize": "15px", "primaryColor": "#DCE9FF", "primaryBorderColor": "#6C8EC4", "primaryTextColor": "#0B2447", "secondaryColor": "#E3F2E8", "secondaryBorderColor": "#7FA88C", "secondaryTextColor": "#12301C", "tertiaryColor": "#F3E5F5", "tertiaryBorderColor": "#A98BB0", "tertiaryTextColor": "#2E1437", "lineColor": "#7B8699", "textColor": "#1B1F27", "noteBkgColor": "#FFF4D6", "noteBorderColor": "#C9A94F", "noteTextColor": "#3A2A00", "actorBkg": "#DCE9FF", "actorBorder": "#6C8EC4", "actorTextColor": "#0B2447", "signalColor": "#7B8699", "signalTextColor": "#1B1F27", "labelBoxBkgColor": "#F1F3F8", "labelBoxBorderColor": "#A7AEBB", "edgeLabelBackground": "#F7F9FC", "clusterBkg": "#F7F9FC", "clusterBorder": "#C9D1DE"}}}%%
flowchart LR
    classDef view fill:#DCE9FF,stroke:#6C8EC4,color:#0B2447
    classDef vm fill:#E3F2E8,stroke:#7FA88C,color:#12301C
    classDef model fill:#F3E5F5,stroke:#A98BB0,color:#2E1437
    classDef warn fill:#FDE7E4,stroke:#C98A82,color:#410E0B
    classDef neutral fill:#F1F3F8,stroke:#A7AEBB,color:#1B1F27
    VM(["View model"]):::vm -- "ResolveView(viewModel, contract)" --> Locator(["View locator"]):::neutral
    Locator -- "looks up by type and contract" --> Map(["Registered mapping"]):::neutral
    Map -- "builds or reuses" --> View(["View, ViewModel set"]):::view

The locator matches on the view model's type and, optionally, a contract, and it always sets the view's ViewModel before handing it back.

Ask the locator for a view

1. Get the locator the app registered. ViewLocator.GetCurrent() returns the IViewLocator an app's builder set up. Call it once you know the app has finished starting.

2. Resolve the view. ResolveView takes the view model and returns its view, with ViewModel already set to the object you passed.

using TodoListViewModel viewModel = new(InMemoryTodoStore.CreateSeeded());

IViewFor? view = ViewLocator.GetCurrent().ResolveView(viewModel);

Console.WriteLine(view?.GetType().Name);
Console.WriteLine(ReferenceEquals(view?.ViewModel, viewModel));
TodoListView
True

3. Read the result. The example registered no mapping for TodoListViewModel. ReactiveUI.Binding's source generator found TodoListView while the project built and added it to the lookup, so the call above needed no setup. ResolveView returns null when nothing matches; a router checks for that before it shows a screen.

ResolveView has a generic overload, ResolveView<TViewModel>(viewModel, contract), that reads the view model's type at compile time, and it also has ResolveView(object?, contract) for a navigation stack that holds view models as object. The generic overload also asks the service locator when no generated or mapped view answers, so it is safe in a trimmed or Native AOT app. The object overload stops after the generated lookup and the Map registrations: closing IViewFor<> over a runtime type needs code the compiler never saw, so it never asks the service locator, and it logs a warning when neither tier answers. ResolveViewUnsafe adds the service locator as a third tier for a view model held as an object, and it carries [RequiresDynamicCode] because it closes IViewFor<> with reflection. Reflection covers what that attribute means for your app. Reach a view registered only in the service locator covers the warning, the fix and the RXUIBIND020 analyzer rule that catches it at build time.

Which lookup the view hosts use

The non-generic view hosts on every platform hold their view model as an object, so they call ResolveView(object?, contract). That covers ViewModelViewHost and RoutedViewHost, WinForms' ViewModelControlHost and RoutedControlHost, and the item template AutoDataTemplateBindingHook assigns. They find every view in the generated lookup and every view you add with Map, with no reflection, so they are safe to trim and to publish with Native AOT.

Each of those hosts has an Unsafe twin, such as ViewModelViewHostUnsafe or RoutedControlHostUnsafe, that calls ResolveViewUnsafe instead. Use a twin only for a view that Splat's service locator alone knows. For that view you have two choices:

ChoiceSafe for Native AOTWhat you change
Bridge the registration with MapFromServiceLocatorYesOne call on the view locator while the app starts; every default host then finds the view.
Use the Unsafe twin of the hostNo: marked [RequiresDynamicCode]Swap the host type where that view is shown.

The bridge is one line on the app's view locator, with the view's service-locator type as TView:

_ = locator.CreateMappingBuilder().MapFromServiceLocator<RadarImageViewModel, IViewFor<RadarImageViewModel>>();

Each platform page shows its twins: WinUI, WPF, Windows Forms, .NET MAUI and iOS and macOS.

Choose a view by contract

A view model can have more than one screen: a compact list next to a full one, for example. A contract is a string that picks between them. Passing null selects the default screen.

using TodoListViewModel viewModel = new(InMemoryTodoStore.CreateSeeded());
IViewLocator locator = ViewLocator.GetCurrent();

Console.WriteLine(locator.ResolveView(viewModel, ViewContracts.Compact)?.GetType().Name);
Console.WriteLine(locator.ResolveView(viewModel, ViewContracts.Print)?.GetType().Name ?? "(none)");
CompactTodoListView
(none)

A contract with no matching view resolves to null, not to the default screen picked by dropping the contract. A host decides whether to fall back to ResolveView(viewModel) itself. Choose a view by contract covers [ViewContract]. It also covers the rules for a view model whose only view carries a contract.

Group a feature's views in a module

An app grows by feature, and a feature's views should register themselves rather than have the app list every one of them by hand. IViewModule is a small interface for that: implement RegisterViews and map the feature's views with a ViewMappingBuilder.

public sealed class GitHubViewModule : IViewModule
{
    /// <inheritdoc/>
    public void RegisterViews(DefaultViewLocator locator) =>
        locator.CreateMappingBuilder()
            .Map<RepositorySearchViewModel, RepositorySearchView>();
}

Add the module once, while the app starts, with WithViewModule<TModule>() on the app's builder. RxAppBuilder covers the rest of that builder. Call WithViewModule after the step that registers a DefaultViewLocator, such as WithCoreServices or a platform module. Called earlier, it throws InvalidOperationException, because it has no locator to add the module's views to.

ExampleApp.Start(static builder => builder
    .WithViewModule<GitHubViewModule>()
    .WithRegistration(static resolver => resolver.RegisterConstant<IActivationForViewFetcher>(new VendorDialogActivationFetcher())));

The WithRegistration call also on that builder registers an activation fetcher a later page needs; it plays no part in view modules. Extending IViewFor covers what it does.

Resolving a view model from that feature works the same as any other.

using RepositorySearchViewModel viewModel = new(new InMemoryGitHubApi());

IViewFor? view = ViewLocator.GetCurrent().ResolveView(viewModel);

Console.WriteLine(view?.GetType().Name);
RepositorySearchView

WithViewModule<TModule> requires a parameterless constructor on TModule. It creates one instance of the module and calls RegisterViews on it immediately, so the module's mappings are ready before the rest of the app runs.

Map a view without building a view model first

DefaultViewLocator.Map registers a view for a view model type on one locator instance. ResolveView<T>() with no view model argument resolves it too, which is useful when you only need to know which view a route maps to, not to show it yet. The view it returns has no view model set.

DefaultViewLocator locator = new();
locator.Map<TimetableViewModel, DayTimetableView>();
locator.Map<TimetableViewModel, WeekTimetableView>(ViewContracts.Week);

IViewFor<TimetableViewModel>? day = locator.ResolveView<TimetableViewModel>();
IViewFor<TimetableViewModel>? week = locator.ResolveView<TimetableViewModel>(ViewContracts.Week);

Console.WriteLine(day?.GetType().Name);
Console.WriteLine(week?.GetType().Name);
Console.WriteLine(day?.ViewModel is null);
DayTimetableView
WeekTimetableView
True

A mapping belongs to the DefaultViewLocator instance you call Map on. Registering one on a locator built with new DefaultViewLocator() for a test, for example, does not affect the locator the app shares.

Map and unmap a view at run time

An app can add and remove mappings while it runs, such as showing a staff-only screen only while a teacher is signed in. Map<TViewModel>(factory) builds the view from a factory instead of a parameterless constructor, and Unmap removes a mapping and reports whether one existed.

DefaultViewLocator locator = new();
TeacherAdminViewModel admin = new("Ms. Ito");

locator.Map<TeacherAdminViewModel>(static () => new TeacherAdminView());
locator.Map<TeacherAdminViewModel>(static () => new TeacherAdminView(), ViewContracts.Admin);

Console.WriteLine(locator.ResolveView(admin)?.GetType().Name);
Console.WriteLine(locator.ResolveView(admin, ViewContracts.Admin)?.GetType().Name);

bool unmappedDefault = locator.Unmap<TeacherAdminViewModel>();
bool unmappedAdmin = locator.Unmap<TeacherAdminViewModel>(ViewContracts.Admin);

Console.WriteLine(locator.ResolveView(admin)?.GetType().Name ?? "(none)");
Console.WriteLine(unmappedDefault);
Console.WriteLine(unmappedAdmin);
TeacherAdminView
TeacherAdminView
(none)
True
True

After sign-out, the locator finds nothing for the view model again, the same as before it was ever mapped. The factory runs on every resolve, so each call to ResolveView after signing in again would build a new TeacherAdminView.

Chain mappings with a builder, including views from the service locator

CreateMappingBuilder returns a ViewMappingBuilder for the locator. Its Map overloads mirror DefaultViewLocator.Map and each returns the builder, so a screen's mappings read as one chain. MapFromServiceLocator<TViewModel, TView> maps a view that another part of the app already registered with Splat's service locator, rather than building it itself.

AppLocator.Register(static () => new SchoolProfileView());
AppLocator.Register(static () => new PrintableProfileView());

DefaultViewLocator locator = new();
locator.CreateMappingBuilder()
    .Map<TimetableViewModel, WeekTimetableView>(ViewContracts.Week)
    .Map<TeacherAdminViewModel>(static () => new TeacherAdminView())
    .Map<TeacherAdminViewModel>(static () => new TeacherAdminView(), ViewContracts.Admin)
    .MapFromServiceLocator<SchoolProfileViewModel, SchoolProfileView>()
    .MapFromServiceLocator<SchoolProfileViewModel, PrintableProfileView>(ViewContracts.Print);

TeacherAdminViewModel admin = new("Ms. Ito");
SchoolProfileViewModel profile = new("Aiko Tanaka");

Console.WriteLine(locator.ResolveView<TimetableViewModel>(ViewContracts.Week)?.GetType().Name);
Console.WriteLine(locator.ResolveView(admin)?.GetType().Name);
Console.WriteLine(locator.ResolveView(admin, ViewContracts.Admin)?.GetType().Name);
Console.WriteLine(locator.ResolveView(profile)?.GetType().Name);
Console.WriteLine(locator.ResolveView(profile, ViewContracts.Print)?.GetType().Name);
Console.WriteLine(ReferenceEquals(locator.ResolveView(profile)?.ViewModel, profile));
WeekTimetableView
TeacherAdminView
TeacherAdminView
SchoolProfileView
PrintableProfileView
True

MapFromServiceLocator asks the service locator each time the mapping resolves; a view that is not registered there throws InvalidOperationException instead of resolving to nothing. Use it for a view a service locator already owns, such as one another Splat-registered module built. When the service locator holds more than one registration of the same IViewFor<TViewModel> type under different contracts, the contracted overload tells them apart.

Handle a missing locator

An app has to register a locator before anything can call ViewLocator.GetCurrent(). Calling it too early, or in an app that never registered one, throws ViewLocatorNotFoundException with a message naming the calls that fix it.

ViewLocatorNotFoundException defaultMessage = new();
ViewLocatorNotFoundException customMessage = new("No view locator was registered for the timetable module.");
InvalidOperationException containerDisposed = new("The module container was disposed.");
ViewLocatorNotFoundException wrapped = new("The timetable module could not resolve its view locator.", containerDisposed);

Console.WriteLine(defaultMessage.Message);
Console.WriteLine(customMessage.Message);
Console.WriteLine(wrapped.Message);
Console.WriteLine(wrapped.InnerException?.Message);
No IViewLocator is registered. Call RxBindingBuilder.CreateReactiveUIBindingBuilder().WithCoreServices().BuildApp() to register default services.
No view locator was registered for the timetable module.
The timetable module could not resolve its view locator.
The module container was disposed.

ViewLocatorNotFoundException has three constructors: one with no arguments and the message above, one that takes your own message, and one that also takes an inner exception. Your own code can throw it too, for example when a module fails to reach the container that holds its locator.

Wrap the locator for a placeholder screen

IViewLocator is a small interface, so you can wrap the app's locator in one of your own. This one asks the inner locator first and shows a placeholder screen for a view model that has no view yet, rather than a blank area.

PlaceholderViewLocator locator = new(ViewLocator.GetCurrent());
using TodoListViewModel todos = new(InMemoryTodoStore.CreateSeeded());

Console.WriteLine(locator.ResolveView(todos)?.GetType().Name);
Console.WriteLine(locator.ResolveView(new SettingsViewModel())?.GetType().Name);
TodoListView
PlaceholderView

SettingsViewModel has no registered view in the example, so the inner locator returns null and the wrapper's placeholder view takes over.

An IViewLocator implements three methods: the generic ResolveView<TViewModel>, ResolveView(object?, contract) and ResolveViewUnsafe(object?, contract). Keep ResolveView(object?, contract) free of any step that closes a generic type over the view model's run-time type, because the default view hosts call it. Put that step in ResolveViewUnsafe, which the Unsafe hosts call, and mark it [RequiresDynamicCode]. The wrapper forwards each method to the same method on the inner locator:

public IViewFor? ResolveView(object? viewModel, string? contract) =>
    inner.ResolveView(viewModel, contract) ?? new PlaceholderView { ViewModel = viewModel };

[RequiresDynamicCode("Asks the inner locator's reflective lookup, which closes IViewFor<> over the view model's runtime type.")]
public IViewFor? ResolveViewUnsafe(object? viewModel, string? contract) =>
    inner.ResolveViewUnsafe(viewModel, contract) ?? new PlaceholderView { ViewModel = viewModel };
``` [Write your own locator](../../../binding/views.md#write-your-own-locator)
covers implementing `IViewLocator` directly, for an app that replaces the default lookup rather than wrapping
it.

## Next steps

- [Views](../../../binding/views.md) covers `IViewFor`, the source generator's lookup, contracts and mappings in
  depth.
- [Extending IViewFor](extending-iviewfor.md) shows implementing `IViewFor<T>` on a view of your own.
- [Routing](../routing.md) uses a view locator to show the view for each view model on the navigation stack.
- [RxAppBuilder](../rxappbuilder.md) covers the builder that registers the app's locator and view modules.
- [Reflection](../reflection.md) covers `[RequiresDynamicCode]` and the object-typed overloads it appears on.

## At a glance


<table>
<thead>
<tr><th>Member</th><th>What it does</th></tr>
</thead>
<tbody>
<tr><td><code>ViewLocator.GetCurrent()</code></td><td>Returns the <code>IViewLocator</code> the app's builder registered; throws <code>ViewLocatorNotFoundException</code> when none is.</td></tr>
<tr><td><code>IViewLocator.ResolveView&lt;TViewModel&gt;(viewModel, contract)</code></td><td>Resolves a view for a view model whose type is known at compile time. Safe for Native AOT.</td></tr>
<tr><td><code>IViewLocator.ResolveView(object?, contract)</code></td><td>Resolves a view for a view model held as <code>object</code>, from the generated lookup and the <code>Map</code> entries. Safe for Native AOT; the default view hosts call it.</td></tr>
<tr><td><code>IViewLocator.ResolveViewUnsafe(object?, contract)</code></td><td>The same, then the service locator for <code>IViewFor&lt;T&gt;</code> of the run-time type; carries <code>[RequiresDynamicCode]</code>. The Unsafe view hosts call it.</td></tr>
<tr><td><code>ResolveView(viewModel)</code></td><td>Extension that resolves with the default contract, on either overload.</td></tr>
<tr><td><code>DefaultViewLocator</code></td><td>The locator an app's builder registers. Checks the generated lookup, then this instance's mappings; the generic overload and <code>ResolveViewUnsafe</code> then ask the service locator.</td></tr>
<tr><td><code>DefaultViewLocator.Map</code></td><td>Registers a view, by type or by factory, for a view model type and an optional contract on this locator.</td></tr>
<tr><td><code>DefaultViewLocator.Unmap</code></td><td>Removes a mapping and reports whether one existed.</td></tr>
<tr><td><code>DefaultViewLocator.CreateMappingBuilder()</code></td><td>Returns a <code>ViewMappingBuilder</code> for chaining several mappings on this locator.</td></tr>
<tr><td><code>ViewMappingBuilder.Map</code></td><td>The same mappings as <code>DefaultViewLocator.Map</code>, returning the builder so calls chain.</td></tr>
<tr><td><code>ViewMappingBuilder.MapFromServiceLocator&lt;TViewModel, TView&gt;</code></td><td>Maps a view the service locator already builds, instead of constructing one directly.</td></tr>
<tr><td><code>IViewModule</code></td><td>Interface with one method, <code>RegisterViews(DefaultViewLocator)</code>, for a feature to register its own views.</td></tr>
<tr><td><code>WithViewModule&lt;TModule&gt;()</code></td><td>Builder method that constructs a module and calls <code>RegisterViews</code> on the app's locator.</td></tr>
<tr><td><code>ViewLocatorNotFoundException</code></td><td>Thrown when no locator is registered, or by your own code when a screen cannot be resolved.</td></tr>
</tbody>
</table>