Expression bindings​
Use Text=${x.bind expression} to assign the result of a strongly typed C#
expression to a property and track its supported observable inputs. Names come
from the component's C#/Akbura scope. Roslyn checks their types and the conversion
to the target property; this is not an Avalonia binding-path string.
x.bind is one-way. When an input notifies, Akbura reevaluates the expression
and updates the target without requiring a component render. It complements,
rather than replaces, Avalonia Binding.
Version compatibility
This page describes the current repository implementation. Use matching compiler, runtime and editor versions containing expression bindings and reactive utility conditions. An older published package may not contain these features.
Start with state​
using Avalonia.Controls;
state int count = 0;
<StackPanel>
<TextBlock Text=${x.bind $"Count: {count}"}/>
<TextBlock Text=${x.bind $"Double: {count * 2}"}/>
<Button Click={count++}>Increase</Button>
</StackPanel>
Both results follow count. State assignments can independently schedule the
component's normal render, too: this example does not prove that state changes
avoid rendering. The expression binding's notification-driven reevaluation and
the state's render scheduling are separate mechanisms.
Normal C# evaluation rules apply to supported arithmetic, interpolation, logical
operators, ?:, ??, conditional access, nested properties and indexers. Keep
mutations in handlers, not inside binding expressions.
A notifying model​
Save this companion C# file as ViewModel.cs. The examples below import Demo
and use these actual types. The setters raise INotifyPropertyChanged; ordinary
auto-properties would not announce changes.
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Demo;
public abstract class NotifyingObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
public sealed class Person(string name) : NotifyingObject
{
private string _name = name;
public string Name { get => _name; set => Set(ref _name, value); }
}
public sealed class ViewModel : NotifyingObject
{
private string _name = "Ada";
private Person? _profile = new("Ada's profile");
private int _selectedIndex;
private bool _usePrimaryName = true;
private string _primaryName = "Primary";
private string _fallbackName = "Fallback";
private bool _isSelected;
private bool _isDisabled;
private bool _hasError;
public string Name { get => _name; set => Set(ref _name, value); }
public Person? Profile { get => _profile; set => Set(ref _profile, value); }
public ObservableCollection<Person> People { get; } = [new("Ada"), new("Grace")];
public int SelectedIndex { get => _selectedIndex; set => Set(ref _selectedIndex, value); }
public bool UsePrimaryName { get => _usePrimaryName; set => Set(ref _usePrimaryName, value); }
public string PrimaryName { get => _primaryName; set => Set(ref _primaryName, value); }
public string FallbackName { get => _fallbackName; set => Set(ref _fallbackName, value); }
public bool IsSelected { get => _isSelected; set => Set(ref _isSelected, value); }
public bool IsDisabled { get => _isDisabled; set => Set(ref _isDisabled, value); }
public bool HasError { get => _hasError; set => Set(ref _hasError, value); }
}
public sealed class Holder : NotifyingObject
{
private ViewModel _vm = new();
public ViewModel Vm { get => _vm; set => Set(ref _vm, value); }
}
Ordinary C# property expressions​
using Avalonia.Controls;
using Demo;
state ViewModel Vm = new();
state int refresh = 0;
<StackPanel DataContext={Vm}>
<TextBlock Text={Vm.Name}/>
<TextBlock Text={$"Frame: {refresh}"}/>
<Button Click={refresh++}>Refresh</Button>
</StackPanel>
{Vm.Name} is evaluated by the normal component render/update path. A change to
Vm.Name does not independently subscribe this expression to INPC. The explicit
state change in Refresh requests a normal update and reads the latest name.
Ordinary expressions are not permanently static; they use a different refresh
contract.
Avalonia Binding​
using Avalonia.Controls;
using Avalonia.Data;
using Demo;
state ViewModel Vm = new();
<TextBlock DataContext={Vm} Text=${Binding Name}/>
Name is an Avalonia binding path. By default it starts at the target's effective
DataContext, here Vm. Avalonia's binding engine observes supported source
notifications without a component render. Binding has its own source selection,
binding modes, converters, StringFormat, fallback values and other features.
C# expression binding​
using Avalonia.Controls;
using Demo;
state ViewModel Vm = new();
<TextBlock DataContext={Vm} Text=${x.bind $"Hello, {Vm.Name}"}/>
Vm is a real component symbol. x.bind does not look for a Vm property in
DataContext; the DataContext assignment above is not needed by this binding.
Akbura tracks the supported observable reads made by the C# expression and writes
the calculated string one-way. The runtime uses ExpressionBinding<T>, not an
Avalonia BindingPath or a CLR markup-extension class named x.bind.
| Property | ${x.bind ...} |
${Binding ...} |
|---|---|---|
| Input language | Typed C# expression | Avalonia BindingPath |
| Name resolution | Component's C#/Akbura scope | DataContext or explicitly selected source |
| Type analysis | Roslyn C# semantic analysis | Avalonia's binding mechanism, depending on binding type |
| Reactive engine | Akbura expression binding | Avalonia binding engine |
| Calculations | Supported C# operators and formatting | Binding paths, converters, StringFormat; not arbitrary C# |
| Direction | One-way | Determined by binding mode |
| Observable source update needs a component render | No | No |
Use either system where its source selection and expression model fit. There is
no claim here that x.bind is universally faster or preferable.
Nested and nullable receivers​
using Avalonia.Controls;
using Demo;
state ViewModel model = new();
<StackPanel DataContext={model}>
<TextBlock Text=${x.bind model.Profile?.Name ?? "Unknown"}/>
<Button Click={model.Profile = new Person("Grace's profile")}>Replace profile</Button>
<Button Click={model.Profile = null}>Clear profile</Button>
</StackPanel>
The binding observes the current Profile and its Name. Replacing Profile
disconnects the old receiver and observes the replacement. null selects the
fallback; no object is created automatically. If the result stays equal while a
receiver changes, the active observations still change.
Indexed collections​
using Avalonia.Controls;
using Demo;
state ViewModel model = new();
<StackPanel DataContext={model}>
<TextBlock Text=${x.bind model.People[model.SelectedIndex].Name}/>
<Button Click={model.SelectedIndex = (model.SelectedIndex + 1) % model.People.Count}>Next person</Button>
<Button Click={model.People[model.SelectedIndex].Name = "Renamed"}>Rename selected</Button>
</StackPanel>
Here ObservableCollection<Person> announces item replacement and collection
changes, SelectedIndex announces selection changes, and each Person announces
Name changes. The active item follows the current index. Changing an unselected
person must not replace the selected display.
Keep the collection nonempty and the index valid before evaluation; this example
never removes items. Indexing an array or List<T> does not invent collection
notifications. An observable item's properties may notify even when its container
cannot announce replacement.
Dynamic conditional branches​
using Avalonia.Controls;
using Demo;
state ViewModel model = new();
<StackPanel DataContext={model}>
<TextBlock Text=${x.bind model.UsePrimaryName ? model.PrimaryName : model.FallbackName}/>
<Button Click={model.UsePrimaryName = !model.UsePrimaryName}>Switch branch</Button>
</StackPanel>
Only the selected branch is read. Editing the inactive name does not change the
result; switching branches uses that branch's latest value. Likewise, &&, ||,
?? and conditional access preserve C# short-circuit evaluation. Observations
of branches that are no longer read are released.
Avalonia property sources​
using Avalonia.Controls;
state Slider source = new() { Value = 25 };
<StackPanel DataContext={source}>
<TextBlock Text=${x.bind $"Value: {source.Value:0}"}/>
<Button Click={source.Value += 5}>Change value</Button>
</StackPanel>
Supported registered Avalonia properties, such as Slider.Value, provide a
notification channel without INPC. Merely deriving from AvaloniaObject does not
make every arbitrary CLR property observable.
Parameters and contextual conversions​
using Avalonia.Controls;
param int Count = 2;
state int offset = 10;
<StackPanel>
<Border Width=${x.bind Count + offset}/>
<Button Click={offset++}>Increase offset</Button>
</StackPanel>
The int result is implicitly converted to the double expected by Width.
Parameter changes and existing state notifications can be observed; their setters
may also request normal component updates. Incompatible conversions are diagnosed.
x.bind does not add a reverse write channel. Use the existing
directional parameter attributes or
state bind/out/in when that connection
is required. Do not combine bind: or out: with x.bind on the same assignment.
Injected services​
using Avalonia.Controls;
using Demo;
inject ViewModel Vm;
<TextBlock DataContext={Vm} Text=${x.bind Vm.Name}/>
Configure a provider for ViewModel as explained in
Dependency Injection. Vm is an injected component
symbol, not an implicitly created DataContext.Vm path. The explicit DataContext
assignment is useful if other controls use ${Binding Name}; x.bind Vm.Name
itself does not require it. The injected model still needs to notify about changes
to its properties.
Three ways to conditionally apply AKCSS utilities​
All three forms below decide whether a utility participates in the same AKCSS
cascade. They differ in how the Boolean is obtained and refreshed. Import
Akbura.Styles.akcss and load the normal AKCSS resources in your application;
see built-in utility resources.
Ordinary C# condition​
using Avalonia.Controls;
using Akbura.Styles.akcss;
using Demo;
state ViewModel Vm = new();
state int refresh = 0;
<StackPanel DataContext={Vm}>
<Border bg-slate-700 {Vm.IsSelected}:bg-blue-500 MinHeight="32"/>
<TextBlock Text={$"Frame: {refresh}"}/>
<Button Click={Vm.IsSelected = !Vm.IsSelected}>Toggle selection</Button>
<Button Click={refresh++}>Refresh</Button>
</StackPanel>
{Vm.IsSelected}:bg-blue-500 is an ordinary C# Boolean expression, evaluated
through the component's normal render/AKCSS refresh path. It does not install
expression-binding observations. Toggling this INPC property alone does not
request a component render; Refresh changes state and makes the ordinary
condition catch up. Conditions using state can naturally update during the
render requested by a state assignment.
Avalonia Binding condition​
using Avalonia.Controls;
using Avalonia.Data;
using Akbura.Styles.akcss;
using Demo;
state ViewModel Vm = new();
<Border DataContext={Vm} bg-slate-700
${Binding IsSelected}:bg-blue-500 MinHeight="32"/>
${Binding IsSelected} is an ordinary Avalonia markup extension. The path starts
at this Border's effective DataContext, which is Vm. Avalonia Binding observes
the notifying IsSelected property and its Boolean result activates or
deactivates the AKCSS candidate without a component render. False restores the
slate utility in this example.
Reactive C# expression condition​
using Avalonia.Controls;
using Akbura.Styles.akcss;
using Demo;
state ViewModel Vm = new();
<Border DataContext={Vm} bg-slate-700
${x.bind Vm.IsSelected && !Vm.IsDisabled}:bg-blue-500 MinHeight="32"/>
Vm resolves in component scope. Roslyn checks the C# condition, which must
convert to bool (bool? needs an explicit null fallback). Akbura observes
supported reads through ExpressionBinding<bool> and updates the utility
candidate directly. No DataContext path is required here.
This is useful for derived reactive conditions involving multiple properties.
When IsSelected is false in this example, the right-hand IsDisabled read is skipped
and need not remain observed. Hidden reads inside arbitrary methods are not
discovered automatically.
A nested DataContext path​
using Avalonia.Controls;
using Avalonia.Data;
using Akbura.Styles.akcss;
using Demo;
state Holder holder = new();
<Border DataContext={holder} bg-slate-700
${Binding Vm.IsSelected}:bg-blue-500 MinHeight="32"/>
This works because holder actually exposes a notifying Vm property whose
value exposes IsSelected. Replacing holder.Vm lets Avalonia follow the new
source. An inject ViewModel Vm; declaration by itself does not establish
this DataContext path. Set DataContext={Vm} and bind IsSelected, or select an
explicit Avalonia binding source instead.
Compare all three against one model​
using Avalonia.Controls;
using Avalonia.Data;
using Akbura.Styles.akcss;
using Demo;
state ViewModel Vm = new();
state int refresh = 0;
<StackPanel DataContext={Vm}>
<Border Name="binding" bg-slate-700 ${Binding IsSelected}:bg-blue-500 MinHeight="32"/>
<Border Name="expression" bg-slate-700 ${x.bind Vm.IsSelected}:bg-blue-500 MinHeight="32"/>
<Border Name="frame" bg-slate-700 {Vm.IsSelected}:bg-blue-500 MinHeight="32"/>
<TextBlock Text={$"Frame: {refresh}"}/>
<Button Click={Vm.IsSelected = !Vm.IsSelected}>Toggle selection</Button>
<Button Click={refresh++}>Refresh ordinary condition</Button>
</StackPanel>
Both the Binding and x.bind cards follow selection immediately. The ordinary card catches up when you request a refresh. Toggling an INPC model property in a button handler does not itself request an Akbura component render.
| Behavior | {condition}:utility |
${Binding Path}:utility |
${x.bind expression}:utility |
|---|---|---|---|
| Input | C# Boolean expression | Avalonia BindingPath | C# Boolean expression |
| Evaluation | Normal component/AKCSS refresh | Avalonia binding engine | Akbura expression observer |
| Names | Component scope | DataContext/selected source | Component scope |
| Observable update without component render | Not independently | Yes, with notifying source | Yes, with supported observable inputs |
| Arbitrary supported C# logic | Yes | Not directly | Yes |
| Conflict handling | AKCSS cascade | AKCSS cascade | AKCSS cascade |
| Use when | The condition belongs to normal rendering | Existing Avalonia source selection/binding features fit | You need derived reactive C# logic |
Shared cascade and restoration​
using Avalonia.Controls;
using Avalonia.Data;
using Akbura.Styles.akcss;
using Demo;
state ViewModel Vm = new();
<StackPanel DataContext={Vm}>
<Border Name="binding" bg-slate-700
${Binding IsSelected}:bg-blue-500 ${Binding HasError}:bg-red-500 p-3/>
<Border Name="expression" bg-slate-700
${x.bind Vm.IsSelected}:bg-blue-500 ${x.bind Vm.HasError}:bg-red-500 p-3/>
</StackPanel>
For both cards: neither condition gives slate; selected gives blue; error gives
red. If both are true, red wins because it appears later with otherwise equal
precedence. Clearing error restores blue; clearing both restores slate. Padding
is unrelated and remains 12 with the default spacing resource. Neither
observation engine requires a separate styling system or a different cascade.
See Utility Variants for ordering metadata and
property-level conflict resolution.
Observation boundaries and lifetime​
- Notifications come from existing Akbura state/parameters/services,
INotifyPropertyChanged, supported registered Avalonia properties andINotifyCollectionChangedon indexed collections. Plain CLR members and ordinary collections do not acquire notification channels. - The actual receivers and branches read by the expression are observed. Replacing a nested receiver or changing the index reconnects observations.
- Arbitrary method bodies and user-defined operations can hide dependencies. Partial-observation warnings mean automatic reactivity is not fully guaranteed; an explicit supported call is not a promise of method-body inspection.
- Render locals capture values from that render. A local initialized from
model.Profileis not automatically an observation of that initializer. x.bindrequires a writable, supported scalar property target. It is not an event handler, command declaration or collection assignment. Mutations, construction, deferred lambdas,await, dynamic operations and other unsupported forms are diagnosed inside binding expressions.- Detaching suspends owned observations; reattaching reads current inputs. Disposing the owning component releases subscriptions. Hot Reload uses the normal component lifetime and reconciliation mechanisms, not a polling loop.
For editing assistance, see Editor Completion. Try the live examples in FeatureGallery and choose Expression bindings, or run the repository's desktop gallery. The hosted gallery may lag behind this branch.