Skip to content

Views

Run the complete page example.

An application shows a screen for each view model. A navigation service holds a to-do list view model and has to ask: which screen shows this? A view is the class for that screen. A view locator is the object that answers the question. You give it a view model and it gives you the view, with the view model already set.

ReactiveUI.Binding writes most of that lookup for you. The source generator finds every class that implements IViewFor<T> and writes a lookup for them while your project builds. You can also register views by hand, and you can choose between several screens for one view model.

This page starts with one view and one lookup. Later sections cover the interfaces a view implements, the locator and its errors, contracts, the generated lookup, hand-written mappings and the order the locator checks.

Find the view for a view model

1. Implement IViewFor<T>. A view declares the type of view model it shows. IViewFor<T> has one member, a typed ViewModel property. Its base interface, IViewFor, has the same property typed as object. Implement the base one explicitly and forward it to the typed one.

public sealed class AccountListView : ObservableObject, IViewFor<IAccountList>
{
    public IAccountList? ViewModel
    {
        get;
        set => SetProperty(ref field, value);
    }

    public Label CountLabel { get; } = new();

    object? IViewFor.ViewModel
    {
        get => ViewModel;
        set => ViewModel = (IAccountList?)value;
    }
}

ObservableObject is a helper class from the examples that raises PropertyChanged. A view should raise a change when its ViewModel changes, because bindings follow that property. This view names an interface, IAccountList, as its view model type. That choice matters in the next step.

2. Resolve a view. Create a DefaultViewLocator and pass it a view model. ResolveView returns the view with its ViewModel set to the object you passed.

AccountListViewModel current = new([CreateAccount(EverydayId, EverydayName)]);
ClosedAccountListViewModel closed = new([]);
DefaultViewLocator locator = new();

var currentView = locator.ResolveView(current);
var closedView = locator.ResolveView(closed);

Console.WriteLine(currentView?.GetType().Name);
Console.WriteLine(closedView?.GetType().Name);
Console.WriteLine(ReferenceEquals(closedView?.ViewModel, closed));
AccountListView
AccountListView
True

3. Read the result. Both view models implement IAccountList, so one view serves both. You wrote no registration code. The generator saw AccountListView and added it to the lookup. ResolveView returns null when no view matches, so check for it.

Give a view its view model

A host, such as a navigation service, hands a view model to a view through the interface. It does not know the concrete view type. Present takes the typed interface and reads the property back.

public static TViewModel? Present<TViewModel>(IViewFor<TViewModel> view, TViewModel viewModel)
    where TViewModel : class
{
    view.ViewModel = viewModel;
    return view.ViewModel;
}

The next snippet shows a new to-do view before and after the host presents a view model to it. A view starts empty, so it only shows data once the host assigns one.

TodoView view = new();
TodoListViewModel viewModel = new(InMemoryTodoStore.CreateSeeded());

Console.WriteLine(view.ViewModel is null);

var shown = Present(view, viewModel);

Console.WriteLine(ReferenceEquals(shown, viewModel));
Console.WriteLine(ReferenceEquals(view.ViewModel, viewModel));
True
True
True

Assign through the non-generic interface

A navigation stack often holds view models as object. IViewFor takes an object, so the host needs no generic type argument.

IssueBoardView view = new();
IssueBoardViewModel viewModel = new(InMemoryGitHubServer.CreateSeeded());
var screen = (IViewFor)view;

screen.ViewModel = viewModel;

Console.WriteLine(ReferenceEquals(screen.ViewModel, viewModel));
Console.WriteLine(ReferenceEquals(view.ViewModel, viewModel));
True
True

The forwarding property casts the object to the view model type. A view model of the wrong type throws InvalidCastException, and the view keeps its old value.

var screen = (IViewFor)new IssueBoardView();
TodoListViewModel wrongViewModel = new(InMemoryTodoStore.CreateSeeded());
var refused = false;

try
{
    screen.ViewModel = wrongViewModel;
}
catch (InvalidCastException)
{
    refused = true;
}

Console.WriteLine(refused);
Console.WriteLine(screen.ViewModel is null);
True
True

Observe the view model of a view

