barbatos-aquarius-view
Views and ViewModels with Barbatos.Wpf.Aquarius (aq: XAML) - Ref/Computed/Watch, aq:Setup ViewModel resolution, aq:Lifecycle hooks (IOnMounted, IOnMountedAsync), Directives.Model/Show/Event, If/Expr,
SKILL.md.claude/skills/barbatos-aquarius-view/SKILL.md
GitHubBarbatos.Wpf.Aquarius - Views and ViewModels
Vue 3's Composition API surface (ref/computed/watch, lifecycle hooks, v-model/v-show/v-if/v-on,
Teleport, Transition, provide/inject, Suspense, slots, <component :is> + KeepAlive) ported to
plain WPF as thin sugar over CommunityToolkit.Mvvm and WPF's own binding engine - never a
replacement for either. One xmlns, xmlns:aq="http://schemas.barbatos.co/aquarius/2026/xaml",
reaches all four CLR namespaces (Barbatos.Wpf.Aquarius.Reactivity, .Composition, .Xaml,
.Animation). Aquarius does not depend on Core and works in any WPF app. Not here: navigation
(barbatos-aquarius-router), validation (barbatos-valee-form), state shared across screens
(barbatos-apsu-store).
Sources of truth
- Docs: README - Reactivity, Lifecycle Hooks, Setup, Directives, Expr - and API-REFERENCE.md.
- Code:
src/Barbatos.Wpf.Aquarius/-Reactivity/,Composition/(Setup.cs,Lifecycle.cs,AquariusServiceCollectionExtensions.cs),Xaml/(Directives.*.cs,Expr*.cs),Animation/. - Sample:
samples/Barbatos.Wpf.Aquarius.Sample/- one self-contained View/ViewModel pair per tab.WpfProgram.cs(AddAquarius/UseAquarius),MainWindow.xaml(parent-bound tab shell),ReactivityDemoViewModel.cs,LifecycleDemoView.xaml+LifecycleDemoViewModel.cs,DirectivesDemoView.xaml+FocusDirective.cs,ExprDemoView.xaml. Self-sufficient View:samples/Barbatos.Wpf.Samples.Shared/InfoCardView.xaml; where the naming convention stops:samples/Barbatos.Wpf.Samples.Shared/CrossAssembly/AccountSummaryPresenter.cs. - Tests that pin behaviour (
tests/Barbatos.Wpf.Aquarius.UnitTests/):SetupTests.cs,AquariusDependencyInjectionTests.cs,LifecycleTests.cs,AsyncLifecycleHookTests.cs,LifecycleLeakTests.cs,WatchTests.cs,ComputedTests.cs,Directives*Tests.cs,DirectiveExtensibilityTests.cs,IfControlTests.cs,ExprXamlTests.cs,SuspenseTests.cs. - patterns.md - read when the task involves Teleport (overlays, toolbars, dockable panels), Transition/TransitionGroup, Provide/Inject, Slots, or Component + KeepAlive. It holds the verified hook sequences, the keep-alive table and the per-pattern pitfalls.
First: do not reinvent what WPF already has
| Want | Use |
|---|---|
| One-way / two-way binding | {Binding} - no directive needed |
| Repeat a collection | ItemsControl + DataTemplate |
| Per-item template supplied by the consumer ("scoped slot") | ItemsControl.ItemTemplate - the item is the slot props |
| One content area | ContentControl.Content |
Error display for INotifyDataErrorInfo |
WPF's validation adorner (forms: barbatos-valee-form) |
A condition beyond Not/IsNull/IsEqualTo/a short Expr |
a Computed<T> in the ViewModel |
| State several screens share | an Apsu store (barbatos-apsu-store), not Provide |
Wiring a View to its ViewModel - Setup
<UserControl aq:Setup.Enable="True" ... /> <!-- by naming convention -->
<UserControl aq:Setup.ViewModel="{x:Type vm:SomeViewModel}" ... /> <!-- explicit; wins over Enable -->
The default convention (Setup.Resolver) works on the namespace-qualified name: it strips a
trailing View from Type.FullName and appends ViewModel, so MyApp.Features.DashboardView
finds only MyApp.Features.DashboardViewModel. It checks the View's assembly first, then every
assembly already loaded. A ViewModel in another assembly is found; one in another namespace
(...Views.X / ...ViewModels.XViewModel), with another suffix (Presenter), or for a View
whose name does not end in View (MainWindow, SettingsPage) is not - and by default that
failure is silent: DataContext stays null (or inherited). Fix with aq:Setup.ViewModel, or
replace the convention app-wide and make misses loud during development:
builder.Services.AddAquarius(options =>
{
options.ThrowOnUnresolvedViewModels = true; // -> Setup.ThrowOnUnresolved
options.ThrowOnUnresolvedExprIdentifiers = true; // -> Expr.ThrowOnUnresolvedIdentifiers
options.ViewModelResolver = viewType => // -> Setup.Resolver (Views -> ViewModels swap)
viewType.FullName is { } name && name.EndsWith("View", StringComparison.Ordinal)
? viewType.Assembly.GetType(name.Replace(".Views.", ".ViewModels.") + "Model")
: null;
});
var app = builder.Build();
app.Services.UseAquarius(); // Setup.ServiceProvider = container, then applies the options
Without Core, call the same two methods on your own ServiceCollection/provider, or just assign
Setup.ServiceProvider. Rules:
- Instance source is
Setup.Resolve(type):Setup.ServiceProvider?.GetService(type), elseActivator.CreateInstance(type)- so an unregistered ViewModel needs a public parameterless constructor. Register one only when it has constructor dependencies, normally transient (a singleton is one instance shared by every copy of that View).Componentand AquariusRouter'sRouterbuild Views through the same method; never add a second construction path. - Resolution happens once, on
Initialized, notLoaded, soLifecyclealways sees the resolvedDataContext. Set it in the View's own XAML root; setting it from code on an element that already raisedInitializeddoes nothing. - Never combine
aq:Setupon a View with aDataContextset at the usage site (<local:FooView DataContext="{Binding Foo}" />): one silently replaces the other (InfoCardView.xamldocuments it). Choose per View: self-sufficient (Setup) or parent-bound (no Setup, like every tab View in the sample'sMainWindow.xaml). UseAquarius()writes both throw switches from the options whenever the container resolvesIOptions<AquariusOptions>- afterAddAquarius, or after anything else calledAddOptions()(AddLogging, any CoreConfigureXxx) - overwriting earlier static assignments. Configure them inAddAquarius, or assign the statics afterUseAquarius().AddAquariusdoes not read configuration, despiteAquariusOptions.SectionName("Barbatos:Aquarius"). To honour appsettings, bind it yourself afterAddAquarius(the later registration wins):builder.Services.AddOptions<AquariusOptions>().Bind(builder.Configuration.GetSection(AquariusOptions.SectionName));- Only the default resolver caches per View type; a custom one runs for every View instance.
Reactivity
Adapted from ReactivityDemoViewModel.cs:
public Ref<int> Count { get; } = new(0); // using Barbatos.Wpf.Aquarius.Reactivity;
public Computed<int> Doubled { get; }
public Computed<string> FullName { get; }
[ObservableProperty] private string _firstName = "Ada";
[ObservableProperty] private string _lastName = "Lovelace";
public ReactivityDemoViewModel(Action<string> log)
{
Doubled = Computed<int>.From(() => Count.Value * 2, Count);
FullName = Computed<string>.From(
() => $"{FirstName} {LastName}",
fullName => // writable: the setter mutates a dependency
{
var parts = fullName.Split(' ', 2);
FirstName = parts.Length > 0 ? parts[0] : "";
LastName = parts.Length > 1 ? parts[1] : "";
},
this); // `this`: the getter reads [ObservableProperty]s
Watch.On(Count, (newValue, oldValue) => log($"Count changed {oldValue} -> {newValue}"));
}
- Bind through
.Value({Binding Count.Value}); there is deliberately no implicit conversion.Ref<T>.ValueraisesPropertyChangedonly when the value changes by default equality, so mutating a reference value in place (Items.Value.Add(x)) notifies nothing. Computed<T>dependencies are explicit - C# has no proxy tracking. AnyPropertyChangedfrom a listed dependency re-runs the getter (no property-name filter);Valuenotifies only when the result changed. The getter runs once insideFrom, so everything it reads must already be initialized. Assigning a read-onlyComputed<T>throwsInvalidOperationException.Watch.Ontakes aRef<T>only - optionsimmediate,once,deep(also reacts to the value's ownCollectionChangedwhen it isINotifyCollectionChanged),flush. The overload whose callback has a third parameter registers a cleanup that runs before the next invocation and onDispose()(cancel stale async work). To watch an[ObservableProperty], use the generatedpartial void OnXxxChangedorWatch.Effect(effect, this).Watch.Effectalways runs once immediately, then on every dependency change.WatchFlush.Postcoalesces a burst of changes into one callback that gets the newest value and the value from before the burst (0 -> 1 -> 2 -> 3is one(3, 0)); a burst that ends where it started still calls back, with equal arguments.- Lifetime: every dependency's
PropertyChangedholds theComputed/watcher and, through the closure, the ViewModel. When a source outlives the ViewModel (a singleton store, an app-wideRef),Dispose()eachComputed<T>andWatchhandle inIOnUnmounted, or the ViewModel leaks. Sources owned by the same ViewModel need nothing. NextTick.Run/RunAsyncpost atDispatcherPriority.Backgroundto the calling thread's dispatcher when it is a UI thread (inside a dispatcher operation, or a non-pool STA thread); called from a worker they post to the watcher's/element's UI thread, elseApplication.Current's. SoWatchFlush.Postcallbacks andIOnBeforeUpdate/IOnUpdatedrun on the UI thread even when thePropertyChangedbehind them was raised on a pool thread (IOnBeforeUpdatethen fires once the UI thread gets to it, not inside the worker's setter). Only an app with noApplicationat all, calling from a worker, still queues onto that worker's never-pumped dispatcher. Work for a UI thread whose dispatcher has shut down (a window thread closing) is dropped, never moved to another thread.- Use
[ObservableProperty]for plain state,Computed<T>for derived values,Ref<T>when the value itself is passed around (Watch.On,Component.Is,Provide.Value). They coexist.
Lifecycle hooks
<UserControl aq:Lifecycle.Enable="True" ... />
public sealed partial class LifecycleDemoViewModel : ObservableObject,
IOnBeforeMount, IOnMounted, IOnUpdated, IOnUnmounted, IOnMountedAsync
Eleven sync interfaces in Barbatos.Wpf.Aquarius.Composition - IOnBeforeCreate, IOnCreated,
IOnBeforeMount, IOnMounted, IOnBeforeUpdate, IOnUpdated, IOnBeforeUnmount,
IOnUnmounted, IOnActivated, IOnDeactivated, IOnErrorCaptured
(bool OnErrorCaptured(Exception), true = handled) - and eight Task-returning twins for all but
IOnBeforeUpdate/IOnUpdated/IOnErrorCaptured. Implement only what you use; never both forms
of one hook. Sequences pinned by LifecycleTests.cs/SetupTests.cs:
| Moment | Hooks, in order |
|---|---|
Mount (Loaded) |
OnBeforeCreate, OnCreated, OnBeforeMount, OnMounted, OnActivated |
Unmount (one Unloaded) |
OnBeforeUnmount, OnDeactivated, OnUnmounted, back to back; a real detach (If) reports OnDeactivated first, via IsVisibleChanged |
Hidden/shown while mounted (Directives.Show, KeepAlive) |
OnDeactivated / OnActivated only |
| ViewModel property changes | OnBeforeUpdate at the first change, one OnUpdated per NextTick batch |
- The first three fire at
InitializedwhenDataContextis already there, else atLoaded- once perDataContextobject, so a ViewModel swapped in beforeLoadedstill gets the full sequence.OnActivatedon mount only if the element is actually visible (a background tab or aShow=falsesibling mounts without it).*Asynctwins fire at the same point and are never awaited. - Hooks follow the element, not the object. Every remount of the same instance (an
Iftoggling, aTabControlswitch, a Teleport move, an unloadedKeepAlive) replays the whole create/mount sequence. MakeOnMountedidempotent, or guard work meant to happen once. - WPF raises
Loadedtwice with noUnloadedbetween (aTabControlrealizes every tab's content, then raisesLoadedagain on selection) and can raiseUnloadedwithoutLoaded. Lifecycle,Directives.Event/Useand Teleport guard both edges; hand-written code must too, and must re-hook what it unhooks. A static-event subscription made on everyLoadedpins the whole tree and its ViewModel (LifecycleLeakTests.cs). - One
aq:Lifecycle.Enableper ViewModel, on the View root that owns it. Hooks go to the element's effectiveDataContext, inherited included, so a nested element withEnablesharing that ViewModel fires every hook a second time. - A
DataContextreplaced while mounted gets no mount hooks - only update tracking moves to it.IOnUpdatedfires for any property of the ViewModel, including ones your own hooks set. IOnErrorCapturedsubscribes to the element's ownDispatcher.UnhandledExceptionwhile mounted (on the main UI thread, the eventApplication.DispatcherUnhandledExceptionforwards to): app-wide on that thread, not scoped to the View's subtree, so every mounted ViewModel implementing it sees every exception there. NoApplicationis needed, and an element on a second UI thread sees that thread's exceptions. A faulted*Asynchook is rethrown on the element'sDispatcherand arrives here - never writeasync void OnMounted().- On a
Window, focus changes (Activated/Deactivated) also raiseIOnActivated/IOnDeactivated. - Loading +
Suspense: putLifecycle.Enableand the loading hook on the View that contains theaq:Suspense(asLifecycleDemoView.xamldoes), never inside itsChild. TheChildis out of the tree whileIsPendingis true, so a hook living there either never fires (spinner forever) or fires again on every resolve (load loop).
Directives (Barbatos.Wpf.Aquarius.Xaml)
| Attached property | Behaviour |
|---|---|
aq:Directives.Model="{Binding Path}" |
Re-applies the binding TwoWay + UpdateSourceTrigger=PropertyChanged to TextBox.Text, ToggleButton.IsChecked, Selector.SelectedItem or RangeBase.Value, from the first value the binding resolves - null included (an empty SelectedItem, an unset string?). Must be a Binding: a plain value, null too, throws InvalidOperationException, as do PasswordBox and every other type; ClearBinding is fine. Copies only Path, Converter, ConverterParameter, ConverterCulture and the source (Source/RelativeSource/ElementName) - StringFormat, Delay, ValidationRules, FallbackValue, TargetNullValue are dropped; write a full {Binding} when you need them. |
Directives.Show |
true -> Visible, false -> Collapsed. Stays mounted. |
Directives.Event + Command (+ CommandParameter) |
Any public instance event by name via reflection; an unknown name throws InvalidOperationException once both are set. Parameter defaults to the raised EventArgs; CanExecute is honoured. Suspended on Unloaded, re-hooked on Loaded. |
Directives.Modifiers |
Comma-separated: stop/prevent (both set Handled), once (permanent detach), self (OriginalSource is the element), left/right/middle (mouse button), enter/tab/esc/space/up/down/left/right/delete (key; delete also matches Backspace). Filters apply only to the matching EventArgs type. No capture - use the Preview{Event} event. |
Directives.Use (+ UseValue, Argument, Modifiers) |
Attaches one custom Directive per element (FrameworkElement only). |
Directives.Class |
Space-separated Style resource keys (TryFindResource); their Setters, BasedOn chain included, are written as local values, later tokens win, dropped tokens are cleared. Unknown key throws. |
Directives.Style |
IDictionary<string, object>; each key is a dependency-property name on the element's own type, each value must already be that property's type (Brushes.Red, not "Red"). Unknown name throws. |
stop/preventandonceact before theself/button/key filters:Modifiers="enter,once"detaches on the first key of any kind, and"enter,stop"marks every keyHandled. Combine them only when that is what you want; otherwise check the key inside the command.Class/Stylebeat the element'sStylesetters and triggers, and reverting clears the property - a value you also set on that property in XAML is lost. OnlySetters apply; triggers and event setters in a classStyleare ignored.Custom directive (
FocusDirective.cs, used inDirectivesDemoView.xaml):public sealed class FocusDirective : Directive { public override void Mounted(FrameworkElement element, DirectiveBinding binding) => element.Focus(); }<local:FocusDirective x:Key="AutoFocus" /> <!-- in Resources --> <TextBox aq:Directives.Use="{StaticResource AutoFocus}" />Mountedruns onLoaded,UpdatedwhenUseValue/Argument/Modifierschange while mounted (DirectiveBinding.Value/OldValue/Argument/Modifiers),UnmountedonUnloaded. One resource instance serves every element that uses it - keep per-element state on the element.
Conditional content: If, Expr, Comparisons, Suspense
<aq:If Condition="{aq:Expr 'Count > 0 && Total <= Budget'}">
<TextBlock Text="Really detached when false" />
<aq:If.Else>
<TextBlock Text="else - nest another aq:If here for else-if" />
</aq:If.Else>
</aq:If>
Ifgenuinely detaches its content (full unmount/remount sequence);Directives.Showonly hides. UseIffor content that changes rarely; useShowfor anything toggled often, including any periodic/realtime source - each timer tick pays a full unmount/remount, only flips within one call stack coalesce (IfControlTests.cs). Each branch holds one content object.If.Condition,Directives.ShowandTransition.Showdefault totrue, so an unresolved binding orExprshows the content ("fails open").Exprgrammar:> >= < <= == != && || ! + - * /, parentheses, right-associative? :, number and string literals, lowercasetrue/false/null, identifiers as property paths (Order.Total),#Name.Pathfor anElementNamebinding. Arithmetic isdouble.==/!=work on any objects (numeric coercion, enum against its name:Status == "Active"); relational operators need numbers or twoIComparables of one type.&& || ! ?:need real booleans - no truthiness; anullor number operand throwsInvalidOperationExceptionat evaluation. NoRelativeSource, method calls, indexers or string concatenation. The result can be any type (a ternary feedingTextBlock.Textworks, seeExprDemoView.xaml); one-way only.Exprin XAML: single quotes delimit the argument; escape<as<,&&as&&, string quotes as".Expr.Evaluate(expression, source)evaluates once from C# by reflection and cannot resolve#identifiers.Comparisons.Not(two-way),IsNull,IsEqualTovia{x:Static aq:Comparisons.IsNull}.IsEqualToisEquals(value, ConverterParameter), and a literal XAMLConverterParameteris a string - it never equals anintor enum; pass{x:Static}or useExpr.{x:Static aq:BuildConfiguration.IsDebug}reads the entry assembly'sDebuggableAttribute(the app's build configuration), notDebugger.IsAttached.aq:Suspense IsPendingshowsFallbackwhiletrue,Childwhilefalse(default); theChildis genuinely unmounted while pending - see the Loading + Suspense rule above.
Teleport, Transition, Provide/Inject, Slots, Component/KeepAlive
Details and verified sequences in patterns.md. The rules that bite without it:
- Teleport moves one
UIElementinto aPanelregistered withaq:TeleportHost.RegisterHost(any other host type throws; non-UIElementcontent is never moved). Every move - including the first settle into an already-registered host - is a full unmount/remount, soOnMountedruns twice.DataContextdoes not travel: the content inherits from its new ancestors. Transitionships no animations and skipsEnteron first render;TransitionGroup.Enteris enter-only.Provide/Injectis subtree-scoped; lifetime values use constructor injection.- Slots need a
SlotHost(in practice a subclass, like the sample'sCard) whoseControlTemplatereads{aq:SlotContent name}/{aq:SlotProvided name}- they bind toTemplatedParent, so they work only inside that template. Componentalone destroys the outgoing View; wrap it inaq:KeepAliveto cache.TabControlandFrame/Pageare not keep-alive (verified).KeepAliveowns its children'sVisibility(never addDirectives.Showto a cached View), throws if children are added directly, and is not supported aroundaqr:RouterView. Register Views it builds as transient.
Symptom -> cause -> fix
| Symptom | Cause | Fix |
|---|---|---|
View renders empty, DataContext is null with aq:Setup.Enable |
ViewModel in another namespace, another suffix, or the View name does not end in View; misses are silent |
aq:Setup.ViewModel="{x:Type ...}" or ViewModelResolver; turn on ThrowOnUnresolvedViewModels |
MissingMethodException while the View loads |
ViewModel has constructor parameters but is not registered, or UseAquarius() never ran |
Register it and call app.Services.UseAquarius() after Build() |
Parent's DataContext="{Binding X}" on a View is ignored, or wipes its ViewModel |
The View also uses aq:Setup |
Keep one: Setup or parent binding |
OnMounted never fires |
No aq:Lifecycle.Enable on the View root (the aq-view template sets it; a hand-written View may not), it sits on an element whose DataContext is another object, or the View is a Suspense child still pending |
Add Enable on the View root; move loading out of the Suspense child |
OnMounted/data load runs twice |
A remount (If, tab switch, Teleport settle) or two Lifecycle.Enable sharing one ViewModel |
Idempotent hook; one Enable per ViewModel |
A NextTick.Run callback never runs, only where there is no Application object (WPF hosted in WinForms) |
Called from a worker with no UI thread to fall back on | Call it on the UI thread |
| Computed value is stale | The getter reads something not passed as a dependency, or the Computed was disposed |
List every source (or this) in From |
| ViewModel never garbage-collected | Watch/Computed on a longer-lived source not disposed; static event subscribed on every Loaded |
Dispose in IOnUnmounted; guard both Loaded/Unloaded edges |
If/Show content always visible |
Unresolved binding or Expr identifier fails open |
ThrowOnUnresolvedExprIdentifiers = true in development |
Expr: '&&' requires a boolean operand |
A logical operator or ?: got a number or null |
Compare explicitly (Count > 0, X != null) |
StringFormat/validation rules lost on a Directives.Model box |
Model copies only path, converter and source |
Write the full {Binding ..., Mode=TwoWay} |
| Event command dead after one keypress / every key swallowed | once/stop act before the key filter |
Drop the modifier; check the key in the command |
Comparisons.IsEqualTo always false |
Literal ConverterParameter is a string |
{x:Static} parameter or Expr |
| Tab switch loses typed text / scroll position | TabControl and Frame destroy content |
aq:KeepAlive + aq:Component (patterns.md) |
Checklist: adding a View + ViewModel
- Scaffold
XyzView.xaml+XyzView.xaml.cs(onlyInitializeComponent()) +XyzViewModel.cs, same folder and namespace:dotnet new aq-view -n Xyz --namespace MyApp.Features.Xyz(installing and changing the template:barbatos-templates). - ViewModel:
public sealed partial class XyzViewModel : ObservableObject(partialfor the[ObservableProperty]/[RelayCommand]generators); state as[ObservableProperty]/Ref<T>, derived values asComputed<T>. - Decide: self-sufficient (
aq:Setup.Enable, as the template generates) or parent-bound (remove it and bindDataContextat the usage site). Never both. - Implementing any
IOn*hook? The View root needsaq:Lifecycle.Enable="True"- the template already sets it; add it to a hand-written View, and never a second one inside that View. - Constructor dependencies? Register the ViewModel (transient) and make sure
UseAquarius()runs afterBuild(). - Dispose
Watchhandles andComputed<T>built on longer-lived sources inIOnUnmounted. - Development builds:
ThrowOnUnresolvedViewModelsandThrowOnUnresolvedExprIdentifierson. - Tests (STA helpers, dispatcher pumping,
GcProbeleak checks):barbatos-wpf-testing.
Changing Barbatos.Wpf.Aquarius itself
Load barbatos-package-authoring. A new control, attached property or directive follows the
Loaded-guard and leak/XAML-test rules in .claude/rules/library-code.md; tests that call
UseAquarius save and restore the Setup.* statics; a behaviour pinned by a test (the KeepAlive
table by KeepAliveTests) changes together with the README text it backs.
Related skills
barbatos-aquarius-router- routed Views,RouterView, pairing a View with a ViewModel in the route table, whyKeepAlivecannot wrapRouterView.barbatos-valee-form(inputs, validation, error display),barbatos-apsu-store(state shared across screens),barbatos-wpf-host(WpfApp, DI, configuration),barbatos-templates(aq-view).barbatos-wpf-testing- testing Views, hooks and leaks.barbatos-package-authoring+barbatos-docs-sync- changing Aquarius itself.