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.
| Member | Use 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.
DefaultViewLocatorkeeps 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.
| Member | What 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.
- The generated lookup, the most recently registered assembly first.
- The mappings you added with
Map. - 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¶
| Member | What it does | Type and values | Notes |
|---|---|---|---|
IViewFor | Marks 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.ViewModel | Gets 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>.ViewModel | Gets 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. |
IActivatableView | Marks an object as a view a host may activate. | Marker interface with no members. | Every IViewFor implements it. |
IViewLocator | Finds the view for a view model. | Interface. | Implement it to write your own locator. |
IViewLocator.ResolveView | Returns 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.ResolveView | Resolves 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.GetCurrent | Returns the locator registered with the service locator. | Static method on the static class ViewLocator. Returns IViewLocator. | Throws ViewLocatorNotFoundException when no locator is registered. |
ViewLocatorNotFoundException | Reports 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. |
DefaultViewLocator | The 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.ResolveView | Returns 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.Map | Registers 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.Unmap | Removes 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.CreateMappingBuilder | Creates a builder that registers mappings in a chain. | Returns ViewMappingBuilder. | The builder writes to this locator. |
DefaultViewLocator.SetGeneratedViewDispatch | Adds 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. |
ViewMappingBuilder | Registers mappings on a locator in a chain. | Sealed class. | Only DefaultViewLocator.CreateMappingBuilder and ConfigureViewLocator create one. |
ViewMappingBuilder.Map | Registers 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. |
ViewContractAttribute | Registers 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.Contract | Gets the contract string. | Read-only string. | The string a caller passes to ResolveView. |
SingleInstanceViewAttribute | Makes 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. |
ExcludeFromViewRegistrationAttribute | Leaves 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. |
ConfigureViewLocator | Registers 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. |