The ViewModel property is an ordinary property, so you observe it like any other. WhenChanged delivers the current value on subscribe and then each change. See observing for how that works.

TodoView view = new();
TodoListViewModel viewModel = new(InMemoryTodoStore.CreateSeeded());
List<TodoListViewModel?> shown = [];

using (view.WhenChanged(x => x.ViewModel!).Subscribe(shown.Add))
{
    view.ViewModel = viewModel;
}

Console.WriteLine(shown.Count);
Console.WriteLine(shown[0] is null);
Console.WriteLine(ReferenceEquals(shown[1], viewModel));
2
True
True

The first value is null because the view had no view model when the subscription began.

Recognize activatable views

IActivatableView is a marker interface. It has no members, and every IViewFor implements it. A host uses it to tell views from other objects.

public static int CountActivatable(IEnumerable<object> candidates) => candidates.OfType<IActivatableView>().Count();

The next snippet puts two views and one view model in a list and counts the activatable items. Only the views count, so the check separates screens from data.

List<object> candidates =
[
    new TodoView(),
    new TodoListViewModel(InMemoryTodoStore.CreateSeeded()),
    new AccountsView(),
];

Console.WriteLine(CountActivatable(candidates));
2

Bind a view to its view model

The view-first binding methods, OneWayBind and Bind, take the view model and two property paths. They need a view that implements IViewFor. On a view that implements IViewFor<T>, the bindings follow view.ViewModel, not the instance you pass, which only decides the types. A view that implements only the non-generic IViewFor binds the view model you pass. Read bindings for the full set of binding methods.

TodoListViewModel viewModel = new(InMemoryTodoStore.CreateSeeded());
TodoView view = new();
await viewModel.LoadAsync();

// The bindings follow view.ViewModel, so the screen shows the view model they read.
_ = Present(view, viewModel);

using (view.OneWayBind(viewModel, x => x.RemainingCount, v => v.RemainingLabel.Text, static count => count.ToString(CultureInfo.InvariantCulture)))
using (view.Bind(viewModel, x => x.NewTitle, v => v.NewTitleTextBox.Text))
{
    Console.WriteLine(view.RemainingLabel.Text);

    view.NewTitleTextBox.Text = NewItemTitle;

    Console.WriteLine(viewModel.NewTitle);
}
3
Book vet appointment

OneWayBind copies the remaining count to the label. Bind carries the text the user types back into the view model. BindAccountsView in the example project does the same for a banking screen. Every binding writes to the view on the thread that owns it. See threading.

A binding made before the view has a view model waits for one. While view.ViewModel is null, the binding writes nothing, so the label keeps its own text. Assigning a view model starts the binding, and replacing it moves the binding to the new one.

TodoListViewModel household = new(InMemoryTodoStore.CreateSeeded());
TodoListViewModel shared = new(InMemoryTodoStore.CreateSeeded());
await household.LoadAsync();
await shared.LoadAsync();
shared.SelectedItem = shared.Items[0];
await shared.CompleteAsync();
TodoView view = new();

// The binding follows view.ViewModel, which is still empty, so the label is left alone.
using (view.OneWayBind(household, x => x.RemainingCount, v => v.RemainingLabel.Text, static count => count.ToString(CultureInfo.InvariantCulture)))
{
    Console.WriteLine(view.RemainingLabel.Text ?? NoViewModelText);

    view.ViewModel = household;
    Console.WriteLine(view.RemainingLabel.Text);

    // Replacing the view model moves the binding to the new one.
    view.ViewModel = shared;
    Console.WriteLine(view.RemainingLabel.Text);
}
no view model yet
3
2

Use the locator your application registers

new DefaultViewLocator() is fine in a test. An application registers one locator for everyone to share. ViewLocator.GetCurrent() returns that locator. WithCoreServices registers a DefaultViewLocator among the core services. The example calls it and then BuildApp. See setup for the builder.

var builder = (IReactiveUIBindingBuilder)RxBindingBuilder.CreateReactiveUIBindingBuilder();
_ = builder.WithCoreServices().BuildApp();

var locator = ViewLocator.GetCurrent();

Console.WriteLine(locator is DefaultViewLocator);
True

The locator is an IViewLocator. Asking for it before any registration throws ViewLocatorNotFoundException. The message names the calls that fix it.

ViewLocatorNotFoundException? failure = null;

try
{
    _ = ViewLocator.GetCurrent();
}
catch (ViewLocatorNotFoundException ex)
{
    failure = ex;
}

Console.WriteLine(failure is not null);
Console.WriteLine(failure!.Message.Contains("WithCoreServices", StringComparison.Ordinal));
True
True

The next snippet asks the registered locator for the screen of a to-do list. It returns TodoView with the view model set, which is what a navigation service needs before it shows the screen.

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

var view = locator.ResolveView(viewModel);

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

Resolve views

IViewLocator declares two overloads of ResolveView. Each takes a contract, a string that picks between screens for one view model. Pass null for the default screen. Contracts are covered below.

MemberUse it when
ResolveView<TViewModel>(viewModel, contract)You know the view model type when you write the call.
ResolveView(object, contract)You hold the view model as an object.
ResolveView(viewModel)You want the default contract. ViewLocatorMixins adds this to both forms.

The generic overload reads the view model type at compile time. It is safe for Native AOT. The object overload reads the runtime type. When no generated or mapped view answers, it closes IViewFor<> over that runtime type to ask the service locator. That step needs code the compiler never saw, so the object overloads carry [RequiresDynamicCode]. In an AOT application, use the generic overload or register the view.

A service locator is a shared registry where an application registers services by type. This library uses Splat's AppLocator for that.

The next snippet holds an issue board view model in an object variable, as a navigation stack does. The compiler picks the object overload, so the call needs no cast and no type argument.

object viewModel = new IssueBoardViewModel(InMemoryGitHubServer.CreateSeeded());
var locator = ViewLocator.GetCurrent();

var view = locator.ResolveView(viewModel);

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

A host that passes a contract calls the interface directly. The helper below forwards a contract for an object view model.

public static IViewFor? ResolveForContract(IViewLocator locator, object viewModel, string? contract) =>
    locator.ResolveView(viewModel, contract);

The next snippet uses that helper to ask for the accounts screen with and without a contract. The default screen and the compact screen come back, so a host can pick a layout by name.

object viewModel = new AccountsViewModel(new InMemoryBankingBackend());
DefaultViewLocator locator = new();

var standard = ResolveForContract(locator, viewModel, null);
var compact = ResolveForContract(locator, viewModel, AccountViewContracts.Compact);

Console.WriteLine(standard?.GetType().Name);
Console.WriteLine(compact?.GetType().Name);
AccountsView
CompactAccountsView

The next snippet resolves a view for a null view model. It returns null and does not throw, so a host can pass a missing view model safely. Both overloads behave this way.

var locator = ViewLocator.GetCurrent();

var view = locator.ResolveView((object?)null);

Console.WriteLine(view is null);
True

Handle a missing view

A view model with no view resolves to null. The locator does not throw, because a missing view is often normal. A host that needs a screen decides what to do. This helper turns null into a ViewLocatorNotFoundException with a message for the user.

public static IViewFor RequireView(IViewLocator locator, object viewModel)
{
    var view = locator.ResolveView(viewModel);
    return view ?? throw new ViewLocatorNotFoundException($"No screen is registered for {viewModel.GetType().Name}.");
}

The next snippet asks for the screen of a to-do item that has none. The plain call returns null, and RequireView throws with its message. That shows both ways a host can react.

TodoItem note = new() { Title = NoteTitle };
var locator = ViewLocator.GetCurrent();
ViewLocatorNotFoundException? failure = null;

var view = locator.ResolveView(note);

try
{
    _ = RequireView(locator, note);
}
catch (ViewLocatorNotFoundException ex)
{
    failure = ex;
}

Console.WriteLine(view is null);
Console.WriteLine(failure?.Message);
True
No screen is registered for TodoItem.

A view that exists but fails to build is a different case. The locator does not catch the exception from a view constructor or a factory. It reaches your code. This helper reports the failure as a missing screen and keeps the cause in InnerException.

public static IViewFor? ResolveOrExplain(IViewLocator locator, TodoItem viewModel)
{
    try
    {
        return locator.ResolveView(viewModel);
    }
    catch (InvalidOperationException ex)
    {
        throw new ViewLocatorNotFoundException($"The screen for {nameof(TodoItem)} could not be built.", ex);
    }
}

The next snippet maps a factory that throws, then resolves through the helper. The caller sees the not-found exception with the original error inside it, so the cause is not lost.

TodoItem viewModel = new() { Title = NoteTitle };
DefaultViewLocator locator = new();
locator.Map<TodoItem>(static () => throw new InvalidOperationException(LayoutFailure));
ViewLocatorNotFoundException? failure = null;

try
{
    _ = ResolveOrExplain(locator, viewModel);
}
catch (ViewLocatorNotFoundException ex)
{
    failure = ex;
}

Console.WriteLine(failure?.Message);
Console.WriteLine(failure?.InnerException?.Message);
The screen for TodoItem could not be built.
The layout file is missing.

ViewLocatorNotFoundException has three constructors: one with no arguments, one with a message and one with a message and an inner exception. The one with no arguments carries a message that names the builder calls that register the locator.

ViewLocatorNotFoundException failure = new();

Console.WriteLine(failure.Message.Contains("BuildApp", StringComparison.Ordinal));
True

Write your own locator

IViewLocator is a small interface. An application with one screen can implement it directly. Both overloads return the view for a to-do list and null for anything else.

public sealed class TodoViewLocator : IViewLocator
{
    public IViewFor? ResolveView(object? viewModel, string? contract) =>
        viewModel is TodoListViewModel todoList ? new TodoView { ViewModel = todoList } : null;

    public IViewFor? ResolveView<TViewModel>(TViewModel viewModel, string? contract)
        where TViewModel : class =>
        viewModel is TodoListViewModel todoList ? new TodoView { ViewModel = todoList } : null;
}

The next snippet resolves the to-do list and an unrelated item through it. The list gets its screen with the view model set and the other item gets null, so a custom locator behaves like the default one.

TodoListViewModel viewModel = new(InMemoryTodoStore.CreateSeeded());
TodoViewLocator locator = new();

var view = locator.ResolveView(viewModel, null);
var missing = locator.ResolveView(new TodoItem(), null);

Console.WriteLine(view?.GetType().Name);
Console.WriteLine(ReferenceEquals(view?.ViewModel, viewModel));
Console.WriteLine(missing is null);
TodoView
True
True

Choose a view by contract

One view model can have more than one screen. A contract is a string that names one of them. Put [ViewContract] on a view to register it under a contract. A view with no attribute is the default screen. The next snippet reads the contract back from the attribute with reflection. It shows the string the view is registered under, which is the string a caller passes to ResolveView.

var attribute = typeof(AccountStatementView).GetCustomAttribute<ViewContractAttribute>();

Console.WriteLine(attribute?.Contract);
statement

AccountStatementView and AccountSummaryView both show an Account. The statement view carries [ViewContract(AccountViewContracts.Statement)]. The summary view has no contract. Pass the contract to ResolveView to choose.

var account = CreateAccount();
DefaultViewLocator locator = new();

var statement = locator.ResolveView(account, AccountViewContracts.Statement);
var summary = locator.ResolveView(account, null);

Console.WriteLine(statement?.GetType().Name);
Console.WriteLine(summary?.GetType().Name);
AccountStatementView
AccountSummaryView

The next snippet asks for a contract that no view claims. The locator returns the default screen instead of nothing, so an unknown contract still shows a sensible screen.

var account = CreateAccount();
DefaultViewLocator locator = new();

var view = locator.ResolveView(account, UnclaimedContract);

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

Two more rules follow from how the generator writes the lookup. A view model that has only one registered view, with no contract, answers to every contract. A view model whose only view has a contract answers to that contract and no other. ResolveBankingViewByContract in the example project picks AccountsView or CompactAccountsView for AccountsViewModel this way.

The generated view dispatch

