Browse Source
* Update ncrunch config.
* Initial groundwork for typed binding expressions.
- Moved a bunch of stuff from `UntypedBindingExpressionBase` to `BindingExpressionBase`
- Make various APIs accept `BindingExpressionBase` instead of `UntypedBindingExpressionBase`
- Added a typed `IPropertyInfo`
- Added a method on the `CompiledBindingPathBuilder` to build typed property accessors
- Initial basic implementation of `TypedBindingExpression`
* Track values in typed binding expression.
* Support binding mode in typed binding expression.
* Add typed binding expression benchmarks.
Add Setup/Values benchmarks comparing the typed binding expression
against the untyped CompiledBinding (as emitted by the XAML compiler
today) and the reflection-based Binding.
Writing the benchmarks surfaced that TypedBindingExpression never
detached: it had no Dispose override, so disposing a binding (or
rebinding the same property) leaked its PropertyChanged subscriptions
and left it registered in the value store. Wire up disposal mirroring
UntypedBindingExpressionBase: stop, detach handlers, remove from the
value store and frame. This required widening
IBindingExpressionSink.OnCompleted from UntypedBindingExpressionBase to
BindingExpressionBase. Also fix a nullable warning that broke the
Release build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
* Don't box value in IBindingExpressionSink.OnChanged.
Instead notify the sink of what's changed and let the sink read the value boxed or unboxed.
* Move `IValueEntry` to `BindingExpressionBase`
* Fall back to BindingExpression.
Make `TypedPropertyElement` derive from `PropertyElement` and fall back to creating a standard boxing `BindingExpression` if any of the requirements for a typed binding expression are not met.
* Don't use typed expressions for DataContext.
* Emit typed binding expression from XAML compiler.
When a compiled binding path is shape-eligible (single CLR property on a
reference-type source, no transforms, instance getter) the compiler now
emits a call to the typed CompiledBindingPathBuilder.Property<TSource,
TResult> overload, producing a TypedPropertyElement that the runtime
turns into a non-boxing TypedBindingExpression<TSource, TValue>. Other
shapes continue to use the existing untyped emission path, and runtime
fallback to BindingExpression still kicks in when modifiers (Source,
Converter, StringFormat, etc.) make the typed expression unsuitable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add failing tests for TwoWay typed binding source echo.
TypedBindingExpression writes the source value back to the source both on
attach and whenever the source raises a change: pushing the value to the
target re-enters OnTargetPropertyChanged, which (in TwoWay mode) calls
WriteValueToSource with the value that just arrived from the source.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Don't echo source value back to source in TwoWay typed binding.
When TypedBindingExpression pushed the source value to the target, the
resulting target PropertyChanged re-entered OnTargetPropertyChanged which,
in TwoWay mode, wrote the value straight back to the source - a redundant
round-trip on every attach and every source-originated change.
Guard the source->target push with a flag and skip WriteValueToSource while
it is set, so only genuine target changes are written back to the source.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address typed binding code-review follow-ups.
- Reset _isRunning when a typed binding is unsubscribed so the expression
can be restarted (and re-subscribe to its source) if the value store
reactivates the entry later, matching UntypedBindingExpressionBase.Stop().
- Remove a stray `using static PropertySetSnapshot` import.
- Extract the duplicated IValueEntry value-unwrapping logic (shared by
EffectiveValue<T> and DirectPropertyBase) into IValueEntry.TryGetValue.
- Drop the unreachable UpdateSourceTrigger NotSupportedException in
CreateTypedExpression; CanUseTypedBindingExpression already constrains it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix TypedBinding_Values benchmark build error.
The typed binding path was built with a single-argument `Property(propertyInfo)`
call, which has no matching overload, so the benchmark project did not compile.
Use the same typed three-argument overload as TypedBinding_Setup so the benchmark
exercises the TypedPropertyElement path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Skip typed binding expression for data-validation properties.
TypedBindingExpression does not support data validation, but the eligibility
check didn't account for it, so a directly-assignable single-property DataContext
binding to a validation-enabled target (e.g. TextBox.Text, NumericUpDown.Value)
would take the typed path and silently drop validation errors.
Fall back to the untyped BindingExpression when the target property enables data
validation. Data validation support in the typed expression can be added as a
follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Clarify that data validation is not supported.
* Address typed binding review comments.
- React to null/empty PropertyChanged.PropertyName ("all properties
changed") in the typed expression, matching the untyped path.
- Swallow source getter exceptions raised during PropertyChanged.
- Fall back to the untyped path for non-StyledElement targets, read-only
sources and wider target types in TwoWay/OneWayToSource modes.
- Fix a stray space in an exception message.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Share TypedBindingExpression.Attach validation across instantiations.
Factor the validation logic (most notably the exception string
formatting) out of the generic Attach method into a non-generic static
helper. It only uses typeof(TValue), not TValue, so sharing it avoids
duplicating the code per generic instantiation, a meaningful NativeAOT
size saving (~3.3 KB => ~2.5 KB per instantiation).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Update API suppressions.
These APIs are in a `[PrivateApi]`.
* Use cached boolean boxes in typed binding expression.
Merging main brought in the cached boxed booleans emitted for compiled
binding property getters (#21065), but bindings that now take the typed
binding expression path don't go through that getter: the value is boxed
in TypedBindingExpression.GetUntypedValue when the target property isn't
strongly typed (e.g. binding a bool to TextBlock.Tag). Box booleans via a
shared cache there too, restoring the no-allocation-per-read behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QkxawATDHa6rbPaDmPCLHZ
* Extract the whole TypedBindingExpression.Attach body into AttachCore.
3198196b4c only factored out the validation, leaving the field
assignments in the generic method. As MrJul pointed out, none of those
assignments depend on TSource or TValue either, so the entire body can
move into a method that doesn't reference the generic parameters and be
shared across instantiations rather than duplicated per instantiation.
Attach is now just a forwarder passing typeof(TValue) as a Type, and
AttachCore reads _sink/TargetProperty directly instead of having them
threaded through as parameters, which reads better than the previous
split.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QkxawATDHa6rbPaDmPCLHZ
* Move the method alongside the other privates.
* Add failing tests for typed binding expression breaking changes.
Each test binds the same property twice: once via a path which produces a
TypedBindingExpression and once via a path which produces an untyped
BindingExpression. The assertions describe the untyped behaviour, so the
typed cases fail where the typed expression diverges:
- A binding with no value (null or incompatible DataContext) publishes the
target property's default value at the binding's priority instead of not
contributing a value, overriding style setters and breaking inheritance.
- Writing a value of another type, or null, to an object-typed target
property throws from OnTargetPropertyChanged.
- No binding error is logged when the DataContext is of the wrong type.
- A throwing source getter leaves the stale value in place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B66teLaFQBr9GJe4a1aTZm
* Fix typed binding expression divergences from the untyped path.
- Don't notify when the expression has no value and had none before: doing
so pushed the target property's default value at the binding's priority,
overriding style setters and breaking property inheritance.
- Read the target property's new value defensively. The binding value type
only needs to be assignable to the target property type, so the property
can hold a value which isn't a TValue; casting it threw out of the caller's
SetValue.
- Log a binding error when the DataContext can't be converted to the source
type, and when the source getter throws. The getter also now clears the
value rather than leaving the stale one in place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B66teLaFQBr9GJe4a1aTZm
* Pass correct priority to child bindings.
The priority here doesn't actually have any effect as the binding isn't in a value store, but there's no reason to not use the correct one.
* Use the typed overload.
* Set initial priority to default priority.
Makes `TypedBindingExpressionBase` and `UntypedBindingExpressionBase` have the same behavior here.
* Remove unused vars.
* We now have a typed value here.
* Publish typed binding values on the UI thread.
The source's PropertyChanged event can be raised on any thread, so marshal
the notification to the UI thread as UntypedBindingExpressionBase does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1ZwmCJkThPSeViLnRxoYs
* Share the property info cache lookup.
Emit and EmitTyped had identical cache lookups, differing only in which
dictionary they searched. Also delay constructing the generic types in
EmitTyped until a cache miss, as they aren't needed on a hit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1ZwmCJkThPSeViLnRxoYs
* Share the property accessor delegate emission.
EmitFunc was duplicated as a local function in both Emit and EmitTyped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1ZwmCJkThPSeViLnRxoYs
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pull/22083/head
committed by
GitHub
28 changed files with 2450 additions and 206 deletions
@ -0,0 +1,40 @@ |
|||
using System; |
|||
|
|||
namespace Avalonia.Data.Core; |
|||
|
|||
public class ClrPropertyInfo<TSource, TValue> : IPropertyInfo<TSource, TValue> |
|||
where TSource : class |
|||
{ |
|||
private readonly Func<TSource, TValue>? _getter; |
|||
private readonly Action<TSource, TValue>? _setter; |
|||
|
|||
public ClrPropertyInfo(string name, Func<TSource, TValue>? getter, Action<TSource, TValue>? setter) |
|||
{ |
|||
_getter = getter; |
|||
_setter = setter; |
|||
PropertyType = typeof(TValue); |
|||
Name = name; |
|||
} |
|||
|
|||
public bool CanSet => _setter != null; |
|||
public bool CanGet => _getter != null; |
|||
public string Name { get; } |
|||
public Type PropertyType { get; } |
|||
|
|||
public TValue Get(TSource target) |
|||
{ |
|||
if (_getter == null) |
|||
throw new NotSupportedException("Property " + Name + " doesn't have a getter"); |
|||
return _getter(target); |
|||
} |
|||
|
|||
public void Set(TSource target, TValue value) |
|||
{ |
|||
if (_setter == null) |
|||
throw new NotSupportedException("Property " + Name + " doesn't have a setter"); |
|||
_setter(target, value); |
|||
} |
|||
|
|||
object? IPropertyInfo.Get(object target) => Get((TSource)target); |
|||
void IPropertyInfo.Set(object target, object? value) => Set((TSource)target, (TValue)value!); |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
using System; |
|||
using Avalonia.Metadata; |
|||
|
|||
namespace Avalonia.Data.Core; |
|||
|
|||
[NotClientImplementable] |
|||
public interface IPropertyInfo<TSource, TValue> : IPropertyInfo |
|||
where TSource : class |
|||
{ |
|||
TValue Get(TSource target); |
|||
void Set(TSource target, TValue value); |
|||
} |
|||
@ -0,0 +1,417 @@ |
|||
using System; |
|||
using System.ComponentModel; |
|||
using System.Diagnostics.CodeAnalysis; |
|||
using System.Runtime.CompilerServices; |
|||
using Avalonia.Logging; |
|||
using Avalonia.PropertyStore; |
|||
using Avalonia.Threading; |
|||
using Avalonia.Utilities; |
|||
|
|||
namespace Avalonia.Data.Core; |
|||
|
|||
/// <summary>
|
|||
/// A binding expression which does not box.
|
|||
/// </summary>
|
|||
/// <typeparam name="TSource">The type of the source object.</typeparam>
|
|||
/// <typeparam name="TValue">The type of the value.</typeparam>
|
|||
/// <remarks>
|
|||
/// A typed binding expression has the following limitations:
|
|||
///
|
|||
/// - It must be a DataContext binding
|
|||
/// - It can only be used with a single source property, i.e. `{Binding Foo}` can be represented as
|
|||
/// a typed binding but `{Binding Foo.Bar}` cannot.
|
|||
/// - It cannot have a Converter, Delay, FallbackValue. StringFormat, TargetNullValue or
|
|||
/// UpdateSourceTrigger != PropertyChanged.
|
|||
/// - The value must be directly assignable to the target property, i.e. no type conversion is
|
|||
/// performed.
|
|||
/// - The target property must not enable data validation.
|
|||
/// </remarks>
|
|||
internal class TypedBindingExpression<TSource, TValue> : BindingExpressionBase, |
|||
IDescription, |
|||
IValueEntry<TValue>, |
|||
IWeakEventSubscriber<PropertyChangedEventArgs> |
|||
where TSource : class |
|||
{ |
|||
private readonly IPropertyInfo<TSource, TValue> _propertyInfo; |
|||
private readonly BindingMode _mode; |
|||
private bool _isRunning; |
|||
private bool _produceValue; |
|||
private bool _writingValueToTarget; |
|||
private IBindingExpressionSink? _sink; |
|||
private ImmediateValueFrame? _frame; |
|||
private WeakReference<TSource?>? _source; |
|||
private WeakReference<StyledElement>? _target; |
|||
private Optional<TValue> _sourceValue; |
|||
private Optional<TValue> _targetValue; |
|||
private bool _shouldUpdateOneTimeBindingTarget; |
|||
|
|||
public TypedBindingExpression( |
|||
IPropertyInfo<TSource, TValue> propertyInfo, |
|||
BindingMode mode, |
|||
BindingPriority defaultPriority) |
|||
: base(defaultPriority) |
|||
{ |
|||
_propertyInfo = propertyInfo; |
|||
_mode = mode; |
|||
_shouldUpdateOneTimeBindingTarget = mode is BindingMode.OneTime; |
|||
} |
|||
|
|||
public string Description => _propertyInfo.Name; |
|||
|
|||
// The whole method body is factored out into AttachCore, which takes the value type as a
|
|||
// Type parameter instead of reading it from TValue. Because AttachCore doesn't reference the
|
|||
// generic parameters, its code is shared across all generic instantiations instead of being
|
|||
// duplicated for each one, which is a meaningful NativeAOT size saving.
|
|||
internal override void Attach( |
|||
IBindingExpressionSink sink, |
|||
ImmediateValueFrame? frame, |
|||
AvaloniaObject target, |
|||
AvaloniaProperty targetProperty, |
|||
BindingPriority priority) |
|||
=> AttachCore(sink, frame, target, targetProperty, priority, typeof(TValue)); |
|||
|
|||
public override void Dispose() |
|||
{ |
|||
if (_sink is null) |
|||
return; |
|||
|
|||
// Null the sink before stopping so that the unsubscribe doesn't push a final value to a
|
|||
// value store that's about to clear this entry anyway.
|
|||
var sink = _sink; |
|||
var frame = _frame; |
|||
_sink = null; |
|||
_frame = null; |
|||
|
|||
StopCore(); |
|||
|
|||
sink.OnCompleted(this); |
|||
frame?.OnEntryDisposed(this); |
|||
} |
|||
|
|||
internal override void Start(bool produceValue) |
|||
{ |
|||
if (_isRunning) |
|||
return; |
|||
|
|||
_isRunning = true; |
|||
|
|||
try |
|||
{ |
|||
_produceValue = produceValue; |
|||
StartCore(); |
|||
} |
|||
finally |
|||
{ |
|||
_produceValue = true; |
|||
} |
|||
} |
|||
|
|||
private protected override bool GetDataValidationState(out BindingValueType state, out Exception? error) |
|||
{ |
|||
// Data validation is not supported by the typed expression: bindings whose target
|
|||
// property enables it are excluded in CompiledBinding.CanUseTypedBindingExpression and
|
|||
// use the untyped BindingExpression instead. Could be implemented here as a follow-up.
|
|||
state = BindingValueType.Value; |
|||
error = null; |
|||
return false; |
|||
} |
|||
|
|||
private protected override object? GetUntypedValue() |
|||
{ |
|||
Start(produceValue: false); |
|||
if (!_sourceValue.HasValue) |
|||
throw new AvaloniaInternalException("The binding expression has no value."); |
|||
return Box(_sourceValue.Value); |
|||
} |
|||
|
|||
TValue IValueEntry<TValue>.GetValue() |
|||
{ |
|||
Start(produceValue: false); |
|||
if (!_sourceValue.HasValue) |
|||
throw new AvaloniaInternalException("The binding expression has no value."); |
|||
return _sourceValue.Value; |
|||
} |
|||
|
|||
private protected override bool HasValue() |
|||
{ |
|||
Start(produceValue: false); |
|||
return _sourceValue.HasValue; |
|||
} |
|||
|
|||
private protected override void Unsubscribe() |
|||
{ |
|||
// Reset _isRunning so the expression can be restarted (and re-subscribe to its source) if
|
|||
// the value store reactivates this entry later.
|
|||
StopCore(); |
|||
_isRunning = false; |
|||
} |
|||
|
|||
void IWeakEventSubscriber<PropertyChangedEventArgs>.OnEvent(object? sender, WeakEvent ev, PropertyChangedEventArgs e) |
|||
{ |
|||
OnSourcePropertyChanged(sender, e); |
|||
} |
|||
|
|||
private void AttachCore( |
|||
IBindingExpressionSink sink, |
|||
ImmediateValueFrame? frame, |
|||
AvaloniaObject target, |
|||
AvaloniaProperty targetProperty, |
|||
BindingPriority priority, |
|||
Type valueType) |
|||
{ |
|||
if (_sink is not null) |
|||
throw new InvalidOperationException("TypedBindingExpression was already attached."); |
|||
if (target is not StyledElement element) |
|||
throw new InvalidOperationException("TypedBindingExpression may only target StyledElements"); |
|||
if (TargetProperty is not null && TargetProperty != targetProperty) |
|||
throw new InvalidOperationException("TypedBindingExpression was already attached to a different property."); |
|||
|
|||
if (!valueType.IsAssignableTo(targetProperty.PropertyType)) |
|||
{ |
|||
throw new InvalidOperationException( |
|||
$"TypedBindingExpression of type '{valueType}' cannot be bound " + |
|||
$"to a property of type '{targetProperty.PropertyType}'."); |
|||
} |
|||
|
|||
_sink = sink; |
|||
_frame = frame; |
|||
_target = new(element); |
|||
TargetProperty = targetProperty; |
|||
Priority = priority; |
|||
} |
|||
|
|||
private void StartCore() |
|||
{ |
|||
if (TryGetTarget(out var target) && TargetProperty is not null) |
|||
{ |
|||
target.PropertyChanged += OnTargetPropertyChanged; |
|||
UpdateSource(target.DataContext); |
|||
} |
|||
} |
|||
|
|||
private void StopCore() |
|||
{ |
|||
if (TryGetTarget(out var target)) |
|||
{ |
|||
target.PropertyChanged -= OnTargetPropertyChanged; |
|||
UpdateSource(null); |
|||
} |
|||
} |
|||
|
|||
private void UpdateSource(object? dataContext) |
|||
{ |
|||
var source = dataContext as TSource; |
|||
|
|||
if (dataContext is not null && source is null) |
|||
{ |
|||
Log($"Could not convert DataContext of type '{dataContext.GetType()}' " + |
|||
$"to '{typeof(TSource)}'."); |
|||
} |
|||
|
|||
if (TryGetSource(out var oldSource)) |
|||
{ |
|||
if (oldSource is INotifyPropertyChanged oldInpc) |
|||
WeakEvents.ThreadSafePropertyChanged.Unsubscribe(oldInpc, this); |
|||
} |
|||
|
|||
_source = new(source); |
|||
_shouldUpdateOneTimeBindingTarget = true; |
|||
|
|||
if (source is INotifyPropertyChanged inpc) |
|||
WeakEvents.ThreadSafePropertyChanged.Subscribe(inpc, this); |
|||
|
|||
if (_mode is BindingMode.OneWayToSource) |
|||
{ |
|||
if (TryGetTargetValue(out var value)) |
|||
WriteValueToSource(value!); |
|||
} |
|||
else |
|||
{ |
|||
WriteSourceValueToTarget(source); |
|||
} |
|||
} |
|||
|
|||
private void WriteValueToSource(TValue value) |
|||
{ |
|||
if (TargetProperty is not null && TryGetTarget(out var target)) |
|||
{ |
|||
if (TryGetSource(out var source)) |
|||
_propertyInfo.Set(source, value); |
|||
} |
|||
} |
|||
|
|||
private void WriteSourceValueToTarget() |
|||
{ |
|||
if (TryGetSource(out var source)) |
|||
WriteSourceValueToTarget(source); |
|||
} |
|||
|
|||
private void WriteSourceValueToTarget(TSource? source) |
|||
{ |
|||
if (_mode is BindingMode.OneTime && !_shouldUpdateOneTimeBindingTarget) |
|||
return; |
|||
|
|||
var oldValue = _sourceValue; |
|||
|
|||
if (source is null) |
|||
{ |
|||
_sourceValue = default; |
|||
} |
|||
else |
|||
{ |
|||
try |
|||
{ |
|||
_sourceValue = new(_propertyInfo.Get(source)); |
|||
} |
|||
catch (Exception e) |
|||
{ |
|||
// Getter exceptions must not escape into the source's PropertyChanged event and
|
|||
// crash the UI thread, so log the error and clear the value, as the untyped
|
|||
// binding path does.
|
|||
Log($"Error getting '{_propertyInfo.Name}': {e.Message}"); |
|||
_sourceValue = default; |
|||
} |
|||
} |
|||
|
|||
if (_produceValue && _mode is not BindingMode.OneWayToSource) |
|||
{ |
|||
// An expression which has no value, and had no value before, must not notify: doing so
|
|||
// would push the target property's default value, overriding values from styles or
|
|||
// property inheritance. Otherwise always notify, even if the value is unchanged, as
|
|||
// the target may hold an uncommitted value written by SetCurrentValue.
|
|||
if (oldValue.HasValue || _sourceValue.HasValue) |
|||
PublishValue(); |
|||
if (_mode is BindingMode.OneTime) |
|||
_shouldUpdateOneTimeBindingTarget = false; |
|||
} |
|||
} |
|||
|
|||
private void PublishValue() |
|||
{ |
|||
// The source's PropertyChanged event may be raised on any thread, but the target can only
|
|||
// be updated on the UI thread.
|
|||
if (Dispatcher.UIThread.CheckAccess()) |
|||
PublishCore(this); |
|||
else |
|||
Dispatcher.UIThread.Post(PublishCore, this); |
|||
|
|||
static void PublishCore(object? state) |
|||
{ |
|||
var expression = (TypedBindingExpression<TSource, TValue>)state!; |
|||
|
|||
// Flag that we're pushing the source value to the target so that the resulting target
|
|||
// PropertyChanged isn't echoed straight back to the source in TwoWay mode.
|
|||
expression._writingValueToTarget = true; |
|||
try |
|||
{ |
|||
expression._sink?.OnChanged(expression, true, false); |
|||
} |
|||
finally |
|||
{ |
|||
expression._writingValueToTarget = false; |
|||
} |
|||
} |
|||
} |
|||
|
|||
private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e) |
|||
{ |
|||
// A null or empty PropertyName means "all properties changed" per the
|
|||
// INotifyPropertyChanged contract, so we must re-read the source value in that case too.
|
|||
if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName == _propertyInfo.Name) |
|||
WriteSourceValueToTarget(); |
|||
} |
|||
|
|||
private void OnTargetPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) |
|||
{ |
|||
if (e.Property == StyledElement.DataContextProperty) |
|||
{ |
|||
UpdateSource(((StyledElement?)sender)?.DataContext); |
|||
} |
|||
else if (e.Property == TargetProperty) |
|||
{ |
|||
_targetValue = ReadTargetValue(e); |
|||
|
|||
// Don't write back to the source if this change is the binding pushing the source
|
|||
// value to the target; that would be a redundant round-trip.
|
|||
if (_targetValue.HasValue && |
|||
!_writingValueToTarget && |
|||
_mode is BindingMode.TwoWay or BindingMode.OneWayToSource) |
|||
{ |
|||
WriteValueToSource(_targetValue.Value); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private static Optional<TValue> ReadTargetValue(AvaloniaPropertyChangedEventArgs e) |
|||
{ |
|||
// The binding value type only needs to be assignable to the target property type, so the
|
|||
// target property can hold values which cannot be represented as a TValue; for example a
|
|||
// string binding on an object-typed property whose value is set to an int. Such values
|
|||
// are reported as absent rather than throwing.
|
|||
if (e is AvaloniaPropertyChangedEventArgs<TValue> typedArgs) |
|||
return typedArgs.NewValue.Value; |
|||
if (e.NewValue is TValue value) |
|||
return value; |
|||
if (e.NewValue is null && default(TValue) is null) |
|||
return new Optional<TValue>(default!); |
|||
return default; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Converts a value to <see cref="object"/>, using cached boxes for booleans so that reading
|
|||
/// a boolean-valued binding into an object-typed target property does not allocate on every
|
|||
/// read (#21065).
|
|||
/// </summary>
|
|||
private static object? Box(TValue value) |
|||
{ |
|||
if (typeof(TValue) == typeof(bool)) |
|||
return BooleanBoxes.Box(Unsafe.As<TValue, bool>(ref value)); |
|||
return value; |
|||
} |
|||
|
|||
private void Log(string error, LogEventLevel level = LogEventLevel.Warning) |
|||
{ |
|||
if (!Logger.TryGet(level, LogArea.Binding, out var log) || !TryGetTarget(out var target)) |
|||
return; |
|||
|
|||
log.Log( |
|||
target, |
|||
"An error occurred binding {Property} to {Expression}: {Message}", |
|||
(object?)TargetProperty ?? "(unknown)", |
|||
Description, |
|||
error); |
|||
} |
|||
|
|||
private bool TryGetSource([NotNullWhen(true)] out TSource? source) |
|||
{ |
|||
if (_source?.TryGetTarget(out source) == true) |
|||
return true; |
|||
source = null; |
|||
return false; |
|||
} |
|||
|
|||
private bool TryGetTarget([NotNullWhen(true)] out StyledElement? target) |
|||
{ |
|||
if (_target?.TryGetTarget(out target) == true) |
|||
return true; |
|||
target = null; |
|||
return false; |
|||
} |
|||
|
|||
private bool TryGetTargetValue(out TValue? value) |
|||
{ |
|||
if (TargetProperty is not null && TryGetTarget(out var target)) |
|||
{ |
|||
value = TargetProperty switch |
|||
{ |
|||
StyledProperty<TValue> s => target.GetValue(s), |
|||
DirectPropertyBase<TValue> d => target.GetValue(d), |
|||
_ => (TValue)target.GetValue(TargetProperty)! |
|||
}; |
|||
return true; |
|||
} |
|||
|
|||
value = default; |
|||
return false; |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
using System.Runtime.CompilerServices; |
|||
|
|||
namespace Avalonia.Utilities |
|||
{ |
|||
/// <summary>
|
|||
/// Holds cached boxes for the two boolean values, so that converting a boolean to
|
|||
/// <see cref="object"/> does not allocate (#21065).
|
|||
/// </summary>
|
|||
internal static class BooleanBoxes |
|||
{ |
|||
public static readonly object True = true; |
|||
public static readonly object False = false; |
|||
|
|||
[MethodImpl(MethodImplOptions.AggressiveInlining)] |
|||
public static object Box(bool value) => value ? True : False; |
|||
} |
|||
} |
|||
@ -0,0 +1,261 @@ |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Data; |
|||
using Avalonia.Data.Core; |
|||
using Avalonia.Diagnostics; |
|||
using Avalonia.Logging; |
|||
using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings; |
|||
using Avalonia.Styling; |
|||
using Avalonia.UnitTests; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Base.UnitTests.Data.Core; |
|||
|
|||
/// <summary>
|
|||
/// Tests which compare the behaviour of <see cref="TypedBindingExpression{TSource, TValue}"/> with
|
|||
/// the untyped <see cref="BindingExpression"/> for bindings which are eligible for the typed path.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// Each test is run twice: once with a binding which produces a typed expression and once with an
|
|||
/// equivalent binding which produces an untyped expression. The assertions describe the behaviour
|
|||
/// of the untyped expression, i.e. the behaviour of the binding before typed binding expressions
|
|||
/// were introduced, so a failure in the <c>typed: true</c> case is a user-visible breaking change.
|
|||
/// </remarks>
|
|||
public partial class TypedBindingExpressionTests |
|||
{ |
|||
[Theory] |
|||
[InlineData(true)] |
|||
[InlineData(false)] |
|||
public void Null_DataContext_Should_Not_Override_Style_Setter(bool typed) |
|||
{ |
|||
// A binding which has no value must not contribute a value to the target property,
|
|||
// otherwise the property's default value is applied at LocalValue priority, hiding the
|
|||
// value from the style setter.
|
|||
var target = new TextBlock(); |
|||
var root = new TestRoot |
|||
{ |
|||
Styles = |
|||
{ |
|||
new Style(x => x.OfType<TextBlock>()) |
|||
{ |
|||
Setters = { new Setter(TextBlock.TextProperty, "styled") }, |
|||
}, |
|||
}, |
|||
Child = target |
|||
}; |
|||
|
|||
AssertExpressionType(typed, target.Bind(TextBlock.TextProperty, CreateStringBinding(typed))); |
|||
|
|||
Assert.Equal("styled", target.Text); |
|||
Assert.Equal(BindingPriority.Style, target.GetDiagnostic(TextBlock.TextProperty).Priority); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(true)] |
|||
[InlineData(false)] |
|||
public void Null_DataContext_Should_Not_Break_Property_Inheritance(bool typed) |
|||
{ |
|||
// As above, but for an inherited property: applying the property's default value at
|
|||
// LocalValue priority stops the value being inherited from the parent.
|
|||
var target = new TextBlock(); |
|||
var root = new TestRoot |
|||
{ |
|||
Child = target, |
|||
[TextBlock.FontSizeProperty] = 30.0, |
|||
}; |
|||
|
|||
AssertExpressionType(typed, target.Bind(TextBlock.FontSizeProperty, CreateDoubleBinding(typed))); |
|||
|
|||
Assert.Equal(30.0, target.FontSize); |
|||
Assert.Equal(BindingPriority.Inherited, target.GetDiagnostic(TextBlock.FontSizeProperty).Priority); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(true)] |
|||
[InlineData(false)] |
|||
public void Style_Priority_Binding_With_Null_DataContext_Should_Not_Break_Property_Inheritance(bool typed) |
|||
{ |
|||
// The same problem occurs for bindings at a priority other than LocalValue, such as a
|
|||
// binding in a style setter.
|
|||
var target = new TextBlock(); |
|||
var root = new TestRoot |
|||
{ |
|||
Child = target, |
|||
[TextBlock.FontSizeProperty] = 30.0, |
|||
}; |
|||
|
|||
var binding = CreateDoubleBinding(typed); |
|||
binding.Priority = BindingPriority.Style; |
|||
|
|||
AssertExpressionType(typed, target.Bind(TextBlock.FontSizeProperty, binding)); |
|||
|
|||
Assert.Equal(30.0, target.FontSize); |
|||
Assert.Equal(BindingPriority.Inherited, target.GetDiagnostic(TextBlock.FontSizeProperty).Priority); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(true)] |
|||
[InlineData(false)] |
|||
public void Setting_Object_Target_Property_To_A_Different_Type_Should_Not_Throw(bool typed) |
|||
{ |
|||
// The binding value type only needs to be assignable to the target property type, so a
|
|||
// string can be bound to an object-typed property. Writing a value of any other type to
|
|||
// that property must not throw when the binding reads the new target value.
|
|||
var data = new ViewModel { StringValue = "foo" }; |
|||
var target = new TextBlock { DataContext = data }; |
|||
var root = new TestRoot |
|||
{ |
|||
Child = target |
|||
}; |
|||
|
|||
AssertExpressionType(typed, target.Bind(TextBlock.TagProperty, CreateStringBinding(typed))); |
|||
|
|||
Assert.Equal("foo", target.Tag); |
|||
|
|||
var ex = Record.Exception(() => target.Tag = 5); |
|||
|
|||
Assert.Null(ex); |
|||
Assert.Equal(5, target.Tag); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(true)] |
|||
[InlineData(false)] |
|||
public void Setting_Object_Target_Property_To_Null_Should_Not_Throw(bool typed) |
|||
{ |
|||
// As above, but with a value-typed binding: writing null to the object-typed target
|
|||
// property must not throw when the binding reads the new target value.
|
|||
var data = new ViewModel { DoubleValue = 1.0 }; |
|||
var target = new TextBlock { DataContext = data }; |
|||
var root = new TestRoot |
|||
{ |
|||
Child = target |
|||
}; |
|||
|
|||
AssertExpressionType(typed, target.Bind(TextBlock.TagProperty, CreateDoubleBinding(typed))); |
|||
|
|||
Assert.Equal(1.0, target.Tag); |
|||
|
|||
var ex = Record.Exception(() => target.SetValue(TextBlock.TagProperty, null)); |
|||
|
|||
Assert.Null(ex); |
|||
Assert.Null(target.Tag); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(true)] |
|||
[InlineData(false)] |
|||
public void Incompatible_DataContext_Should_Log_A_Binding_Error(bool typed) |
|||
{ |
|||
// When the DataContext isn't of the expected type the untyped expression logs a binding
|
|||
// error; the typed expression silently produces no value.
|
|||
var errors = new List<string>(); |
|||
|
|||
using var sink = TestLogSink.Start((level, area, source, template, values) => |
|||
{ |
|||
if (level >= LogEventLevel.Warning && area == LogArea.Binding) |
|||
errors.Add(template); |
|||
}); |
|||
|
|||
var target = new TextBlock { DataContext = new ViewModel { StringValue = "foo" } }; |
|||
var root = new TestRoot |
|||
{ |
|||
Child = target |
|||
}; |
|||
|
|||
AssertExpressionType(typed, target.Bind(TextBlock.TextProperty, CreateStringBinding(typed))); |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
|
|||
target.DataContext = new object(); |
|||
|
|||
Assert.NotEmpty(errors); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(true)] |
|||
[InlineData(false)] |
|||
public void Source_Getter_Exception_Should_Clear_The_Target_Value(bool typed) |
|||
{ |
|||
// When the source getter throws, the untyped expression reports a binding error and
|
|||
// reverts the target to its default value; the typed expression silently leaves the stale
|
|||
// value in place.
|
|||
var errors = new List<string>(); |
|||
|
|||
using var sink = TestLogSink.Start((level, area, source, template, values) => |
|||
{ |
|||
if (level >= LogEventLevel.Warning && area == LogArea.Binding) |
|||
errors.Add(template); |
|||
}); |
|||
|
|||
var data = new ViewModel { StringValue = "foo" }; |
|||
var target = new TextBlock { DataContext = data }; |
|||
var root = new TestRoot |
|||
{ |
|||
Child = target |
|||
}; |
|||
|
|||
AssertExpressionType(typed, target.Bind(TextBlock.TextProperty, CreateStringBinding(typed))); |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
|
|||
data.ThrowOnGet = true; |
|||
data.RaisePropertyChanged(nameof(ViewModel.StringValue)); |
|||
|
|||
Assert.Null(target.Text); |
|||
Assert.NotEmpty(errors); |
|||
} |
|||
|
|||
private static void AssertExpressionType(bool typed, BindingExpressionBase expression) |
|||
{ |
|||
if (typed) |
|||
Assert.IsNotType<BindingExpression>(expression); |
|||
else |
|||
Assert.IsType<BindingExpression>(expression); |
|||
} |
|||
|
|||
private static CompiledBinding CreateStringBinding(bool typed, BindingMode mode = BindingMode.OneWay) |
|||
{ |
|||
if (typed) |
|||
return CreateBinding(mode); |
|||
|
|||
var path = new CompiledBindingPathBuilder().Property( |
|||
new ClrPropertyInfo( |
|||
nameof(ViewModel.StringValue), |
|||
o => ((ViewModel)o).StringValue, |
|||
(o, v) => ((ViewModel)o).StringValue = (string?)v, |
|||
typeof(string)), |
|||
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor).Build(); |
|||
|
|||
return new CompiledBinding(path) { Mode = mode }; |
|||
} |
|||
|
|||
private static CompiledBinding CreateDoubleBinding(bool typed, BindingMode mode = BindingMode.OneWay) |
|||
{ |
|||
var builder = new CompiledBindingPathBuilder(); |
|||
|
|||
if (typed) |
|||
{ |
|||
builder.Property<ViewModel, double>( |
|||
new ClrPropertyInfo<ViewModel, double>( |
|||
nameof(ViewModel.DoubleValue), |
|||
o => o.DoubleValue, |
|||
(o, v) => o.DoubleValue = v), |
|||
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor, |
|||
false); |
|||
} |
|||
else |
|||
{ |
|||
builder.Property( |
|||
new ClrPropertyInfo( |
|||
nameof(ViewModel.DoubleValue), |
|||
o => ((ViewModel)o).DoubleValue, |
|||
(o, v) => ((ViewModel)o).DoubleValue = (double)v!, |
|||
typeof(double)), |
|||
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor); |
|||
} |
|||
|
|||
return new CompiledBinding(builder.Build()) { Mode = mode }; |
|||
} |
|||
} |
|||
@ -0,0 +1,515 @@ |
|||
using System; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Data; |
|||
using Avalonia.Data.Converters; |
|||
using Avalonia.Data.Core; |
|||
using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings; |
|||
using Avalonia.UnitTests; |
|||
using Xunit; |
|||
|
|||
namespace Avalonia.Base.UnitTests.Data.Core; |
|||
|
|||
public partial class TypedBindingExpressionTests : ScopedTestBase |
|||
{ |
|||
[Fact] |
|||
public void Should_Produce_TypedBindingExpression() |
|||
{ |
|||
var binding = CreateBinding(); |
|||
var target = new TextBlock(); |
|||
|
|||
BindAndAssert(target, binding); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Bind_String_Value() |
|||
{ |
|||
var data = new ViewModel { StringValue = "Hello" }; |
|||
var target = CreateTarget(data); |
|||
|
|||
Assert.Equal("Hello", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneWay_Binding_Should_Track_String_Value() |
|||
{ |
|||
var data = new ViewModel { StringValue = "Hello" }; |
|||
var target = CreateTarget(data, mode: BindingMode.OneWay); |
|||
|
|||
Assert.Equal("Hello", target.Text); |
|||
|
|||
data.StringValue = "World"; |
|||
|
|||
Assert.Equal("World", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneWay_Binding_Should_Track_DataContext() |
|||
{ |
|||
var data1 = new ViewModel { StringValue = "Hello" }; |
|||
var data2 = new ViewModel { StringValue = "World" }; |
|||
var target = CreateTarget(data1, mode: BindingMode.OneWay); |
|||
|
|||
Assert.Equal("Hello", target.Text); |
|||
|
|||
target.DataContext = data2; |
|||
|
|||
Assert.Equal("World", target.Text); |
|||
} |
|||
|
|||
// The name of this test makes no sense in English but keeping it as it matches the name of
|
|||
// the test in BindingExpressionTests.
|
|||
[Fact] |
|||
public void OneWay_Binding_Updates_Target_When_Changes_And_Source_Raises_PropertyChanged() |
|||
{ |
|||
var data = new ViewModel { StringValue = "foo" }; |
|||
var target = CreateTarget(data, mode: BindingMode.OneWay); |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
|
|||
target.SetCurrentValue(TextBlock.TextProperty, "bar"); |
|||
|
|||
Assert.Equal("bar", target.Text); |
|||
|
|||
data.RaisePropertyChanged(nameof(data.StringValue)); |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void TwoWay_Binding_Writes_Value_To_Source() |
|||
{ |
|||
var source = new ViewModel { StringValue = "Hello" }; |
|||
var target = CreateTarget(source, mode: BindingMode.TwoWay); |
|||
|
|||
Assert.Equal("Hello", target.Text); |
|||
|
|||
source.StringValue = "World"; |
|||
|
|||
Assert.Equal("World", target.Text); |
|||
|
|||
target.Text = "Goodbye"; |
|||
|
|||
Assert.Equal("Goodbye", source.StringValue); |
|||
} |
|||
|
|||
[Fact] |
|||
public void TwoWay_Binding_Does_Not_Write_Back_To_Source_On_Attach() |
|||
{ |
|||
var source = new ViewModel { StringValue = "Hello" }; |
|||
var setsAfterConstruction = source.StringValueSetCount; |
|||
|
|||
var target = CreateTarget(source, mode: BindingMode.TwoWay); |
|||
|
|||
Assert.Equal("Hello", target.Text); |
|||
|
|||
// Pushing the source value to the target must not echo it straight back to the source.
|
|||
Assert.Equal(setsAfterConstruction, source.StringValueSetCount); |
|||
} |
|||
|
|||
[Fact] |
|||
public void TwoWay_Binding_Does_Not_Echo_Source_Change_Back_To_Source() |
|||
{ |
|||
var source = new ViewModel { StringValue = "Hello" }; |
|||
var target = CreateTarget(source, mode: BindingMode.TwoWay); |
|||
|
|||
var before = source.StringValueSetCount; |
|||
|
|||
source.StringValue = "World"; // One setter call: this assignment.
|
|||
|
|||
Assert.Equal("World", target.Text); |
|||
Assert.Equal(before + 1, source.StringValueSetCount); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneTime_Binding_Sets_Target_Only_Once_If_Data_Context_Does_Not_Change() |
|||
{ |
|||
var data = new ViewModel { StringValue = "foo" }; |
|||
var target = CreateTarget(data, mode: BindingMode.OneTime); |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
|
|||
data.StringValue = "bar"; |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneTime_Binding_Sets_Target_When_Data_Context_Changes() |
|||
{ |
|||
var data = new ViewModel { StringValue = "foo" }; |
|||
var target = CreateTarget(data, mode: BindingMode.OneTime); |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
|
|||
target.DataContext = new ViewModel { StringValue = "bar" }; |
|||
|
|||
Assert.Equal("bar", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneTime_Binding_Waits_For_DataContext() |
|||
{ |
|||
var target = CreateTarget(null, mode: BindingMode.OneTime); |
|||
|
|||
Assert.Null(target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneTime_Binding_Waits_For_DataContext_With_Matching_Property_Name() |
|||
{ |
|||
var data1 = new { Baz = "baz" }; |
|||
var data2 = new ViewModel { StringValue = "foo" }; |
|||
var target = CreateTarget(null, mode: BindingMode.OneTime); |
|||
|
|||
target.DataContext = data1; |
|||
Assert.Null(target.Text); |
|||
|
|||
target.DataContext = data2; |
|||
Assert.Equal("foo", target.Text); |
|||
|
|||
data2.StringValue = "bar"; |
|||
Assert.Equal("foo", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneTime_Binding_Waits_For_DataContext_With_Matching_Property_Type() |
|||
{ |
|||
var data1 = new { StringValue = 1.5 }; |
|||
var data2 = new ViewModel { StringValue = "foo" }; |
|||
var target = CreateTarget(null, mode: BindingMode.OneTime); |
|||
|
|||
target.DataContext = data1; |
|||
Assert.Null(target.Text); |
|||
|
|||
target.DataContext = data2; |
|||
Assert.Equal("foo", target.Text); |
|||
|
|||
data2.StringValue = "bar"; |
|||
Assert.Equal("foo", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneWayToSource_Binding_Updates_Source_When_Target_Changes() |
|||
{ |
|||
var data = new ViewModel(); |
|||
var target = CreateTarget(data, mode: BindingMode.OneWayToSource); |
|||
|
|||
Assert.Null(data.StringValue); |
|||
|
|||
target.Text = "foo"; |
|||
Assert.Equal("foo", data.StringValue); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneWayToSource_Binding_Does_Not_Update_Target_When_Source_Changes() |
|||
{ |
|||
var data = new ViewModel(); |
|||
var target = CreateTarget(data, mode: BindingMode.OneWayToSource); |
|||
|
|||
target.Text = "foo"; |
|||
Assert.Equal("foo", data.StringValue); |
|||
|
|||
data.StringValue = "bar"; |
|||
Assert.Equal("foo", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OneWayToSource_Binding_Updates_Source_When_DataContext_Changes() |
|||
{ |
|||
var data1 = new ViewModel(); |
|||
var data2 = new ViewModel(); |
|||
var target = CreateTarget(data1, mode: BindingMode.OneWayToSource); |
|||
|
|||
target.Text = "foo"; |
|||
Assert.Equal("foo", data1.StringValue); |
|||
|
|||
target.DataContext = data2; |
|||
Assert.Equal("foo", data2.StringValue); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Can_Bind_Readonly_Property_OneWayToSource() |
|||
{ |
|||
var data = new ViewModel(); |
|||
var target = new SelectableTextBlock |
|||
{ |
|||
DataContext = data, |
|||
Text = "foobar", |
|||
SelectionStart = 0, |
|||
SelectionEnd = 3 |
|||
}; |
|||
|
|||
Assert.Equal("foo", target.SelectedText); |
|||
|
|||
var binding = CreateBinding(mode: BindingMode.OneWayToSource); |
|||
target.Bind(SelectableTextBlock.SelectedTextProperty, binding); |
|||
|
|||
Assert.Equal("foo", data.StringValue); |
|||
|
|||
target.SelectionEnd = 4; |
|||
|
|||
// TODO: Uncomment when https://github.com/AvaloniaUI/Avalonia/issues/21461 fixed.
|
|||
//Assert.Equal("foob", data.StringValue);
|
|||
} |
|||
|
|||
[Fact] |
|||
public void Can_Bind_String_To_Object() |
|||
{ |
|||
var log = string.Empty; |
|||
using var logger = TestLogSink.Start((_, _, _, m, _) => log += m); |
|||
var source = new ViewModel { StringValue = "Hello" }; |
|||
var binding = CreateBinding(); |
|||
var target = new TextBlock { DataContext = source }; |
|||
var expression = target.Bind(TextBlock.TagProperty, binding); |
|||
|
|||
Assert.IsType<TypedBindingExpression<ViewModel, string?>>(expression); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Disposing_Binding_Unsubscribes_From_Source() |
|||
{ |
|||
var data = new ViewModel { StringValue = "foo" }; |
|||
var target = new TextBlock { DataContext = data }; |
|||
var binding = CreateBinding(); |
|||
var expression = target.Bind(TextBlock.TextProperty, binding); |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
Assert.Equal(1, data.PropertyChangedSubscriptionCount); |
|||
|
|||
expression.Dispose(); |
|||
|
|||
Assert.Equal(0, data.PropertyChangedSubscriptionCount); |
|||
|
|||
// Source changes no longer propagate to the (now unbound) target.
|
|||
data.StringValue = "bar"; |
|||
Assert.NotEqual("bar", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Rebinding_Same_Property_Unsubscribes_Previous_Binding() |
|||
{ |
|||
var data = new ViewModel { StringValue = "foo" }; |
|||
var target = new TextBlock { DataContext = data }; |
|||
|
|||
target.Bind(TextBlock.TextProperty, CreateBinding()); |
|||
target.Bind(TextBlock.TextProperty, CreateBinding()); |
|||
|
|||
// The first binding should have been disposed when the second was applied, leaving a
|
|||
// single subscription rather than two.
|
|||
Assert.Equal(1, data.PropertyChangedSubscriptionCount); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Produce_TypedBindingExpression_When_Binding_String_To_Double() |
|||
{ |
|||
var log = string.Empty; |
|||
using var logger = TestLogSink.Start((_, _, _, m, _) => log += m); |
|||
var source = new ViewModel { StringValue = "Hello" }; |
|||
var binding = CreateBinding(); |
|||
var target = new TextBlock { DataContext = source }; |
|||
var expression = target.Bind(TextBlock.OpacityProperty, binding); |
|||
|
|||
Assert.IsType<BindingExpression>(expression); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Produce_TypedBindingExpression_When_Converter_Is_Present() |
|||
{ |
|||
var binding = CreateBinding(); |
|||
binding.Converter = new FuncValueConverter<string?, string?>(s => s); |
|||
|
|||
var target = new TextBlock(); |
|||
var expression = target.Bind(TextBlock.TextProperty, binding); |
|||
|
|||
Assert.IsType<BindingExpression>(expression); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Produce_TypedBindingExpression_When_Binding_DataContext() |
|||
{ |
|||
var log = string.Empty; |
|||
using var logger = TestLogSink.Start((_, _, _, m, _) => log += m); |
|||
var source = new ViewModel { StringValue = "Hello" }; |
|||
var binding = CreateBinding(); |
|||
var target = new TextBlock(); |
|||
var expression = target.Bind(TextBlock.DataContextProperty, binding); |
|||
|
|||
Assert.IsType<BindingExpression>(expression); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(null)] |
|||
[InlineData("")] |
|||
public void OneWay_Binding_Updates_Target_When_Source_Raises_PropertyChanged_For_All_Properties( |
|||
string? allPropertiesName) |
|||
{ |
|||
// A null or empty PropertyName means "all properties changed" per the INotifyPropertyChanged
|
|||
// contract, so the binding must re-read its source value.
|
|||
var data = new ViewModel { StringValue = "foo" }; |
|||
var target = CreateTarget(data, mode: BindingMode.OneWay); |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
|
|||
data.SetStringValueWithoutNotification("bar"); |
|||
data.RaisePropertyChanged(allPropertiesName); |
|||
|
|||
Assert.Equal("bar", target.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Getter_Exception_Does_Not_Propagate_When_Source_Raises_PropertyChanged() |
|||
{ |
|||
// The untyped binding path swallows getter exceptions to avoid crashing the UI thread; the
|
|||
// typed path must do the same rather than letting them escape into the event handler.
|
|||
var data = new ViewModel { StringValue = "foo" }; |
|||
var target = CreateTarget(data, mode: BindingMode.OneWay); |
|||
|
|||
Assert.Equal("foo", target.Text); |
|||
|
|||
data.ThrowOnGet = true; |
|||
|
|||
var ex = Record.Exception(() => data.RaisePropertyChanged(nameof(ViewModel.StringValue))); |
|||
|
|||
Assert.Null(ex); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Produce_TypedBindingExpression_For_ReadOnly_Source_In_TwoWay() |
|||
{ |
|||
// A read-only source property cannot be written back to in TwoWay/OneWayToSource modes, so
|
|||
// the untyped path (which fails silently) must be used instead.
|
|||
var propertyInfo = new ClrPropertyInfo<ViewModel, string?>( |
|||
nameof(ViewModel.StringValue), |
|||
v => v.StringValue, |
|||
setter: null); |
|||
var binding = CreateBinding(propertyInfo, mode: BindingMode.TwoWay); |
|||
var target = new TextBlock { DataContext = new ViewModel() }; |
|||
|
|||
var expression = target.Bind(TextBlock.TextProperty, binding); |
|||
|
|||
Assert.IsType<BindingExpression>(expression); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Produce_TypedBindingExpression_When_Target_Type_Is_Wider_In_TwoWay() |
|||
{ |
|||
// The source is a string but the target property is object. The forward assignment is valid
|
|||
// but writing an arbitrary object back to the string source could throw, so the untyped path
|
|||
// must be used.
|
|||
var source = new ViewModel { StringValue = "Hello" }; |
|||
var binding = CreateBinding(mode: BindingMode.TwoWay); |
|||
var target = new TextBlock { DataContext = source }; |
|||
|
|||
var expression = target.Bind(TextBlock.TagProperty, binding); |
|||
|
|||
Assert.IsType<BindingExpression>(expression); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Produce_TypedBindingExpression_For_Non_StyledElement_Target() |
|||
{ |
|||
// TypedBindingExpression only supports StyledElement targets; other AvaloniaObjects (e.g.
|
|||
// Application, which is an IDataContextProvider but not a StyledElement) must use the untyped
|
|||
// path rather than throwing at runtime.
|
|||
var binding = CreateBinding(); |
|||
var target = new NonStyledTarget { DataContext = new ViewModel { StringValue = "Hello" } }; |
|||
|
|||
var expression = target.Bind(NonStyledTarget.ValueProperty, binding); |
|||
|
|||
Assert.IsType<BindingExpression>(expression); |
|||
} |
|||
|
|||
private static TypedBindingExpression<ViewModel, string?> BindAndAssert(StyledElement target, BindingBase binding) |
|||
{ |
|||
var expression = target.Bind(TextBlock.TextProperty, binding); |
|||
return Assert.IsType<TypedBindingExpression<ViewModel, string?>>(expression); |
|||
} |
|||
|
|||
private static CompiledBinding CreateBinding(BindingMode mode = BindingMode.OneWay) |
|||
{ |
|||
var propertyInfo = new ClrPropertyInfo<ViewModel, string?>( |
|||
nameof(ViewModel.StringValue), |
|||
v => v.StringValue, |
|||
(o, v) => o.StringValue = v); |
|||
return CreateBinding(propertyInfo, mode); |
|||
} |
|||
|
|||
private static CompiledBinding CreateBinding( |
|||
IPropertyInfo<ViewModel, string?> propertyInfo, |
|||
BindingMode mode = BindingMode.OneWay) |
|||
{ |
|||
var path = new CompiledBindingPathBuilder().Property<ViewModel, string?>( |
|||
propertyInfo, |
|||
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor, |
|||
false).Build(); |
|||
return new CompiledBinding(path) { Mode = mode, }; |
|||
} |
|||
|
|||
private static TextBlock CreateTarget(ViewModel? data, BindingMode mode = BindingMode.OneWay) |
|||
{ |
|||
var result = new TextBlock { DataContext = data }; |
|||
var binding = CreateBinding(mode); |
|||
BindAndAssert(result, binding); |
|||
return result; |
|||
} |
|||
|
|||
private class ViewModel : NotifyingBase |
|||
{ |
|||
private string? _stringValue; |
|||
private double _doubleValue; |
|||
|
|||
// Counts every setter invocation so tests can assert the binding doesn't write spurious
|
|||
// values back to the source. PropertyChanged is only raised on a real change.
|
|||
public int StringValueSetCount { get; private set; } |
|||
|
|||
// When set, the getter throws so tests can verify getter exceptions don't escape the
|
|||
// binding's PropertyChanged handler.
|
|||
public bool ThrowOnGet { get; set; } |
|||
|
|||
public string? StringValue |
|||
{ |
|||
get |
|||
{ |
|||
if (ThrowOnGet) |
|||
throw new InvalidOperationException("Getter failed."); |
|||
return _stringValue; |
|||
} |
|||
set |
|||
{ |
|||
++StringValueSetCount; |
|||
SetField(ref _stringValue, value); |
|||
} |
|||
} |
|||
|
|||
public double DoubleValue |
|||
{ |
|||
get => _doubleValue; |
|||
set => SetField(ref _doubleValue, value); |
|||
} |
|||
|
|||
// Mutates the backing field without raising PropertyChanged, so tests can then raise an
|
|||
// "all properties changed" notification (null/empty name) and observe the binding react.
|
|||
public void SetStringValueWithoutNotification(string? value) => _stringValue = value; |
|||
} |
|||
|
|||
private class NonStyledTarget : AvaloniaObject, IDataContextProvider |
|||
{ |
|||
public static readonly StyledProperty<object?> DataContextProperty = |
|||
StyledElement.DataContextProperty.AddOwner<NonStyledTarget>(); |
|||
|
|||
public static readonly StyledProperty<string?> ValueProperty = |
|||
AvaloniaProperty.Register<NonStyledTarget, string?>(nameof(Value)); |
|||
|
|||
public object? DataContext |
|||
{ |
|||
get => GetValue(DataContextProperty); |
|||
set => SetValue(DataContextProperty, value); |
|||
} |
|||
|
|||
public string? Value |
|||
{ |
|||
get => GetValue(ValueProperty); |
|||
set => SetValue(ValueProperty, value); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,143 @@ |
|||
using System.ComponentModel; |
|||
using System.Runtime.CompilerServices; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Data; |
|||
using Avalonia.Data.Core; |
|||
using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings; |
|||
using BenchmarkDotNet.Attributes; |
|||
|
|||
#nullable enable |
|||
|
|||
namespace Avalonia.Benchmarks.Data; |
|||
|
|||
// Compares the setup (create instance + attach + dispose) cost of the three kinds of binding to
|
|||
// a DataContext property:
|
|||
//
|
|||
// - Typed: the new TypedBindingExpression (built here by hand as the XAML compiler does
|
|||
// not yet produce it). Strongly typed, does not box.
|
|||
// - CompiledBinding: the untyped CompiledBinding that the XAML compiler produces today for
|
|||
// `{Binding Value}` (uses an InpcPropertyAccessor + boxed values).
|
|||
// - Reflection: the classic reflection-based `Binding`.
|
|||
//
|
|||
// An `int` property is used as boxing avoidance is the main selling point of the typed expression.
|
|||
// The binding instances are created once and reused across the loop (as they are in real XAML)
|
|||
// so that only the per-bind cost is measured, not path/expression construction.
|
|||
[MemoryDiagnoser] |
|||
public class TypedBinding_Setup |
|||
{ |
|||
private readonly TestData _data = new(); |
|||
private readonly TestControl _target = new(); |
|||
private readonly CompiledBinding _typedOneWay = CreateTypedBinding(BindingMode.OneWay); |
|||
private readonly CompiledBinding _typedTwoWay = CreateTypedBinding(BindingMode.TwoWay); |
|||
private readonly CompiledBinding _compiledOneWay = CreateCompiledBinding(BindingMode.OneWay); |
|||
private readonly CompiledBinding _compiledTwoWay = CreateCompiledBinding(BindingMode.TwoWay); |
|||
private readonly Binding _reflectionOneWay = new(nameof(TestData.IntValue)) { Mode = BindingMode.OneWay }; |
|||
private readonly Binding _reflectionTwoWay = new(nameof(TestData.IntValue)) { Mode = BindingMode.TwoWay }; |
|||
|
|||
public TypedBinding_Setup() |
|||
{ |
|||
_target.DataContext = _data; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Setup_Typed_OneWay() |
|||
{ |
|||
for (var i = 0; i < 100; ++i) |
|||
using (_target.Bind(TestControl.IntValueProperty, _typedOneWay)) { } |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Setup_CompiledBinding_OneWay() |
|||
{ |
|||
for (var i = 0; i < 100; ++i) |
|||
using (_target.Bind(TestControl.IntValueProperty, _compiledOneWay)) { } |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Setup_Reflection_OneWay() |
|||
{ |
|||
for (var i = 0; i < 100; ++i) |
|||
using (_target.Bind(TestControl.IntValueProperty, _reflectionOneWay)) { } |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Setup_Typed_TwoWay() |
|||
{ |
|||
for (var i = 0; i < 100; ++i) |
|||
using (_target.Bind(TestControl.IntValueProperty, _typedTwoWay)) { } |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Setup_CompiledBinding_TwoWay() |
|||
{ |
|||
for (var i = 0; i < 100; ++i) |
|||
using (_target.Bind(TestControl.IntValueProperty, _compiledTwoWay)) { } |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Setup_Reflection_TwoWay() |
|||
{ |
|||
for (var i = 0; i < 100; ++i) |
|||
using (_target.Bind(TestControl.IntValueProperty, _reflectionTwoWay)) { } |
|||
} |
|||
|
|||
private static CompiledBinding CreateTypedBinding(BindingMode mode) |
|||
{ |
|||
var propertyInfo = new ClrPropertyInfo<TestData, int>( |
|||
nameof(TestData.IntValue), |
|||
v => v.IntValue, |
|||
(o, v) => o.IntValue = v); |
|||
var path = new CompiledBindingPathBuilder().Property( |
|||
propertyInfo, |
|||
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor, |
|||
false).Build(); |
|||
return new CompiledBinding(path) { Mode = mode }; |
|||
} |
|||
|
|||
// Builds the untyped CompiledBinding the same way the XAML compiler does today: a
|
|||
// CompiledBindingPath with an (object-typed) ClrPropertyInfo accessed via an
|
|||
// InpcPropertyAccessor. Differs from the typed binding only in the path element used.
|
|||
private static CompiledBinding CreateCompiledBinding(BindingMode mode) |
|||
{ |
|||
var propertyInfo = new ClrPropertyInfo( |
|||
nameof(TestData.IntValue), |
|||
o => ((TestData)o).IntValue, |
|||
(o, v) => ((TestData)o).IntValue = (int)v!, |
|||
typeof(int)); |
|||
var path = new CompiledBindingPathBuilder() |
|||
.Property(propertyInfo, PropertyInfoAccessorFactory.CreateInpcPropertyAccessor) |
|||
.Build(); |
|||
return new CompiledBinding(path) { Mode = mode }; |
|||
} |
|||
|
|||
private class TestControl : Control |
|||
{ |
|||
public static readonly StyledProperty<int> IntValueProperty = |
|||
AvaloniaProperty.Register<TestControl, int>(nameof(IntValue)); |
|||
|
|||
public int IntValue |
|||
{ |
|||
get => GetValue(IntValueProperty); |
|||
set => SetValue(IntValueProperty, value); |
|||
} |
|||
} |
|||
|
|||
private class TestData : INotifyPropertyChanged |
|||
{ |
|||
private int _intValue; |
|||
|
|||
public event PropertyChangedEventHandler? PropertyChanged; |
|||
|
|||
public int IntValue |
|||
{ |
|||
get => _intValue; |
|||
set |
|||
{ |
|||
if (_intValue == value) |
|||
return; |
|||
_intValue = value; |
|||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IntValue))); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,162 @@ |
|||
using System.ComponentModel; |
|||
using Avalonia.Controls; |
|||
using Avalonia.Data; |
|||
using Avalonia.Data.Core; |
|||
using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings; |
|||
using BenchmarkDotNet.Attributes; |
|||
|
|||
#nullable enable |
|||
|
|||
namespace Avalonia.Benchmarks.Data; |
|||
|
|||
// Compares the steady-state cost of pushing values through the three kinds of binding to a
|
|||
// DataContext property: see TypedBinding_Setup for a description of each kind.
|
|||
//
|
|||
// The binding is attached once and then 100 value changes are pushed through it. An `int` property
|
|||
// is used so that the OneWay benchmarks highlight the per-value boxing that the typed expression
|
|||
// avoids; the allocation column (from MemoryDiagnoser) is the interesting one here.
|
|||
[MemoryDiagnoser] |
|||
public class TypedBinding_Values |
|||
{ |
|||
private TestData _data = null!; |
|||
private TestControl _target = null!; |
|||
|
|||
[GlobalSetup] |
|||
public void Setup() |
|||
{ |
|||
_data = new TestData(); |
|||
_target = new TestControl { DataContext = _data }; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Produce_Typed_OneWay() |
|||
{ |
|||
_data.IntValue = -1; |
|||
using var d = _target.Bind(TestControl.IntValueProperty, CreateTypedBinding(BindingMode.OneWay)); |
|||
|
|||
for (var i = 0; i < 100; ++i) |
|||
_data.IntValue = i; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Produce_CompiledBinding_OneWay() |
|||
{ |
|||
_data.IntValue = -1; |
|||
using var d = _target.Bind(TestControl.IntValueProperty, CreateCompiledBinding(BindingMode.OneWay)); |
|||
|
|||
for (var i = 0; i < 100; ++i) |
|||
_data.IntValue = i; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Produce_Reflection_OneWay() |
|||
{ |
|||
_data.IntValue = -1; |
|||
using var d = _target.Bind(TestControl.IntValueProperty, CreateReflectionBinding(BindingMode.OneWay)); |
|||
|
|||
for (var i = 0; i < 100; ++i) |
|||
_data.IntValue = i; |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Produce_Typed_TwoWay() |
|||
{ |
|||
_data.IntValue = -1; |
|||
using var d = _target.Bind(TestControl.IntValueProperty, CreateTypedBinding(BindingMode.TwoWay)); |
|||
|
|||
for (var i = 0; i < 100; ++i) |
|||
{ |
|||
_data.IntValue = i * 2; |
|||
_target.IntValue = (i * 2) + 1; |
|||
} |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Produce_CompiledBinding_TwoWay() |
|||
{ |
|||
_data.IntValue = -1; |
|||
using var d = _target.Bind(TestControl.IntValueProperty, CreateCompiledBinding(BindingMode.TwoWay)); |
|||
|
|||
for (var i = 0; i < 100; ++i) |
|||
{ |
|||
_data.IntValue = i * 2; |
|||
_target.IntValue = (i * 2) + 1; |
|||
} |
|||
} |
|||
|
|||
[Benchmark] |
|||
public void Produce_Reflection_TwoWay() |
|||
{ |
|||
_data.IntValue = -1; |
|||
using var d = _target.Bind(TestControl.IntValueProperty, CreateReflectionBinding(BindingMode.TwoWay)); |
|||
|
|||
for (var i = 0; i < 100; ++i) |
|||
{ |
|||
_data.IntValue = i * 2; |
|||
_target.IntValue = (i * 2) + 1; |
|||
} |
|||
} |
|||
|
|||
private static CompiledBinding CreateTypedBinding(BindingMode mode) |
|||
{ |
|||
var propertyInfo = new ClrPropertyInfo<TestData, int>( |
|||
nameof(TestData.IntValue), |
|||
v => v.IntValue, |
|||
(o, v) => o.IntValue = v); |
|||
var path = new CompiledBindingPathBuilder().Property( |
|||
propertyInfo, |
|||
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor, |
|||
false).Build(); |
|||
return new CompiledBinding(path) { Mode = mode }; |
|||
} |
|||
|
|||
// Builds the untyped CompiledBinding the same way the XAML compiler does today: a
|
|||
// CompiledBindingPath with an (object-typed) ClrPropertyInfo accessed via an
|
|||
// InpcPropertyAccessor. Differs from the typed binding only in the path element used.
|
|||
private static CompiledBinding CreateCompiledBinding(BindingMode mode) |
|||
{ |
|||
var propertyInfo = new ClrPropertyInfo( |
|||
nameof(TestData.IntValue), |
|||
o => ((TestData)o).IntValue, |
|||
(o, v) => ((TestData)o).IntValue = (int)v!, |
|||
typeof(int)); |
|||
var path = new CompiledBindingPathBuilder() |
|||
.Property(propertyInfo, PropertyInfoAccessorFactory.CreateInpcPropertyAccessor) |
|||
.Build(); |
|||
return new CompiledBinding(path) { Mode = mode }; |
|||
} |
|||
|
|||
private static Binding CreateReflectionBinding(BindingMode mode) |
|||
=> new(nameof(TestData.IntValue)) { Mode = mode }; |
|||
|
|||
private class TestControl : Control |
|||
{ |
|||
public static readonly StyledProperty<int> IntValueProperty = |
|||
AvaloniaProperty.Register<TestControl, int>(nameof(IntValue)); |
|||
|
|||
public int IntValue |
|||
{ |
|||
get => GetValue(IntValueProperty); |
|||
set => SetValue(IntValueProperty, value); |
|||
} |
|||
} |
|||
|
|||
private class TestData : INotifyPropertyChanged |
|||
{ |
|||
private int _intValue; |
|||
|
|||
public event PropertyChangedEventHandler? PropertyChanged; |
|||
|
|||
public int IntValue |
|||
{ |
|||
get => _intValue; |
|||
set |
|||
{ |
|||
if (_intValue == value) |
|||
return; |
|||
_intValue = value; |
|||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IntValue))); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue