StaticViewLocator 0.7.0
dotnet add package StaticViewLocator --version 0.7.0
NuGet\Install-Package StaticViewLocator -Version 0.7.0
<PackageReference Include="StaticViewLocator" Version="0.7.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="StaticViewLocator" Version="0.7.0" />
<PackageReference Include="StaticViewLocator"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add StaticViewLocator --version 0.7.0
#r "nuget: StaticViewLocator, 0.7.0"
#:package StaticViewLocator@0.7.0
#addin nuget:?package=StaticViewLocator&version=0.7.0
#tool nuget:?package=StaticViewLocator&version=0.7.0
StaticViewLocator
A C# source generator that automatically implements static view locator for Avalonia without using reflection.
Usage
Add NuGet package reference to project.
<PackageReference Include="StaticViewLocator" Version="0.6.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
Annotate a view locator class with [StaticViewLocator], make it partial, and let the generator provide the lookup tables and fallback helpers.
[StaticViewLocator]
public partial class ViewLocator : IDataTemplate
{
public Control? Build(object? data)
{
if (data is null)
{
return null;
}
var type = data.GetType();
var func = TryGetFactory(type) ?? TryGetFactoryFromInterfaces(type);
if (func is not null)
{
return func.Invoke();
}
var missingView = TryGetMissingView(type) ?? TryGetMissingViewFromInterfaces(type);
if (missingView is not null)
{
return new TextBlock { Text = missingView };
}
throw new Exception($"Unable to create view for type: {type}");
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
Explicit mappings
Use [StaticViewMapping] on the locator when a view and view model do not follow the configured naming rules. Explicit mappings override convention-based discovery and also support model types whose names do not end in ViewModel.
[StaticViewLocator]
[StaticViewMapping(typeof(LoginViewModel), typeof(LogInView))]
[StaticViewMapping(typeof(DashboardModel), typeof(DashboardScreen))]
public partial class ViewLocator : IDataTemplate
{
}
Generic MVVM contract mappings
Use ViewModelMappingContracts when a framework already expresses the view/view-model relationship through a one-argument generic interface or base class. The generator scans concrete Avalonia views at compile time, follows their interface and base-type ancestry, and maps the contract's generic argument to the concrete view.
public interface IViewFor<TViewModel>
{
}
public abstract class FrameworkView<TViewModel> : UserControl, IViewFor<TViewModel>
{
}
public sealed class DashboardScreen : FrameworkView<DashboardModel>
{
}
[StaticViewLocator(
ViewModelMappingContracts = new[] { typeof(IViewFor<>) })]
public partial class ViewLocator
{
}
This generates DashboardModel -> DashboardScreen even though neither type follows the default *ViewModel -> *View naming convention. Multiple contracts can be supplied in the array.
Contract discovery has these constraints:
- each configured contract must be an open generic type with exactly one type parameter;
- invalid configured contracts are ignored and produce the
SVL0006error; - the contract may be an interface or a base class, including one inherited indirectly;
- discovered views must be concrete, non-generic types from the current compilation;
- discovered views must derive from
UserControl,Window, or a type configured throughStaticViewLocatorAdditionalViewBaseTypes; - discovered views must be accessible to the locator and expose an accessible constructor callable without arguments;
- contract discovery happens at compile time and adds no runtime assembly scanning;
- ambiguous configured-contract mappings are omitted, produce
SVL0003, and must be resolved with an explicit[StaticViewMapping]override; automatic ReactiveUI ambiguities also produceSVL0003.
Mapping sources are applied in this order, from highest to lowest priority:
[StaticViewMapping]explicit overrideViewModelMappingContractsinference- automatic
ReactiveUI.IViewFor<TViewModel>inference whenGenerateIViewLocator = true - configured namespace and type-name conventions
Exact factory generation
Set GenerateViewFactoryMethods = true to generate this private partial-class helper:
private static bool TryCreateViewExact(Type viewModelType, out Control? view)
It performs only an exact dictionary lookup and invokes the statically generated constructor delegate. It does not walk base types or interfaces and does not construct closed generic types at runtime. This is useful when an MVVM framework needs to create a view and then apply its own view-model assignment or lifecycle rules.
If the annotated partial class already declares a compatible TryCreateViewExact(Type, out Control?) returning bool, the generator reuses it instead of emitting a duplicate helper. The source helper may be static or instance-based because generated adapter calls are instance methods.
When a locator supplies its own Build method, set GenerateRuntimeTypeFallbackMethods = false to omit the legacy BaseType, GetInterfaces(), and generic-type-definition fallback helpers. If the generator must emit the legacy Build, those helpers are always emitted because that implementation depends on them. The generated IDataTemplate.Build path uses exact static lookup and does not emit these runtime type-walking helpers unless a source-declared Build(object?) requires them and the option remains enabled.
Generated ReactiveUI IViewLocator and Avalonia IDataTemplate
The generator can optionally generate the complete ReactiveUI IViewLocator and Avalonia IDataTemplate adapter. This removes the manual Build, Match, and four ResolveView methods from the locator class.
using ReactiveUI;
using StaticViewLocator;
[StaticViewLocator(
GenerateIViewLocator = true,
GenerateIDataTemplate = true,
GenerateRuntimeTypeFallbackMethods = false,
GeneratedAdapterResolutionMode = ViewResolutionMode.ExactThenBaseTypesThenInterfaces,
DataTemplateMatchTypes = new[] { typeof(ViewModelBase) })]
public partial class ViewLocator
{
}
GenerateIViewLocator = true requires the consumer project to reference ReactiveUI. It automatically discovers concrete Avalonia views implementing ReactiveUI.IViewFor<TViewModel>, so ViewModelMappingContracts = new[] { typeof(IViewFor<>) } and GenerateViewFactoryMethods = true are not required for this mode. User-configured ViewModelMappingContracts take precedence over this automatic ReactiveUI inference, while [StaticViewMapping] remains the final override. The generated locator implements all four current ReactiveUI resolution overloads. Runtime-instance resolution assigns IViewFor.ViewModel; non-null contracts return null because the generated map is currently unkeyed.
ReactiveUI package families
ReactiveUI 24 is available in two mutually exclusive distribution families. Choose one complete family for an application and keep every ReactiveUI and Avalonia package on that same row:
| Mode | ReactiveUI packages | Concrete implementation namespaces |
|---|---|---|
| Primitives | ReactiveUI, ReactiveUI.Avalonia |
ReactiveUI, ReactiveUI.Avalonia |
| System.Reactive | ReactiveUI.Reactive, ReactiveUI.Avalonia.Reactive |
ReactiveUI.Reactive, ReactiveUI.Avalonia.Reactive |
Do not reference packages from both rows in the same application. StaticViewLocator supports either mode without a generator option because its generated adapter uses only the distribution-neutral contracts ReactiveUI.IViewFor, ReactiveUI.IViewFor<TViewModel>, and ReactiveUI.IViewLocator. Only application code that uses concrete types such as ReactiveObject, ReactiveCommand, ViewModelViewHost, or UseReactiveUI needs the namespace from the selected distribution.
GenerateIDataTemplate = true adds IDataTemplate, Control? Build(object?), and Match(object?). The generated adapters share a statically emitted resolver. Its hot path is one data.GetType() call and an exact dictionary lookup, followed only when needed by generated data is MappedBaseType or data is IMappedContract branches. It does not walk BaseType, call GetInterfaces or IsAssignableFrom, transform names, scan assemblies, probe factories, or construct a control from Match.
Match returns true only when that resolver finds a real view factory. Entries in the missing-view diagnostic map never count as matches. DataTemplateMatchTypes is an additional generated data is T filter over resolved models; it cannot make an unresolved model match.
GeneratedAdapterResolutionMode controls compile-time fallback policy for both generated adapters:
| Mode | Generated resolution order |
|---|---|
Exact |
Exact runtime type only. |
ExactThenBaseTypes |
Exact type, then nearest mapped base class. |
ExactThenInterfaces |
Exact type, then nearest mapped interface. |
ExactThenBaseTypesThenInterfaces |
Exact type, mapped bases, then mapped interfaces. This is the default and preserves the established base-before-interface precedence. |
ExactThenInterfacesThenBaseTypes |
Exact type, mapped interfaces, then mapped bases. |
For concrete model types visible to the compilation, inherited or interface results are flattened into exact dictionary entries. Runtime type-test branches cover types that cannot be enumerated at compile time. Interface branches have deterministic ordering; when a discovered concrete type has equally applicable interfaces mapped to different views, SVL0007 requires an explicit [StaticViewMapping]. For an open generic model mapping, the generator first tries to infer an unambiguous non-generic base class or interface contract that resolves to the same view, then emits a legal closed-instance type test for that contract. A conflicting or overly broad contract is not inferred. If no safe mapped or inferred contract exists, it reports SVL0008 instead of adding a reflective fallback.
The generated Build pipeline is:
BuildInvalidView(param)fornullinput.BuildResolvedView(param)for the normal statically mapped view.BuildFallbackView(param)for application-specific fallback cases.BuildMissingView(param, viewModelType)for the final not-found control.
For non-sealed locator classes, default hook implementations are generated as protected virtual, allowing normal subclass overrides. For sealed locator classes the generated defaults are private, because virtual members are illegal on sealed types. In both cases, a hook with the corresponding by-value object?-based signature declared directly in the annotated partial class suppresses generation of that default hook; unrelated or ref/in/out overloads do not suppress it. A custom hook return type must be implicitly convertible to Control?. This allows application-specific behavior without replacing the public generated Build method. For example, an application-specific context fallback can be implemented as:
public interface IContextHost
{
object? Context { get; }
}
[StaticViewLocator(
GenerateIViewLocator = true,
GenerateIDataTemplate = true,
GenerateRuntimeTypeFallbackMethods = false,
GeneratedAdapterResolutionMode = ViewResolutionMode.ExactThenBaseTypesThenInterfaces,
DataTemplateMatchTypes = new[] { typeof(ViewModelBase), typeof(IContextHost) })]
public partial class ViewLocator
{
protected virtual Control? BuildFallbackView(object? param)
{
if (param is not IContextHost { Context: ViewModelBase })
{
return null;
}
var contentControl = new ContentControl
{
DataContext = param,
};
contentControl.Bind(
ContentControl.ContentProperty,
new Binding(nameof(IContextHost.Context)));
return contentControl;
}
protected virtual bool MatchDataTemplate(object? data)
{
return data is IContextHost || TryGetResolvedViewFactory(data, out _);
}
}
The custom match hook is intentional in this example: specialized fallback objects are not real generated mappings, so the default resolved-only Match does not claim them. Custom BuildResolvedView, BuildFallbackView, BuildMissingView, and MatchDataTemplate hooks remain available for policies that do not fit the generated resolver.
The generator assembly itself does not reference ReactiveUI. Distribution-neutral ReactiveUI contract types are referenced only in generated consumer source when GenerateIViewLocator is enabled.
Source-declared public adapter methods are reused only when they implement the corresponding framework contract: public instance accessibility, by-value parameters, the expected return type, and the ReactiveUI where TViewModel : class constraint. A same-signature member that cannot implement the contract produces SVL0002 rather than silently suppressing required generated code.
The solution builds the same complete AXAML sample against both distributions. StaticViewLocatorReactiveUIDemo covers the primitives packages, while StaticViewLocatorReactiveUIDemo.Reactive links the same C# and AXAML source and covers the System.Reactive packages. The shared UI displays the same navigation state through two side-by-side paths: an Avalonia ContentControl using the generated IDataTemplate, and a ReactiveUI ViewModelViewHost whose ViewLocator is the generated locator. It also demonstrates the context-wrapper fallback without runtime view scanning. A small local ReactiveViewHost wrapper keeps the AXAML distribution-neutral because the concrete ReactiveUI control namespace differs between package families.
Generator diagnostics
| ID | Severity | Meaning |
|---|---|---|
SVL0001 |
Error | A requested adapter contract type is not referenced. |
SVL0002 |
Error | A source member collides with a generated adapter member but has an incompatible contract. |
SVL0003 |
Error | More than one inferred view maps to the same view model. |
SVL0004 |
Error | A mapped view is inaccessible, abstract, or has no accessible constructor callable without arguments. |
SVL0005 |
Error | The annotated locator is nested, static, file-local, or not partial. |
SVL0006 |
Error | A configured mapping contract is not an open generic interface or class with exactly one type parameter. |
SVL0007 |
Error | Equally applicable mapped interfaces resolve a discovered model to different views; add an explicit mapping. |
SVL0008 |
Error | An open generic adapter mapping has no safe mapped or inferred non-generic base/interface contract for a compiled type test. |
Attribute options
| Option | Default | Behavior |
|---|---|---|
GenerateViewFactoryMethods |
false |
Emits TryCreateViewExact(Type, out Control?) for framework adapters and custom locators unless a compatible source helper already exists. |
GenerateRuntimeTypeFallbackMethods |
true |
Emits base/interface/open-generic runtime fallback helpers when a legacy or source-declared Build path needs them; generated IDataTemplate.Build does not require them. |
GenerateIViewLocator |
false |
Generates ReactiveUI IViewLocator, all four ResolveView overloads, and automatic IViewFor<TViewModel> compile-time mappings. Requires a ReactiveUI reference in the consumer project. |
GenerateIDataTemplate |
false |
Generates Avalonia IDataTemplate, Build, Match, and customizable build hooks. |
GeneratedAdapterResolutionMode |
ExactThenBaseTypesThenInterfaces |
Selects the exact/base/interface precedence compiled into both generated adapters. |
ViewModelMappingContracts |
empty | Infers mappings from configured open generic contracts with one type parameter. |
DataTemplateMatchTypes |
empty | Additional generated type filter applied only after a real factory resolves. |
[StaticViewMapping(typeof(TViewModel), typeof(TView))] is repeatable and is the final override for a mapped view model. It also admits model types whose names do not end in ViewModel.
The generator emits:
s_views: resolved mappings fromTypetoFunc<Control>s_missingViews: unresolved mappings used for"Not Found: ..."fallback text when the generator emits the default or legacy missing-view path; omitted when a generated adapter supplies a customBuildMissingView- optional exact factory creation through
TryCreateViewExact - optional generated ReactiveUI
IViewLocator - optional generated Avalonia
IDataTemplate - runtime helpers for generic type-definition, base-class, and interface fallback only when required or enabled for a source-declared/legacy
Buildpath
By default, the legacy generated lookup order is:
- exact runtime type
- generic type definition for generic runtime types
- base type chain
- implemented interfaces in reverse order
The generated IViewLocator and generated IDataTemplate paths use the configured compile-time resolver and never call the legacy runtime walkers. GenerateRuntimeTypeFallbackMethods continues to preserve those walkers only for a source-declared or legacy Build implementation that explicitly retains them.
Migration note: resolved-only template matching
Generated IDataTemplate.Match no longer treats an unresolved model or a missing-view diagnostic entry as a match. A DataTemplateMatchTypes entry also no longer broadens matching to unresolved objects. Applications that intentionally build a specialized fallback must declare a MatchDataTemplate hook for that fallback, as shown above. Applications that previously depended on runtime base/interface walking should select the corresponding GeneratedAdapterResolutionMode; the walk is emitted as compile-time exact mappings and type-test branches rather than reflective runtime traversal.
Source generator will generate mappings using convention-based transforms. By default:
- namespace
ViewModelsbecomesViews - type suffix
ViewModelbecomesView - generic arity markers are removed from the target view name
- interface prefix
Iis stripped before resolving the target view name
This allows patterns like:
MyApp.ViewModels.SettingsViewModel -> MyApp.Views.SettingsViewMyApp.ViewModels.WidgetViewModel<T> -> MyApp.Views.WidgetViewMyApp.ViewModels.IDetailsViewModel -> MyApp.Views.DetailsView
public partial class ViewLocator
{
private static Dictionary<Type, Func<Control>> s_views = new()
{
[typeof(StaticViewLocatorDemo.ViewModels.TestViewModel)] = () => new StaticViewLocatorDemo.Views.TestView(),
};
private static Dictionary<Type, string> s_missingViews = new()
{
[typeof(StaticViewLocatorDemo.ViewModels.MainWindowViewModel)] = "Not Found: StaticViewLocatorDemo.Views.MainWindowView",
};
}
MSBuild configuration
You can scope which view model namespaces are considered and opt into additional behaviors.
<PropertyGroup>
<StaticViewLocatorViewModelNamespacePrefixes>MyApp.ViewModels,MyApp.Modules</StaticViewLocatorViewModelNamespacePrefixes>
<StaticViewLocatorIncludeInternalViewModels>false</StaticViewLocatorIncludeInternalViewModels>
<StaticViewLocatorIncludeReferencedAssemblies>false</StaticViewLocatorIncludeReferencedAssemblies>
<StaticViewLocatorAdditionalViewBaseTypes>MyApp.Controls.ToolWindowBase</StaticViewLocatorAdditionalViewBaseTypes>
<StaticViewLocatorNamespaceReplacementRules>ViewModels=Views</StaticViewLocatorNamespaceReplacementRules>
<StaticViewLocatorTypeNameReplacementRules>ViewModel=View,Vm=Page</StaticViewLocatorTypeNameReplacementRules>
<StaticViewLocatorStripGenericArityFromViewName>true</StaticViewLocatorStripGenericArityFromViewName>
<StaticViewLocatorInterfacePrefixesToStrip>I</StaticViewLocatorInterfacePrefixesToStrip>
</PropertyGroup>
Defaults and behavior:
StaticViewLocatorViewModelNamespacePrefixesuses comma separators and defaults to all namespaces.StaticViewLocatorIncludeReferencedAssembliesdefaults tofalse. Whentrue, view models from referenced assemblies are included.StaticViewLocatorIncludeInternalViewModelsdefaults tofalse. Whentrue, internal view models from referenced assemblies are included only if the referenced assembly exposes them viaInternalsVisibleTo.StaticViewLocatorAdditionalViewBaseTypesuses comma separators and extends the default view base type list.StaticViewLocatorNamespaceReplacementRulesuses comma separators withfrom=topairs and is applied sequentially to the view-model namespace when deriving the target view namespace. The default includesViewModels=Views.StaticViewLocatorTypeNameReplacementRulesuses comma separators withfrom=topairs and is applied sequentially to the view-model type name when deriving the target view name. The default includesViewModel=View.StaticViewLocatorStripGenericArityFromViewNamedefaults totrue. When enabled, generic arity markers like`1are removed from the derived target view name, soWidgetViewModel<T>can map toWidgetView.StaticViewLocatorInterfacePrefixesToStripuses comma separators and is applied to interface view-model names before looking up the target view. The default includesI.
Use commas for multi-value MSBuild properties. Semicolons are not supported because Roslyn treats ; as an EditorConfig comment marker when exporting CompilerVisibleProperty values, truncating everything after the first semicolon (dotnet/roslyn#51692).
These properties are exported as CompilerVisibleProperty by the package, so analyzers can read them without extra project configuration.
Generator architecture
The incremental pipeline remains split into the original source view-model discovery, analyzer-option ingestion, optional referenced-assembly discovery, locator discovery, and per-locator emission stages. Mapping normalization applies conventions and configured contracts first, then automatic ReactiveUI discovery, then explicit overrides. Adapter analysis is isolated in StaticViewLocatorGenerator.Adapters.cs; it validates framework symbols and source-member compatibility before emitting IDataTemplate or IViewLocator members. Every test-helper generation run compiles the resulting syntax trees, so snapshot assertions cannot pass while generated code is invalid.
Supported resolution features
- Exact type mapping
- Explicit view/view-model mapping overrides
- Compile-time mapping inference from generic MVVM contracts
- Optional exact factory generation for framework adapters
- Optional generated ReactiveUI
IViewLocator - Optional generated Avalonia
IDataTemplate - Customizable generated
Buildpipeline hooks - Optional omission of runtime type-walking helpers in custom locators
- Open generic mapping, for example
WidgetViewModel<T> -> WidgetView - Base-class fallback
- Interface fallback
- Configurable namespace replacement rules
- Configurable type-name replacement rules
- Configurable interface prefix stripping
- Configurable additional allowed view base types
- Optional referenced-assembly scanning
- Optional internal view-model inclusion
Notes
- Convention candidate discovery starts from types whose names end with
ViewModel. Explicit and generic-contract mappings can add other model types. - Missing views do not block fallback resolution. The generator keeps unresolved targets in
s_missingViews, so a derived type can still fall back to a base-class or interface mapping before returning a"Not Found"placeholder in the legacy runtime-fallback path. - If you provide custom replacement rules, they take precedence over the built-in defaults.
- Replacement targets may be empty. For example,
ViewModel=removes theViewModelsuffix without adding a replacement suffix. - Exact factory generation is intentionally separate from runtime fallback. Framework adapters should prefer exact creation when the framework supplies the concrete view-model type.
Default view base types:
Avalonia.Controls.UserControlAvalonia.Controls.Window
Accessibility rules:
- View models in the current compilation are always eligible (subject to namespace prefixes).
- Referenced assembly view models must be public unless
StaticViewLocatorIncludeInternalViewModelsis enabled andInternalsVisibleTois configured. - Locator classes may be public or internal, including in the global namespace, but must be top-level, non-static, non-file-local, and partial.
License
StaticViewLocator is licensed under the MIT license. See LICENSE file for details.
Learn more about Target Frameworks and .NET Standard.
This package has no dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories (2)
Showing the top 2 popular GitHub repositories that depend on StaticViewLocator:
| Repository | Stars |
|---|---|
|
wieslawsoltes/Dock
A docking layout system.
|
|
|
wieslawsoltes/Core2D
A multi-platform data driven 2D diagram editor.
|