The generator writes one lookup for each assembly that contains views. Each entry pairs a view model type with a view and a contract. The lookup is a type switch, so it uses no reflection and works with trimming and Native AOT. This lookup is the view dispatch: it chooses which view to build for a view model.

A few facts describe how the generated lookup behaves.

  • It prefers the service locator. The generated resolver for a view asks the service locator first. When the service locator has no view, the resolver builds the view with its parameterless constructor.
  • It registers itself. From C# 9 the registration runs in a module initializer, before any code in the assembly. In older projects it runs when a binding first uses the generated class.
  • It keeps one view for each pair of view model and contract. The first registration in source order wins.
  • It skips open generic views. That includes a view nested in an open generic type.
  • Assemblies stack. DefaultViewLocator keeps the lookup of each assembly, in registration order, and asks the most recent first. When two assemblies both have a view for the same view model, the assembly that registered last wins.

Reuse one view

[SingleInstanceView] tells the lookup to build a view once and hand out the same instance every time. Each resolve sets the latest view model on that instance. Use it only for a view that is not shown in more than one place at once.

var everyday = CreateAccount(EverydayId, EverydayName);
var savings = CreateAccount(SavingsId, SavingsName);
DefaultViewLocator locator = new();

var first = locator.ResolveView(everyday);
var second = locator.ResolveView(savings);

Console.WriteLine(first?.GetType().Name);
Console.WriteLine(ReferenceEquals(first, second));
Console.WriteLine(ReferenceEquals(first?.ViewModel, savings));
AccountSummaryView
True
True

Leave a view out

[ExcludeFromViewRegistration] keeps a view out of the lookup. Nothing answers until the application maps the view by hand. The next sections show Map.

var item = new TodoItem { Title = "Renew car registration" };
DefaultViewLocator locator = new();

var before = locator.ResolveView(item);
locator.Map<TodoItem, TodoItemPreviewView>();
var after = locator.ResolveView(item);

Console.WriteLine(before is null);
Console.WriteLine(after?.GetType().Name);
True
TodoItemPreviewView

Register a view that needs arguments

ReceiptView has no parameterless constructor, so the lookup cannot build it. The lookup finds it only when the service locator holds it. Register the view with AppLocator, and unregister it when you finish.

TransferReceipt receipt = new("RCPT-000001", TransferAmount, AccountBalance - TransferAmount, DateTimeOffset.UnixEpoch);
DefaultViewLocator locator = new();

var before = locator.ResolveView(receipt);
AppLocator.CurrentMutable.Register<IViewFor<TransferReceipt>>(static () => new ReceiptView(ReceiptHeading));

try
{
    var after = (ReceiptView?)locator.ResolveView(receipt);

    Console.WriteLine(before is null);
    Console.WriteLine(after?.HeadingLabel.Text);
    Console.WriteLine(ReferenceEquals(after?.ViewModel, receipt));
}
finally
{
    AppLocator.CurrentMutable.UnregisterAll<IViewFor<TransferReceipt>>();
}
True
Transfer receipt
True

Register a view for an interface

The generated lookup tests the view model with is, so a view registered for an interface serves every class that implements it. You saw this in the first walkthrough. A view model can also have a view for its own class next to the interface view. The lookup tests registrations in source-file order and returns the first match. Nothing reports the overlap.

AccountListViewModel current = new([CreateAccount(EverydayId, EverydayName)]);
DefaultViewLocator locator = new();

var view = locator.ResolveView(current);

Console.WriteLine(view?.GetType().Name);
Console.WriteLine(view is DetailedAccountListView);
AccountListView
False

Here the interface view comes first, so DetailedAccountListView is never returned. Give one of the two a contract, or map it by hand, when you want both.

Register a lookup of your own

DefaultViewLocator.SetGeneratedViewDispatch is the method the generated code calls. You can call it yourself with a function that takes a view model and a contract. Return null for anything the function does not know. Editors hide the method from completion lists, because generated code is its intended caller.

var item = new TodoItem { Title = "Renew car registration" };
DefaultViewLocator locator = new();

var before = locator.ResolveView(item, PreviewCardContract);

DefaultViewLocator.SetGeneratedViewDispatch(static (viewModel, contract) =>
    viewModel is TodoItem && contract == PreviewCardContract ? new TodoItemPreviewView() : null);

var after = locator.ResolveView(item, PreviewCardContract);
var otherContract = locator.ResolveView(item, null);

Console.WriteLine(before is null);
Console.WriteLine(after?.GetType().Name);
Console.WriteLine(ReferenceEquals(after?.ViewModel, item));
Console.WriteLine(otherContract is null);
True
TodoItemPreviewView
True
True

The lookup is shared by every DefaultViewLocator in the process. Adding it once is enough, and adding the same function again has no effect.

Map views by hand

Map registers a view on one locator. Use it for a view the generator skips, or for a view you choose at run time. A mapping belongs to the locator instance you call it on. Calling Map again for the same view model and contract replaces the old mapping.

MemberWhat it registers
Map<TViewModel, TView>()A view built with its parameterless constructor.
Map<TViewModel, TView>(contract)The same view under a contract.
Map<TViewModel>(factory)A view built by a factory.
Map<TViewModel>(factory, contract)A factory under a contract.
Unmap<TViewModel>() and Unmap<TViewModel>(contract)Removes a mapping.

The next snippet maps the preview view for a to-do item and resolves it. The locator builds the view with its parameterless constructor and sets the view model, so a view the generator skips still works.

var item = CreateItem();
DefaultViewLocator locator = new();

locator.Map<TodoItem, TodoItemPreviewView>();

var view = locator.ResolveView(item);

Console.WriteLine(view?.GetType().Name);
Console.WriteLine(ReferenceEquals(view?.ViewModel, item));
TodoItemPreviewView
True

Add a contract, and the mapping answers only to that contract.

var item = CreateItem();
DefaultViewLocator locator = new();

locator.Map<TodoItem, TodoItemPreviewView>(PreviewContract);

var preview = locator.ResolveView(item, PreviewContract);
var plain = locator.ResolveView(item, null);

Console.WriteLine(preview?.GetType().Name);
Console.WriteLine(plain is null);
TodoItemPreviewView
True

Use a factory for a screen that needs setup before it is shown. The locator calls the factory on every resolve, so each call gets a new view.

var item = CreateItem();
DefaultViewLocator locator = new();

locator.Map<TodoItem>(static () => new TodoItemPreviewView { IsCompact = true });

var view = (TodoItemPreviewView?)locator.ResolveView(item);

Console.WriteLine(view?.IsCompact);
Console.WriteLine(ReferenceEquals(view?.ViewModel, item));
True
True

Unmap removes a mapping and returns true when one existed. A null contract removes the default mapping.

var item = CreateItem();
DefaultViewLocator locator = new();
locator.Map<TodoItem, TodoItemPreviewView>();
locator.Map<TodoItem, TodoItemPreviewView>(PreviewContract);

var removedDefault = locator.Unmap<TodoItem>();
var removedAgain = locator.Unmap<TodoItem>();
var removedContract = locator.Unmap<TodoItem>(PreviewContract);

Console.WriteLine(removedDefault);
Console.WriteLine(removedAgain);
Console.WriteLine(removedContract);
Console.WriteLine(locator.ResolveView(item) is null);
True
False
True
True

A mapping matches the exact type you register. The generic ResolveView reads that type from the compile-time type of the variable you pass. The object overload reads it from the runtime type. A mapping for an interface matches only when the call is typed as that interface.

Chain mappings with the builder

CreateMappingBuilder returns a ViewMappingBuilder. It has the same four Map overloads, and each returns the builder so that you can chain them.

var item = CreateItem();
DefaultViewLocator locator = new();

_ = locator.CreateMappingBuilder()
    .Map<TodoItem, TodoItemPreviewView>()
    .Map<TodoItem, TodoItemDetailView>(DetailContract)
    .Map<TodoItem>(static () => new TodoItemPreviewView { IsCompact = true }, CompactContract);

var standard = locator.ResolveView(item, null);
var detail = locator.ResolveView(item, DetailContract);
var compact = (TodoItemPreviewView?)locator.ResolveView(item, CompactContract);

Console.WriteLine(standard?.GetType().Name);
Console.WriteLine(detail?.GetType().Name);
Console.WriteLine(compact?.IsCompact);
TodoItemPreviewView
TodoItemDetailView
True

Register mappings at startup

A shared locator needs its mappings before the first ResolveView. Do that in the builder. ConfigureViewLocator creates a DefaultViewLocator, hands you its ViewMappingBuilder and registers the locator. Call it after WithCoreServices, so the locator you configured is the one registered last. The next snippet maps two views in the builder and reads them back from the shared locator.

var item = CreateItem();
var builder = (IReactiveUIBindingBuilder)RxBindingBuilder.CreateReactiveUIBindingBuilder();

_ = builder
    .WithCoreServices()
    .ConfigureViewLocator(static mappings => mappings
        .Map<TodoItem, TodoItemPreviewView>()
        .Map<TodoItem, TodoItemDetailView>(DetailContract))
    .BuildApp();

var locator = ViewLocator.GetCurrent();

var standard = locator.ResolveView(item);
var detail = locator.ResolveView(item, DetailContract);

Console.WriteLine(standard?.GetType().Name);
Console.WriteLine(detail?.GetType().Name);
TodoItemPreviewView
TodoItemDetailView

The same call exists as an extension on Splat's IAppBuilder, so a chain that has passed through the Splat builder can still reach it. The extension lives in ReactiveUI.Binding.Mixins. It throws InvalidOperationException when the builder is not a ReactiveUI.Binding builder.

var item = CreateItem();

_ = RxBindingBuilder.CreateReactiveUIBindingBuilder()
    .WithCoreServices()
    .ConfigureViewLocator(static mappings => mappings.Map<TodoItem, TodoItemPreviewView>())
    .BuildApp();

var view = ViewLocator.GetCurrent().ResolveView(item);

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

The generated lookup is shared by every locator, so a locator built by ConfigureViewLocator still answers for the views the generator found.

The order the locator checks

DefaultViewLocator.ResolveView checks three sources and returns the first view it finds.

  1. The generated lookup, the most recently registered assembly first.
  2. The mappings you added with Map.
  3. The service locator.

Every source sets the view model on the view before the locator returns it. The example registers one view in each source and removes the mapping to show the next source answer.

var item = CreateItem();
var account = CreateAccount();
DefaultViewLocator locator = new();
locator.Map<Account, AccountStatementView>();
locator.Map<TodoItem, TodoItemPreviewView>();
AppLocator.CurrentMutable.Register<IViewFor<TodoItem>>(static () => new TodoItemDetailView());

try
{
    var generated = locator.ResolveView(account);
    var mapped = locator.ResolveView(item);
    _ = locator.Unmap<TodoItem>();
    var registered = locator.ResolveView(item);

    Console.WriteLine(generated?.GetType().Name);
    Console.WriteLine(mapped?.GetType().Name);
    Console.WriteLine(registered?.GetType().Name);
}
finally
{
    AppLocator.CurrentMutable.UnregisterAll<IViewFor<TodoItem>>();
}
AccountSummaryView
TodoItemPreviewView
TodoItemDetailView

The generated lookup answers for the account, even though the example mapped AccountStatementView for Account. The generated source comes first, so a mapping cannot override a view the generator registered. To replace a generated view, add [ExcludeFromViewRegistration] to it.

Next steps

  • Setup shows the builder that registers the locator.
  • Bindings covers the view-first binding methods used above.
  • Threading explains where a binding writes to a view.
  • API reference lists every member.

API reference

MemberWhat it doesType and valuesNotes
IViewForMarks a class as a view of one view model, held as an object.Interface. Extends IActivatableView.Use it when the host does not know the view model type.
IViewFor.ViewModelGets or sets the view model the view shows.Read and write object?.A view implements it explicitly and forwards it to the typed property.
IViewFor<T>Marks a class as a view of one view model type.Interface. T : class. Extends IViewFor.The generator reads each class that implements it to build the view dispatch.
IViewFor<T>.ViewModelGets or sets the view model the view shows, typed.Read and write T?.Hides the object property of IViewFor. The value is null until a host assigns one.
IActivatableViewMarks an object as a view a host may activate.Marker interface with no members.Every IViewFor implements it.
IViewLocatorFinds the view for a view model.Interface.Implement it to write your own locator.
IViewLocator.ResolveViewReturns the view for a view model under a contract.Generic form ResolveView<TViewModel>(TViewModel, string?) with TViewModel : class, and ResolveView(object?, string?). Both return IViewFor?.A null contract picks the default screen. The result is null when no view matches. The object form carries [RequiresDynamicCode].
ViewLocatorMixins.ResolveViewResolves a view with the default contract.Extension of IViewLocator, in a generic form and an object? form. Both return IViewFor?.Throws ArgumentNullException when the locator is null. The object? form carries [RequiresDynamicCode].
ViewLocator.GetCurrentReturns the locator registered with the service locator.Static method on the static class ViewLocator. Returns IViewLocator.Throws ViewLocatorNotFoundException when no locator is registered.
ViewLocatorNotFoundExceptionReports that no view locator is registered, or that a screen is missing.Class that extends Exception.ViewLocator.GetCurrent throws it. Your own code can throw it.
ViewLocatorNotFoundException(...)Creates the exception.No arguments, string message, or string message with Exception innerException.The form with no arguments has a message that names WithCoreServices and BuildApp.
DefaultViewLocatorThe locator that WithCoreServices registers.Sealed class. Implements IViewLocator. Has a public parameterless constructor.Checks the generated lookup, then the mappings of the instance, then the service locator.
DefaultViewLocator.ResolveViewReturns the view for a view model and sets its ViewModel.The two forms of IViewLocator.ResolveView.A null view model gives null. The generic form reads the compile-time type and the object form reads the runtime type. An exception from a view constructor or factory reaches the caller.
DefaultViewLocator.MapRegisters a view for a view model type on this locator.Map<TViewModel, TView>(), with TView : IViewFor, new(), or Map<TViewModel>(Func<IViewFor> factory). Each takes an optional string? contract. TViewModel : class. Returns void.Replaces a mapping for the same type and contract. A null contract is the default mapping. A null factory throws ArgumentNullException.
DefaultViewLocator.UnmapRemoves a mapping.Unmap<TViewModel>() or Unmap<TViewModel>(string? contract). TViewModel : class. Returns bool.true when a mapping existed. A null contract removes the default mapping.
DefaultViewLocator.CreateMappingBuilderCreates a builder that registers mappings in a chain.Returns ViewMappingBuilder.The builder writes to this locator.
DefaultViewLocator.SetGeneratedViewDispatchAdds a lookup that the locators check before mappings.Static. Takes Func<object, string, IViewFor?>.Generated code calls it. Editors hide it. The most recent lookup is asked first. A null argument throws ArgumentNullException. Adding a function twice has no effect.
ViewMappingBuilderRegisters mappings on a locator in a chain.Sealed class.Only DefaultViewLocator.CreateMappingBuilder and ConfigureViewLocator create one.
ViewMappingBuilder.MapRegisters a view for a view model type.The same forms as DefaultViewLocator.Map. Returns ViewMappingBuilder.Returns the builder so calls chain. A null factory throws ArgumentNullException.
ViewContractAttributeRegisters a view in the generated lookup under a contract.Sealed attribute for classes. Constructor takes string contract.A view with no attribute is the default screen for its view model.
ViewContractAttribute.ContractGets the contract string.Read-only string.The string a caller passes to ResolveView.
SingleInstanceViewAttributeMakes the generated lookup build the view once and reuse it.Sealed attribute for classes.Needs a parameterless constructor. Do not use it on a view shown in more than one place at once.
ExcludeFromViewRegistrationAttributeLeaves a view out of the generated lookup.Sealed attribute for classes. Not inherited. One per class.Register the view with Map or the service locator instead.
ConfigureViewLocatorRegisters a DefaultViewLocator that holds the mappings you add.Takes Action<ViewMappingBuilder> configure. Returns IReactiveUIBindingBuilder.A null action throws ArgumentNullException. An extension on IAppBuilder in BuilderMixins throws InvalidOperationException for another builder type.