Browse Source

Merge pull request #10423 from AvaloniaUI/feature/styled-property-data-validation

Add data validation support to styled properties
pull/10463/head
Max Katz 3 years ago
committed by GitHub
parent
commit
bc74d04198
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      src/Avalonia.Base/AvaloniaObject.cs
  2. 4
      src/Avalonia.Base/AvaloniaObjectExtensions.cs
  3. 13
      src/Avalonia.Base/AvaloniaProperty.cs
  4. 18
      src/Avalonia.Base/AvaloniaPropertyMetadata.cs
  5. 27
      src/Avalonia.Base/DirectPropertyMetadata`1.cs
  6. 76
      src/Avalonia.Base/PropertyStore/BindingEntryBase.cs
  7. 11
      src/Avalonia.Base/PropertyStore/DirectBindingObserver.cs
  8. 5
      src/Avalonia.Base/PropertyStore/DirectUntypedBindingObserver.cs
  9. 51
      src/Avalonia.Base/PropertyStore/EffectiveValue.cs
  10. 26
      src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs
  11. 11
      src/Avalonia.Base/PropertyStore/IValueEntry.cs
  12. 8
      src/Avalonia.Base/PropertyStore/ImmediateValueEntry.cs
  13. 6
      src/Avalonia.Base/PropertyStore/ImmediateValueFrame.cs
  14. 114
      src/Avalonia.Base/PropertyStore/LocalValueBindingObserver.cs
  15. 133
      src/Avalonia.Base/PropertyStore/LocalValueBindingObserverBase.cs
  16. 94
      src/Avalonia.Base/PropertyStore/LocalValueUntypedBindingObserver.cs
  17. 3
      src/Avalonia.Base/PropertyStore/SourceUntypedBindingEntry.cs
  18. 6
      src/Avalonia.Base/PropertyStore/TypedBindingEntry.cs
  19. 3
      src/Avalonia.Base/PropertyStore/UntypedBindingEntry.cs
  20. 37
      src/Avalonia.Base/PropertyStore/ValueStore.cs
  21. 1
      src/Avalonia.Base/StyledElement.cs
  22. 6
      src/Avalonia.Base/StyledPropertyMetadata`1.cs
  23. 2
      src/Avalonia.Base/Styling/PropertySetterBindingInstance.cs
  24. 8
      src/Avalonia.Base/Styling/PropertySetterTemplateInstance.cs
  25. 10
      src/Avalonia.Base/Styling/Setter.cs
  26. 7
      tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs
  27. 300
      tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs
  28. 1
      tests/Avalonia.Base.UnitTests/AvaloniaPropertyTests.cs
  29. 408
      tests/Avalonia.Markup.UnitTests/Data/BindingTests_DataValidation.cs

5
src/Avalonia.Base/AvaloniaObject.cs

@ -784,6 +784,11 @@ namespace Avalonia
}
}
internal void OnUpdateDataValidation(AvaloniaProperty property, BindingValueType state, Exception? error)
{
UpdateDataValidation(property, state, error);
}
/// <summary>
/// Gets a description of an observable that van be used in logs.
/// </summary>

4
src/Avalonia.Base/AvaloniaObjectExtensions.cs

@ -199,13 +199,11 @@ namespace Avalonia
property = property ?? throw new ArgumentNullException(nameof(property));
binding = binding ?? throw new ArgumentNullException(nameof(binding));
var metadata = property.GetMetadata(target.GetType()) as IDirectPropertyMetadata;
var result = binding.Initiate(
target,
property,
anchor,
metadata?.EnableDataValidation ?? false);
property.GetMetadata(target.GetType()).EnableDataValidation ?? false);
if (result != null)
{

13
src/Avalonia.Base/AvaloniaProperty.cs

@ -227,6 +227,7 @@ namespace Avalonia
/// <param name="defaultBindingMode">The default binding mode for the property.</param>
/// <param name="validate">A value validation callback.</param>
/// <param name="coerce">A value coercion callback.</param>
/// <param name="enableDataValidation">Whether the property is interested in data validation.</param>
/// <returns>A <see cref="StyledProperty{TValue}"/></returns>
public static StyledProperty<TValue> Register<TOwner, TValue>(
string name,
@ -234,7 +235,8 @@ namespace Avalonia
bool inherits = false,
BindingMode defaultBindingMode = BindingMode.OneWay,
Func<TValue, bool>? validate = null,
Func<AvaloniaObject, TValue, TValue>? coerce = null)
Func<AvaloniaObject, TValue, TValue>? coerce = null,
bool enableDataValidation = false)
where TOwner : AvaloniaObject
{
_ = name ?? throw new ArgumentNullException(nameof(name));
@ -242,7 +244,8 @@ namespace Avalonia
var metadata = new StyledPropertyMetadata<TValue>(
defaultValue,
defaultBindingMode: defaultBindingMode,
coerce: coerce);
coerce: coerce,
enableDataValidation: enableDataValidation);
var result = new StyledProperty<TValue>(
name,
@ -253,7 +256,7 @@ namespace Avalonia
AvaloniaPropertyRegistry.Instance.Register(typeof(TOwner), result);
return result;
}
/// <inheritdoc cref="Register{TOwner, TValue}" />
/// <param name="notifying">
/// A method that gets called before and after the property starts being notified on an
@ -267,6 +270,7 @@ namespace Avalonia
BindingMode defaultBindingMode,
Func<TValue, bool>? validate,
Func<AvaloniaObject, TValue, TValue>? coerce,
bool enableDataValidation,
Action<AvaloniaObject, bool>? notifying)
where TOwner : AvaloniaObject
{
@ -275,7 +279,8 @@ namespace Avalonia
var metadata = new StyledPropertyMetadata<TValue>(
defaultValue,
defaultBindingMode: defaultBindingMode,
coerce: coerce);
coerce: coerce,
enableDataValidation: enableDataValidation);
var result = new StyledProperty<TValue>(
name,

18
src/Avalonia.Base/AvaloniaPropertyMetadata.cs

@ -13,10 +13,13 @@ namespace Avalonia
/// Initializes a new instance of the <see cref="AvaloniaPropertyMetadata"/> class.
/// </summary>
/// <param name="defaultBindingMode">The default binding mode.</param>
/// <param name="enableDataValidation">Whether the property is interested in data validation.</param>
public AvaloniaPropertyMetadata(
BindingMode defaultBindingMode = BindingMode.Default)
BindingMode defaultBindingMode = BindingMode.Default,
bool? enableDataValidation = null)
{
_defaultBindingMode = defaultBindingMode;
EnableDataValidation = enableDataValidation;
}
/// <summary>
@ -31,6 +34,17 @@ namespace Avalonia
}
}
/// <summary>
/// Gets a value indicating whether the property is interested in data validation.
/// </summary>
/// <remarks>
/// Data validation is validation performed at the target of a binding, for example in a
/// view model using the INotifyDataErrorInfo interface. Only certain properties on a
/// control (such as a TextBox's Text property) will be interested in receiving data
/// validation messages so this feature must be explicitly enabled by setting this flag.
/// </remarks>
public bool? EnableDataValidation { get; private set; }
/// <summary>
/// Merges the metadata with the base metadata.
/// </summary>
@ -44,6 +58,8 @@ namespace Avalonia
{
_defaultBindingMode = baseMetadata.DefaultBindingMode;
}
EnableDataValidation ??= baseMetadata.EnableDataValidation;
}
}
}

27
src/Avalonia.Base/DirectPropertyMetadata`1.cs

@ -21,10 +21,9 @@ namespace Avalonia
TValue unsetValue = default!,
BindingMode defaultBindingMode = BindingMode.Default,
bool? enableDataValidation = null)
: base(defaultBindingMode)
: base(defaultBindingMode, enableDataValidation)
{
UnsetValue = unsetValue;
EnableDataValidation = enableDataValidation;
}
/// <summary>
@ -32,16 +31,6 @@ namespace Avalonia
/// </summary>
public TValue UnsetValue { get; private set; }
/// <summary>
/// Gets a value indicating whether the property is interested in data validation.
/// </summary>
/// <remarks>
/// Data validation is validation performed at the target of a binding, for example in a
/// view model using the INotifyDataErrorInfo interface. Only certain properties on a
/// control (such as a TextBox's Text property) will be interested in receiving data
/// validation messages so this feature must be explicitly enabled by setting this flag.
/// </remarks>
public bool? EnableDataValidation { get; private set; }
/// <inheritdoc/>
object? IDirectPropertyMetadata.UnsetValue => UnsetValue;
@ -51,19 +40,9 @@ namespace Avalonia
{
base.Merge(baseMetadata, property);
var src = baseMetadata as DirectPropertyMetadata<TValue>;
if (src != null)
if (baseMetadata is DirectPropertyMetadata<TValue> src)
{
if (UnsetValue == null)
{
UnsetValue = src.UnsetValue;
}
if (EnableDataValidation == null)
{
EnableDataValidation = src.EnableDataValidation;
}
UnsetValue ??= src.UnsetValue;
}
}
}

76
src/Avalonia.Base/PropertyStore/BindingEntryBase.cs

@ -16,27 +16,37 @@ namespace Avalonia.PropertyStore
private IDisposable? _subscription;
private bool _hasValue;
private TValue? _value;
private TValue? _defaultValue;
private bool _isDefaultValueInitialized;
private UncommonFields? _uncommon;
protected BindingEntryBase(
AvaloniaObject target,
ValueFrame frame,
AvaloniaProperty property,
IObservable<BindingValue<TSource>> source)
: this(target, frame, property, (object)source)
{
Frame = frame;
Source = source;
Property = property;
}
protected BindingEntryBase(
AvaloniaObject target,
ValueFrame frame,
AvaloniaProperty property,
IObservable<TSource> source)
: this(target, frame, property, (object)source)
{
}
private BindingEntryBase(
AvaloniaObject target,
ValueFrame frame,
AvaloniaProperty property,
object source)
{
Frame = frame;
Source = source;
Property = property;
Source = source;
if (property.GetMetadata(target.GetType()).EnableDataValidation == true)
_uncommon = new() { _hasDataValidation = true };
}
public bool HasValue
@ -68,6 +78,20 @@ namespace Avalonia.PropertyStore
return _value!;
}
public bool GetDataValidationState(out BindingValueType state, out Exception? error)
{
if (_uncommon?._hasDataValidation == true)
{
state = _uncommon._dataValidationState;
error = _uncommon._dataValidationError;
return true;
}
state = BindingValueType.Value;
error = null;
return false;
}
public void Start() => Start(true);
public void OnCompleted() => BindingCompleted();
@ -111,16 +135,28 @@ namespace Avalonia.PropertyStore
{
static void Execute(BindingEntryBase<TValue, TSource> instance, BindingValue<TValue> value)
{
if (instance.Frame.Owner is null)
if (instance.Frame.Owner is not { } valueStore)
return;
LoggingUtils.LogIfNecessary(instance.Frame.Owner.Owner, instance.Property, value);
var owner = valueStore.Owner;
var property = instance.Property;
var originalType = value.Type;
LoggingUtils.LogIfNecessary(owner, property, value);
var effectiveValue = value.HasValue ? value.Value : instance.GetCachedDefaultValue();
if (!value.HasValue && value.Type != BindingValueType.DataValidationError)
value = value.WithValue(instance.GetCachedDefaultValue());
if (!instance._hasValue || !EqualityComparer<TValue>.Default.Equals(instance._value, effectiveValue))
if (instance._uncommon?._hasDataValidation == true)
{
instance._value = effectiveValue;
instance._uncommon._dataValidationState = value.Type;
instance._uncommon._dataValidationError = value.Error;
}
if (value.HasValue &&
(!instance._hasValue || !EqualityComparer<TValue>.Default.Equals(instance._value, value.Value)))
{
instance._value = value.Value;
instance._hasValue = true;
if (instance._subscription is not null && instance._subscription != s_creatingQuiet)
instance.Frame.Owner?.OnBindingValueChanged(instance, instance.Frame.Priority);
@ -152,13 +188,23 @@ namespace Avalonia.PropertyStore
private TValue GetCachedDefaultValue()
{
if (!_isDefaultValueInitialized)
if (_uncommon?._isDefaultValueInitialized != true)
{
_defaultValue = GetDefaultValue(Frame.Owner!.Owner.GetType());
_isDefaultValueInitialized = true;
_uncommon ??= new();
_uncommon._defaultValue = GetDefaultValue(Frame.Owner!.Owner.GetType());
_uncommon._isDefaultValueInitialized = true;
}
return _defaultValue!;
return _uncommon._defaultValue!;
}
private class UncommonFields
{
public TValue? _defaultValue;
public bool _isDefaultValueInitialized;
public bool _hasDataValidation;
public BindingValueType _dataValidationState;
public Exception? _dataValidationError;
}
}
}

11
src/Avalonia.Base/PropertyStore/DirectBindingObserver.cs

@ -9,11 +9,13 @@ namespace Avalonia.PropertyStore
IDisposable
{
private readonly ValueStore _owner;
private readonly bool _hasDataValidation;
private IDisposable? _subscription;
public DirectBindingObserver(ValueStore owner, DirectPropertyBase<T> property)
{
_owner = owner;
_hasDataValidation = property.GetMetadata(owner.Owner.GetType())?.EnableDataValidation ?? false;
Property = property;
}
@ -33,10 +35,17 @@ namespace Avalonia.PropertyStore
{
_subscription?.Dispose();
_subscription = null;
OnCompleted();
}
public void OnCompleted()
{
_owner.OnLocalValueBindingCompleted(Property, this);
if (_hasDataValidation)
_owner.Owner.OnUpdateDataValidation(Property, BindingValueType.UnsetValue, null);
}
public void OnCompleted() => _owner.OnLocalValueBindingCompleted(Property, this);
public void OnError(Exception error) => OnCompleted();
public void OnNext(T value)

5
src/Avalonia.Base/PropertyStore/DirectUntypedBindingObserver.cs

@ -10,11 +10,13 @@ namespace Avalonia.PropertyStore
IDisposable
{
private readonly ValueStore _owner;
private readonly bool _hasDataValidation;
private IDisposable? _subscription;
public DirectUntypedBindingObserver(ValueStore owner, DirectPropertyBase<T> property)
{
_owner = owner;
_hasDataValidation = property.GetMetadata(owner.Owner.GetType())?.EnableDataValidation ?? false;
Property = property;
}
@ -30,6 +32,9 @@ namespace Avalonia.PropertyStore
_subscription?.Dispose();
_subscription = null;
_owner.OnLocalValueBindingCompleted(Property, this);
if (_hasDataValidation)
_owner.Owner.OnUpdateDataValidation(Property, BindingValueType.UnsetValue, null);
}
public void OnCompleted() => _owner.OnLocalValueBindingCompleted(Property, this);

51
src/Avalonia.Base/PropertyStore/EffectiveValue.cs

@ -11,9 +11,6 @@ namespace Avalonia.PropertyStore
/// </remarks>
internal abstract class EffectiveValue
{
private IValueEntry? _valueEntry;
private IValueEntry? _baseValueEntry;
/// <summary>
/// Gets the current effective value as a boxed value.
/// </summary>
@ -29,6 +26,16 @@ namespace Avalonia.PropertyStore
/// </summary>
public BindingPriority BasePriority { get; protected set; }
/// <summary>
/// Gets the active value entry for the current effective value.
/// </summary>
public IValueEntry? ValueEntry { get; private set; }
/// <summary>
/// Gets the active value entry for the current base value.
/// </summary>
public IValueEntry? BaseValueEntry { get; private set; }
/// <summary>
/// Gets a value indicating whether the <see cref="Value"/> was overridden by a call to
/// <see cref="AvaloniaObject.SetCurrentValue{T}"/>.
@ -63,14 +70,14 @@ namespace Avalonia.PropertyStore
{
if (Priority == BindingPriority.Unset)
{
_valueEntry?.Unsubscribe();
_valueEntry = null;
ValueEntry?.Unsubscribe();
ValueEntry = null;
}
if (BasePriority == BindingPriority.Unset)
{
_baseValueEntry?.Unsubscribe();
_baseValueEntry = null;
BaseValueEntry?.Unsubscribe();
BaseValueEntry = null;
}
}
@ -135,40 +142,34 @@ namespace Avalonia.PropertyStore
// value, then the current entry becomes our base entry.
if (Priority > BindingPriority.LocalValue && Priority < BindingPriority.Inherited)
{
Debug.Assert(_valueEntry is not null);
_baseValueEntry = _valueEntry;
_valueEntry = null;
Debug.Assert(ValueEntry is not null);
BaseValueEntry = ValueEntry;
ValueEntry = null;
}
if (_valueEntry != entry)
if (ValueEntry != entry)
{
_valueEntry?.Unsubscribe();
_valueEntry = entry;
ValueEntry?.Unsubscribe();
ValueEntry = entry;
}
}
else if (Priority <= BindingPriority.Animation)
{
// We've received a non-animation value and have an active animation value, so the
// new entry becomes our base entry.
if (_baseValueEntry != entry)
if (BaseValueEntry != entry)
{
_baseValueEntry?.Unsubscribe();
_baseValueEntry = entry;
BaseValueEntry?.Unsubscribe();
BaseValueEntry = entry;
}
}
else if (_valueEntry != entry)
else if (ValueEntry != entry)
{
// Both the current value and the new value are non-animation values, so the new
// entry replaces the existing entry.
_valueEntry?.Unsubscribe();
_valueEntry = entry;
ValueEntry?.Unsubscribe();
ValueEntry = entry;
}
}
protected void UnsubscribeValueEntries()
{
_valueEntry?.Unsubscribe();
_baseValueEntry?.Unsubscribe();
}
}
}

26
src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Data;
using static Avalonia.Rendering.Composition.Animations.PropertySetSnapshot;
namespace Avalonia.PropertyStore
{
@ -61,6 +62,12 @@ namespace Avalonia.PropertyStore
UpdateValueEntry(value, priority);
SetAndRaiseCore(owner, (StyledProperty<T>)value.Property, GetValue(value), priority, false);
if (priority > BindingPriority.LocalValue &&
value.GetDataValidationState(out var state, out var error))
{
owner.Owner.OnUpdateDataValidation(value.Property, state, error);
}
}
public void SetLocalValueAndRaise(
@ -128,12 +135,10 @@ namespace Avalonia.PropertyStore
public override void DisposeAndRaiseUnset(ValueStore owner, AvaloniaProperty property)
{
UnsubscribeValueEntries();
DisposeAndRaiseUnset(owner, (StyledProperty<T>)property);
}
ValueEntry?.Unsubscribe();
BaseValueEntry?.Unsubscribe();
public void DisposeAndRaiseUnset(ValueStore owner, StyledProperty<T> property)
{
var p = (StyledProperty<T>)property;
BindingPriority priority;
T oldValue;
@ -150,9 +155,16 @@ namespace Avalonia.PropertyStore
if (!EqualityComparer<T>.Default.Equals(oldValue, Value))
{
owner.Owner.RaisePropertyChanged(property, Value, oldValue, priority, true);
owner.Owner.RaisePropertyChanged(p, Value, oldValue, priority, true);
if (property.Inherits)
owner.OnInheritedEffectiveValueDisposed(property, Value);
owner.OnInheritedEffectiveValueDisposed(p, Value);
}
if (ValueEntry?.GetDataValidationState(out _, out _) ??
BaseValueEntry?.GetDataValidationState(out _, out _) ??
false)
{
owner.Owner.OnUpdateDataValidation(p, BindingValueType.UnsetValue, null);
}
}

11
src/Avalonia.Base/PropertyStore/IValueEntry.cs

@ -1,4 +1,5 @@
using System;
using Avalonia.Data;
namespace Avalonia.PropertyStore
{
@ -22,6 +23,16 @@ namespace Avalonia.PropertyStore
/// </exception>
object? GetValue();
/// <summary>
/// Gets the data validation state if supported.
/// </summary>
/// <param name="state">The binding validation state.</param>
/// <param name="error">The current binding error, if any.</param>
/// <returns>
/// True if the entry supports data validation, otherwise false.
/// </returns>
bool GetDataValidationState(out BindingValueType state, out Exception? error);
/// <summary>
/// Called when the value entry is removed from the value store.
/// </summary>

8
src/Avalonia.Base/PropertyStore/ImmediateValueEntry.cs

@ -1,4 +1,5 @@
using System;
using Avalonia.Data;
namespace Avalonia.PropertyStore
{
@ -27,5 +28,12 @@ namespace Avalonia.PropertyStore
object? IValueEntry.GetValue() => _value;
T IValueEntry<T>.GetValue() => _value;
bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error)
{
state = BindingValueType.Value;
error = null;
return false;
}
}
}

6
src/Avalonia.Base/PropertyStore/ImmediateValueFrame.cs

@ -18,7 +18,7 @@ namespace Avalonia.PropertyStore
StyledProperty<T> property,
IObservable<BindingValue<T>> source)
{
var e = new TypedBindingEntry<T>(this, property, source);
var e = new TypedBindingEntry<T>(Owner!.Owner, this, property, source);
Add(e);
return e;
}
@ -27,7 +27,7 @@ namespace Avalonia.PropertyStore
StyledProperty<T> property,
IObservable<T> source)
{
var e = new TypedBindingEntry<T>(this, property, source);
var e = new TypedBindingEntry<T>(Owner!.Owner, this, property, source);
Add(e);
return e;
}
@ -36,7 +36,7 @@ namespace Avalonia.PropertyStore
StyledProperty<T> property,
IObservable<object?> source)
{
var e = new SourceUntypedBindingEntry<T>(this, property, source);
var e = new SourceUntypedBindingEntry<T>(Owner!.Owner, this, property, source);
Add(e);
return e;
}

114
src/Avalonia.Base/PropertyStore/LocalValueBindingObserver.cs

@ -1,121 +1,25 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Data;
using Avalonia.Threading;
namespace Avalonia.PropertyStore
{
internal class LocalValueBindingObserver<T> : IObserver<T>,
IObserver<BindingValue<T>>,
IDisposable
internal class LocalValueBindingObserver<T> : LocalValueBindingObserverBase<T>,
IObserver<object?>
{
private readonly ValueStore _owner;
private IDisposable? _subscription;
private T? _defaultValue;
private bool _isDefaultValueInitialized;
public LocalValueBindingObserver(ValueStore owner, StyledProperty<T> property)
: base(owner, property)
{
_owner = owner;
Property = property;
}
public StyledProperty<T> Property { get;}
public void Start(IObservable<T> source)
{
_subscription = source.Subscribe(this);
}
public void Start(IObservable<BindingValue<T>> source)
{
_subscription = source.Subscribe(this);
}
public void Start(IObservable<object?> source) => _subscription = source.Subscribe(this);
public void Dispose()
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = TrimmingMessages.ImplicitTypeConvertionSupressWarningMessage)]
public void OnNext(object? value)
{
_subscription?.Dispose();
_subscription = null;
_owner.OnLocalValueBindingCompleted(Property, this);
}
public void OnCompleted() => _owner.OnLocalValueBindingCompleted(Property, this);
public void OnError(Exception error) => OnCompleted();
public void OnNext(T value)
{
static void Execute(LocalValueBindingObserver<T> instance, T value)
{
var owner = instance._owner;
var property = instance.Property;
if (property.ValidateValue?.Invoke(value) == false)
value = instance.GetCachedDefaultValue();
owner.SetValue(property, value, BindingPriority.LocalValue);
}
if (Dispatcher.UIThread.CheckAccess())
{
Execute(this, value);
}
else
{
// To avoid allocating closure in the outer scope we need to capture variables
// locally. This allows us to skip most of the allocations when on UI thread.
var instance = this;
var newValue = value;
Dispatcher.UIThread.Post(() => Execute(instance, newValue));
}
}
public void OnNext(BindingValue<T> value)
{
static void Execute(LocalValueBindingObserver<T> instance, BindingValue<T> value)
{
var owner = instance._owner;
var property = instance.Property;
LoggingUtils.LogIfNecessary(owner.Owner, property, value);
if (value.HasValue)
{
var effectiveValue = value.Value;
if (property.ValidateValue?.Invoke(effectiveValue) == false)
effectiveValue = instance.GetCachedDefaultValue();
owner.SetValue(property, effectiveValue, BindingPriority.LocalValue);
}
else
{
owner.SetValue(property, instance.GetCachedDefaultValue(), BindingPriority.LocalValue);
}
}
if (value.Type is BindingValueType.DoNothing or BindingValueType.DataValidationError)
if (value == BindingOperations.DoNothing)
return;
if (Dispatcher.UIThread.CheckAccess())
{
Execute(this, value);
}
else
{
// To avoid allocating closure in the outer scope we need to capture variables
// locally. This allows us to skip most of the allocations when on UI thread.
var instance = this;
var newValue = value;
Dispatcher.UIThread.Post(() => Execute(instance, newValue));
}
}
private T GetCachedDefaultValue()
{
if (!_isDefaultValueInitialized)
{
_defaultValue = Property.GetDefaultValue(_owner.Owner.GetType());
_isDefaultValueInitialized = true;
}
return _defaultValue!;
base.OnNext(BindingValue<T>.FromUntyped(value, Property.PropertyType));
}
}
}

133
src/Avalonia.Base/PropertyStore/LocalValueBindingObserverBase.cs

@ -0,0 +1,133 @@
using System;
using Avalonia.Data;
using Avalonia.Threading;
namespace Avalonia.PropertyStore
{
internal class LocalValueBindingObserverBase<T> : IObserver<T>,
IObserver<BindingValue<T>>,
IDisposable
{
private readonly ValueStore _owner;
private readonly bool _hasDataValidation;
protected IDisposable? _subscription;
private T? _defaultValue;
private bool _isDefaultValueInitialized;
protected LocalValueBindingObserverBase(ValueStore owner, StyledProperty<T> property)
{
_owner = owner;
Property = property;
_hasDataValidation = property.GetMetadata(owner.Owner.GetType()).EnableDataValidation ?? false;
}
public StyledProperty<T> Property { get;}
public void Start(IObservable<T> source)
{
_subscription = source.Subscribe(this);
}
public void Start(IObservable<BindingValue<T>> source)
{
_subscription = source.Subscribe(this);
}
public void Dispose()
{
_subscription?.Dispose();
_subscription = null;
OnCompleted();
}
public void OnCompleted()
{
if (_hasDataValidation)
_owner.Owner.OnUpdateDataValidation(Property, BindingValueType.UnsetValue, null);
_owner.OnLocalValueBindingCompleted(Property, this);
}
public void OnError(Exception error) => OnCompleted();
public void OnNext(T value)
{
static void Execute(LocalValueBindingObserverBase<T> instance, T value)
{
var owner = instance._owner;
var property = instance.Property;
if (property.ValidateValue?.Invoke(value) == false)
value = instance.GetCachedDefaultValue();
owner.SetLocalValue(property, value);
if (instance._hasDataValidation)
owner.Owner.OnUpdateDataValidation(property, BindingValueType.Value, null);
}
if (Dispatcher.UIThread.CheckAccess())
{
Execute(this, value);
}
else
{
// To avoid allocating closure in the outer scope we need to capture variables
// locally. This allows us to skip most of the allocations when on UI thread.
var instance = this;
var newValue = value;
Dispatcher.UIThread.Post(() => Execute(instance, newValue));
}
}
public void OnNext(BindingValue<T> value)
{
static void Execute(LocalValueBindingObserverBase<T> instance, BindingValue<T> value)
{
var owner = instance._owner;
var property = instance.Property;
var originalType = value.Type;
LoggingUtils.LogIfNecessary(owner.Owner, property, value);
// Revert to the default value if the binding value fails validation, or if
// there was no value (though not if there was a data validation error).
if ((value.HasValue && property.ValidateValue?.Invoke(value.Value) == false) ||
(!value.HasValue && value.Type != BindingValueType.DataValidationError))
value = value.WithValue(instance.GetCachedDefaultValue());
if (value.HasValue)
owner.SetLocalValue(property, value.Value);
if (instance._hasDataValidation)
owner.Owner.OnUpdateDataValidation(property, originalType, value.Error);
}
if (value.Type is BindingValueType.DoNothing)
return;
if (Dispatcher.UIThread.CheckAccess())
{
Execute(this, value);
}
else
{
// To avoid allocating closure in the outer scope we need to capture variables
// locally. This allows us to skip most of the allocations when on UI thread.
var instance = this;
var newValue = value;
Dispatcher.UIThread.Post(() => Execute(instance, newValue));
}
}
private T GetCachedDefaultValue()
{
if (!_isDefaultValueInitialized)
{
_defaultValue = Property.GetDefaultValue(_owner.Owner.GetType());
_isDefaultValueInitialized = true;
}
return _defaultValue!;
}
}
}

94
src/Avalonia.Base/PropertyStore/LocalValueUntypedBindingObserver.cs

@ -1,94 +0,0 @@
using System;
using Avalonia.Data;
using Avalonia.Threading;
namespace Avalonia.PropertyStore
{
internal class LocalValueUntypedBindingObserver<T> : IObserver<object?>,
IDisposable
{
private readonly ValueStore _owner;
private IDisposable? _subscription;
private T? _defaultValue;
private bool _isDefaultValueInitialized;
public LocalValueUntypedBindingObserver(ValueStore owner, StyledProperty<T> property)
{
_owner = owner;
Property = property;
}
public StyledProperty<T> Property { get; }
public void Start(IObservable<object?> source)
{
_subscription = source.Subscribe(this);
}
public void Dispose()
{
_subscription?.Dispose();
_subscription = null;
_owner.OnLocalValueBindingCompleted(Property, this);
}
public void OnCompleted() => _owner.OnLocalValueBindingCompleted(Property, this);
public void OnError(Exception error) => OnCompleted();
public void OnNext(object? value)
{
static void Execute(LocalValueUntypedBindingObserver<T> instance, object? value)
{
var owner = instance._owner;
var property = instance.Property;
if (value is BindingNotification n)
{
value = n.Value;
LoggingUtils.LogIfNecessary(owner.Owner, property, n);
}
if (value == AvaloniaProperty.UnsetValue)
{
owner.SetValue(property, instance.GetCachedDefaultValue(), BindingPriority.LocalValue);
}
else if (UntypedValueUtils.TryConvertAndValidate(property, value, out var typedValue))
{
owner.SetValue(property, typedValue, BindingPriority.LocalValue);
}
else
{
owner.SetValue(property, instance.GetCachedDefaultValue(), BindingPriority.LocalValue);
LoggingUtils.LogInvalidValue(owner.Owner, property, typeof(T), value);
}
}
if (value == BindingOperations.DoNothing)
return;
if (Dispatcher.UIThread.CheckAccess())
{
Execute(this, value);
}
else if (value != BindingOperations.DoNothing)
{
// To avoid allocating closure in the outer scope we need to capture variables
// locally. This allows us to skip most of the allocations when on UI thread.
var instance = this;
var newValue = value;
Dispatcher.UIThread.Post(() => Execute(instance, newValue));
}
}
private T GetCachedDefaultValue()
{
if (!_isDefaultValueInitialized)
{
_defaultValue = Property.GetDefaultValue(_owner.Owner.GetType());
_isDefaultValueInitialized = true;
}
return _defaultValue!;
}
}
}

3
src/Avalonia.Base/PropertyStore/SourceUntypedBindingEntry.cs

@ -12,10 +12,11 @@ namespace Avalonia.PropertyStore
private readonly Func<TTarget, bool>? _validate;
public SourceUntypedBindingEntry(
AvaloniaObject target,
ValueFrame frame,
StyledProperty<TTarget> property,
IObservable<object?> source)
: base(frame, property, source)
: base(target, frame, property, source)
{
_validate = property.ValidateValue;
}

6
src/Avalonia.Base/PropertyStore/TypedBindingEntry.cs

@ -10,18 +10,20 @@ namespace Avalonia.PropertyStore
internal sealed class TypedBindingEntry<T> : BindingEntryBase<T, T>
{
public TypedBindingEntry(
AvaloniaObject target,
ValueFrame frame,
StyledProperty<T> property,
IObservable<T> source)
: base(frame, property, source)
: base(target, frame, property, source)
{
}
public TypedBindingEntry(
AvaloniaObject target,
ValueFrame frame,
StyledProperty<T> property,
IObservable<BindingValue<T>> source)
: base(frame, property, source)
: base(target, frame, property, source)
{
}

3
src/Avalonia.Base/PropertyStore/UntypedBindingEntry.cs

@ -12,10 +12,11 @@ namespace Avalonia.PropertyStore
private readonly Func<object?, bool>? _validate;
public UntypedBindingEntry(
AvaloniaObject target,
ValueFrame frame,
AvaloniaProperty property,
IObservable<object?> source)
: base(frame, property, source)
: base(target, frame, property, source)
{
_validate = ((IStyledPropertyAccessor)property).ValidateValue;
}

37
src/Avalonia.Base/PropertyStore/ValueStore.cs

@ -104,7 +104,7 @@ namespace Avalonia.PropertyStore
{
if (priority == BindingPriority.LocalValue)
{
var observer = new LocalValueUntypedBindingObserver<T>(this, property);
var observer = new LocalValueBindingObserver<T>(this, property);
DisposeExistingLocalValueBinding(property);
_localValueBindings ??= new();
_localValueBindings[property.Id] = observer;
@ -193,18 +193,7 @@ namespace Avalonia.PropertyStore
}
else
{
if (TryGetEffectiveValue(property, out var existing))
{
var effective = (EffectiveValue<T>)existing;
effective.SetLocalValueAndRaise(this, property, value);
}
else
{
var effectiveValue = CreateEffectiveValue(property);
AddEffectiveValue(property, effectiveValue);
effectiveValue.SetLocalValueAndRaise(this, property, value);
}
SetLocalValue(property, value);
return null;
}
}
@ -223,6 +212,21 @@ namespace Avalonia.PropertyStore
}
}
public void SetLocalValue<T>(StyledProperty<T> property, T value)
{
if (TryGetEffectiveValue(property, out var existing))
{
var effective = (EffectiveValue<T>)existing;
effective.SetLocalValueAndRaise(this, property, value);
}
else
{
var effectiveValue = CreateEffectiveValue(property);
AddEffectiveValue(property, effectiveValue);
effectiveValue.SetLocalValueAndRaise(this, property, value);
}
}
public object? GetValue(AvaloniaProperty property)
{
if (_effectiveValues.TryGetValue(property, out var v))
@ -834,8 +838,6 @@ namespace Avalonia.PropertyStore
break;
}
current?.EndReevaluation();
if (current?.Priority == BindingPriority.Unset)
{
if (current.BasePriority == BindingPriority.Unset)
@ -848,6 +850,8 @@ namespace Avalonia.PropertyStore
current.RemoveAnimationAndRaise(this, property);
}
}
current?.EndReevaluation();
}
finally
{
@ -919,7 +923,6 @@ namespace Avalonia.PropertyStore
for (var i = _effectiveValues.Count - 1; i >= 0; --i)
{
_effectiveValues.GetKeyValue(i, out var key, out var e);
e.EndReevaluation();
if (e.Priority == BindingPriority.Unset)
{
@ -929,6 +932,8 @@ namespace Avalonia.PropertyStore
if (i > _effectiveValues.Count)
break;
}
e.EndReevaluation();
}
}
finally

1
src/Avalonia.Base/StyledElement.cs

@ -46,6 +46,7 @@ namespace Avalonia
defaultBindingMode: BindingMode.OneWay,
validate: null,
coerce: null,
enableDataValidation: false,
notifying: DataContextNotifying);
/// <summary>

6
src/Avalonia.Base/StyledPropertyMetadata`1.cs

@ -16,11 +16,13 @@ namespace Avalonia
/// <param name="defaultValue">The default value of the property.</param>
/// <param name="defaultBindingMode">The default binding mode.</param>
/// <param name="coerce">A value coercion callback.</param>
/// <param name="enableDataValidation">Whether the property is interested in data validation.</param>
public StyledPropertyMetadata(
Optional<TValue> defaultValue = default,
BindingMode defaultBindingMode = BindingMode.Default,
Func<AvaloniaObject, TValue, TValue>? coerce = null)
: base(defaultBindingMode)
Func<AvaloniaObject, TValue, TValue>? coerce = null,
bool enableDataValidation = false)
: base(defaultBindingMode, enableDataValidation)
{
_defaultValue = defaultValue;
CoerceValue = coerce;

2
src/Avalonia.Base/Styling/PropertySetterBindingInstance.cs

@ -15,7 +15,7 @@ namespace Avalonia.Styling
AvaloniaProperty property,
BindingMode mode,
IObservable<object?> source)
: base(instance, property, source)
: base(target, instance, property, source)
{
_target = target;
_mode = mode;

8
src/Avalonia.Base/Styling/PropertySetterTemplateInstance.cs

@ -1,4 +1,5 @@
using System;
using Avalonia.Data;
using Avalonia.PropertyStore;
namespace Avalonia.Styling
@ -19,6 +20,13 @@ namespace Avalonia.Styling
public object? GetValue() => _value ??= _template.Build();
bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error)
{
state = BindingValueType.Value;
error = null;
return false;
}
void IValueEntry.Unsubscribe() { }
}
}

10
src/Avalonia.Base/Styling/Setter.cs

@ -90,6 +90,13 @@ namespace Avalonia.Styling
object? IValueEntry.GetValue() => Value;
bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error)
{
state = BindingValueType.Value;
error = null;
return false;
}
private AvaloniaProperty EnsureProperty()
{
return Property ?? throw new InvalidOperationException("Setter.Property must be set.");
@ -99,7 +106,8 @@ namespace Avalonia.Styling
{
if (!Property!.IsDirect)
{
var i = binding.Initiate(target, Property)!;
var hasDataValidation = Property.GetMetadata(target.GetType()).EnableDataValidation ?? false;
var i = binding.Initiate(target, Property, enableDataValidation: hasDataValidation)!;
var mode = i.Mode;
if (mode == BindingMode.Default)

7
tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_Binding.cs

@ -888,7 +888,8 @@ namespace Avalonia.Base.UnitTests
var target = new Class1();
var source = new Subject<object?>();
var called = false;
var expectedMessageTemplate = "Error in binding to {Target}.{Property}: expected {ExpectedType}, got {Value} ({ValueType})";
var expectedMessageTemplate = "Error in binding to {Target}.{Property}: {Message}";
var message = "Unable to convert object 'foo' of type 'System.String' to type 'System.Double'.";
LogCallback checkLogMessage = (level, area, src, mt, pv) =>
{
@ -898,9 +899,7 @@ namespace Avalonia.Base.UnitTests
src == target &&
pv[0].GetType() == typeof(Class1) &&
(AvaloniaProperty)pv[1] == Class1.QuxProperty &&
(Type)pv[2] == typeof(double) &&
(string)pv[3] == "foo" &&
(Type)pv[4] == typeof(string))
(string)pv[2] == message)
{
called = true;
}

300
tests/Avalonia.Base.UnitTests/AvaloniaObjectTests_DataValidation.cs

@ -1,115 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Subjects;
using Avalonia.Data;
using Avalonia.UnitTests;
using Xunit;
#nullable enable
namespace Avalonia.Base.UnitTests
{
public class AvaloniaObjectTests_DataValidation
{
[Fact]
public void Binding_Non_Validated_Styled_Property_Does_Not_Call_UpdateDataValidation()
public abstract class TestBase<T>
where T : AvaloniaProperty<int>
{
var target = new Class1();
var source = new Subject<BindingValue<int>>();
[Fact]
public void Binding_Non_Validated_Property_Does_Not_Call_UpdateDataValidation()
{
var target = new Class1();
var source = new Subject<BindingValue<int>>();
var property = GetNonValidatedProperty();
target.Bind(Class1.NonValidatedProperty, source);
source.OnNext(6);
source.OnNext(BindingValue<int>.BindingError(new Exception()));
source.OnNext(BindingValue<int>.DataValidationError(new Exception()));
source.OnNext(6);
target.Bind(property, source);
source.OnNext(6);
source.OnNext(BindingValue<int>.BindingError(new Exception()));
source.OnNext(BindingValue<int>.DataValidationError(new Exception()));
source.OnNext(6);
Assert.Empty(target.Notifications);
}
Assert.Empty(target.Notifications);
}
[Fact]
public void Binding_Non_Validated_Direct_Property_Does_Not_Call_UpdateDataValidation()
{
var target = new Class1();
var source = new Subject<BindingValue<int>>();
[Fact]
public void Binding_Validated_Property_Calls_UpdateDataValidation()
{
var target = new Class1();
var source = new Subject<BindingValue<int>>();
var property = GetProperty();
var error1 = new Exception();
var error2 = new Exception();
target.Bind(Class1.NonValidatedDirectProperty, source);
source.OnNext(6);
source.OnNext(BindingValue<int>.BindingError(new Exception()));
source.OnNext(BindingValue<int>.DataValidationError(new Exception()));
source.OnNext(6);
target.Bind(property, source);
source.OnNext(6);
source.OnNext(BindingValue<int>.DataValidationError(error1));
source.OnNext(BindingValue<int>.BindingError(error2));
source.OnNext(7);
Assert.Empty(target.Notifications);
}
Assert.Equal(new Notification[]
{
new(BindingValueType.Value, 6, null),
new(BindingValueType.DataValidationError, 6, error1),
new(BindingValueType.BindingError, 0, error2),
new(BindingValueType.Value, 7, null),
}, target.Notifications);
}
[Fact]
public void Binding_Validated_Direct_Property_Calls_UpdateDataValidation()
{
var target = new Class1();
var source = new Subject<BindingValue<int>>();
target.Bind(Class1.ValidatedDirectIntProperty, source);
source.OnNext(6);
source.OnNext(BindingValue<int>.BindingError(new Exception()));
source.OnNext(BindingValue<int>.DataValidationError(new Exception()));
source.OnNext(7);
var result = target.Notifications;
Assert.Equal(4, result.Count);
Assert.Equal(BindingValueType.Value, result[0].type);
Assert.Equal(6, result[0].value);
Assert.Equal(BindingValueType.BindingError, result[1].type);
Assert.Equal(BindingValueType.DataValidationError, result[2].type);
Assert.Equal(BindingValueType.Value, result[3].type);
Assert.Equal(7, result[3].value);
[Fact]
public void Binding_Validated_Property_Calls_UpdateDataValidation_Untyped()
{
var target = new Class1();
var source = new Subject<object>();
var property = GetProperty();
var error1 = new Exception();
var error2 = new Exception();
target.Bind(property, source);
source.OnNext(6);
source.OnNext(new BindingNotification(error1, BindingErrorType.DataValidationError));
source.OnNext(new BindingNotification(error2, BindingErrorType.Error));
source.OnNext(7);
Assert.Equal(new Notification[]
{
new(BindingValueType.Value, 6, null),
new(BindingValueType.DataValidationError, 6, error1),
new(BindingValueType.BindingError, 0, error2),
new(BindingValueType.Value, 7, null),
}, target.Notifications);
}
[Fact]
public void Binding_Overridden_Validated_Property_Calls_UpdateDataValidation()
{
var target = new Class2();
var source = new Subject<BindingValue<int>>();
var property = GetNonValidatedProperty();
// Class2 overrides the non-validated property metadata to enable data validation.
target.Bind(property, source);
source.OnNext(1);
Assert.Equal(1, target.Notifications.Count);
}
[Fact]
public void Disposing_Binding_Subscription_Clears_DataValidation()
{
var target = new Class1();
var source = new Subject<BindingValue<int>>();
var property = GetProperty();
var error = new Exception();
var sub = target.Bind(property, source);
source.OnNext(6);
source.OnNext(BindingValue<int>.DataValidationError(error));
sub.Dispose();
Assert.Equal(new Notification[]
{
new(BindingValueType.Value, 6, null),
new(BindingValueType.DataValidationError, 6, error),
new(BindingValueType.UnsetValue, 6, null),
}, target.Notifications);
}
[Fact]
public void Completing_Binding_Clears_DataValidation()
{
var target = new Class1();
var source = new Subject<BindingValue<int>>();
var property = GetProperty();
var error = new Exception();
target.Bind(property, source);
source.OnNext(6);
source.OnNext(BindingValue<int>.DataValidationError(error));
source.OnCompleted();
Assert.Equal(new Notification[]
{
new(BindingValueType.Value, 6, null),
new(BindingValueType.DataValidationError, 6, error),
new(BindingValueType.UnsetValue, 6, null),
}, target.Notifications);
}
protected abstract T GetProperty();
protected abstract T GetNonValidatedProperty();
}
[Fact]
public void Binding_Overridden_Validated_Direct_Property_Calls_UpdateDataValidation()
public class DirectPropertyTests : TestBase<DirectPropertyBase<int>>
{
var target = new Class2();
var source = new Subject<BindingValue<int>>();
[Fact]
public void Bound_Validated_String_Property_Can_Be_Set_To_Null()
{
var source = new ViewModel
{
StringValue = "foo",
};
// Class2 overrides `NonValidatedDirectProperty`'s metadata to enable data validation.
target.Bind(Class1.NonValidatedDirectProperty, source);
source.OnNext(1);
var target = new Class1
{
[!Class1.ValidatedDirectStringProperty] = new Binding
{
Path = nameof(ViewModel.StringValue),
Source = source,
},
};
Assert.Equal("foo", target.ValidatedDirectString);
Assert.Equal(1, target.Notifications.Count);
source.StringValue = null;
Assert.Null(target.ValidatedDirectString);
}
protected override DirectPropertyBase<int> GetProperty() => Class1.ValidatedDirectIntProperty;
protected override DirectPropertyBase<int> GetNonValidatedProperty() => Class1.NonValidatedDirectIntProperty;
}
[Fact]
public void Bound_Validated_Direct_String_Property_Can_Be_Set_To_Null()
public class StyledPropertyTests : TestBase<StyledProperty<int>>
{
var source = new ViewModel
[Fact]
public void Bound_Validated_String_Property_Can_Be_Set_To_Null()
{
StringValue = "foo",
};
var source = new ViewModel
{
StringValue = "foo",
};
var target = new Class1
{
[!Class1.ValidatedDirectStringProperty] = new Binding
var target = new Class1
{
Path = nameof(ViewModel.StringValue),
Source = source,
},
};
[!Class1.ValidatedDirectStringProperty] = new Binding
{
Path = nameof(ViewModel.StringValue),
Source = source,
},
};
Assert.Equal("foo", target.ValidatedDirectString);
Assert.Equal("foo", target.ValidatedDirectString);
source.StringValue = null;
source.StringValue = null;
Assert.Null(target.ValidatedDirectString);
Assert.Null(target.ValidatedDirectString);
}
protected override StyledProperty<int> GetProperty() => Class1.ValidatedStyledIntProperty;
protected override StyledProperty<int> GetNonValidatedProperty() => Class1.NonValidatedStyledIntProperty;
}
private record class Notification(BindingValueType type, object? value, Exception? error);
private class Class1 : AvaloniaObject
{
public static readonly StyledProperty<int> NonValidatedProperty =
AvaloniaProperty.Register<Class1, int>(
nameof(NonValidated));
public static readonly DirectProperty<Class1, int> NonValidatedDirectProperty =
public static readonly DirectProperty<Class1, int> NonValidatedDirectIntProperty =
AvaloniaProperty.RegisterDirect<Class1, int>(
nameof(NonValidatedDirect),
o => o.NonValidatedDirect,
(o, v) => o.NonValidatedDirect = v);
nameof(NonValidatedDirectInt),
o => o.NonValidatedDirectInt,
(o, v) => o.NonValidatedDirectInt = v);
public static readonly DirectProperty<Class1, int> ValidatedDirectIntProperty =
AvaloniaProperty.RegisterDirect<Class1, int>(
@ -118,27 +215,30 @@ namespace Avalonia.Base.UnitTests
(o, v) => o.ValidatedDirectInt = v,
enableDataValidation: true);
public static readonly DirectProperty<Class1, string> ValidatedDirectStringProperty =
AvaloniaProperty.RegisterDirect<Class1, string>(
public static readonly DirectProperty<Class1, string?> ValidatedDirectStringProperty =
AvaloniaProperty.RegisterDirect<Class1, string?>(
nameof(ValidatedDirectString),
o => o.ValidatedDirectString,
(o, v) => o.ValidatedDirectString = v,
enableDataValidation: true);
public static readonly StyledProperty<int> NonValidatedStyledIntProperty =
AvaloniaProperty.Register<Class1, int>(
nameof(NonValidatedStyledInt));
public static readonly StyledProperty<int> ValidatedStyledIntProperty =
AvaloniaProperty.Register<Class1, int>(
nameof(ValidatedStyledInt),
enableDataValidation: true);
private int _nonValidatedDirect;
private int _directInt;
private string _directString;
private string? _directString;
public int NonValidated
{
get { return GetValue(NonValidatedProperty); }
set { SetValue(NonValidatedProperty, value); }
}
public int NonValidatedDirect
public int NonValidatedDirectInt
{
get { return _directInt; }
set { SetAndRaise(NonValidatedDirectProperty, ref _nonValidatedDirect, value); }
set { SetAndRaise(NonValidatedDirectIntProperty, ref _nonValidatedDirect, value); }
}
public int ValidatedDirectInt
@ -147,20 +247,32 @@ namespace Avalonia.Base.UnitTests
set { SetAndRaise(ValidatedDirectIntProperty, ref _directInt, value); }
}
public string ValidatedDirectString
public string? ValidatedDirectString
{
get { return _directString; }
set { SetAndRaise(ValidatedDirectStringProperty, ref _directString, value); }
}
public List<(BindingValueType type, object value)> Notifications { get; } = new();
public int NonValidatedStyledInt
{
get { return GetValue(NonValidatedStyledIntProperty); }
set { SetValue(NonValidatedStyledIntProperty, value); }
}
public int ValidatedStyledInt
{
get => GetValue(ValidatedStyledIntProperty);
set => SetValue(ValidatedStyledIntProperty, value);
}
public List<Notification> Notifications { get; } = new();
protected override void UpdateDataValidation(
AvaloniaProperty property,
BindingValueType state,
Exception error)
Exception? error)
{
Notifications.Add((state, GetValue(property)));
Notifications.Add(new(state, GetValue(property), error));
}
}
@ -168,16 +280,18 @@ namespace Avalonia.Base.UnitTests
{
static Class2()
{
NonValidatedDirectProperty.OverrideMetadata<Class2>(
NonValidatedDirectIntProperty.OverrideMetadata<Class2>(
new DirectPropertyMetadata<int>(enableDataValidation: true));
NonValidatedStyledIntProperty.OverrideMetadata<Class2>(
new StyledPropertyMetadata<int>(enableDataValidation: true));
}
}
public class ViewModel : NotifyingBase
{
private string _stringValue;
private string? _stringValue;
public string StringValue
public string? StringValue
{
get { return _stringValue; }
set { _stringValue = value; RaisePropertyChanged(); }

1
tests/Avalonia.Base.UnitTests/AvaloniaPropertyTests.cs

@ -198,6 +198,7 @@ namespace Avalonia.Base.UnitTests
defaultBindingMode: BindingMode.OneWay,
validate: null,
coerce: null,
enableDataValidation: false,
notifying: FooNotifying);
public int NotifyCount { get; private set; }

408
tests/Avalonia.Markup.UnitTests/Data/BindingTests_DataValidation.cs

@ -1,72 +1,404 @@
using System;
using System.Reactive.Linq;
using System.Collections;
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Data.Core;
using Avalonia.Markup.Data;
using Avalonia.Styling;
using Avalonia.UnitTests;
using Xunit;
#nullable enable
namespace Avalonia.Markup.UnitTests.Data
{
public class BindingTests_DataValidation
{
[Fact]
public void Initiate_Should_Not_Enable_Data_Validation_With_BindingPriority_LocalValue()
public abstract class TestBase<T>
where T : AvaloniaProperty<int>
{
var textBlock = new TextBlock
[Fact]
public void Setter_Exception_Causes_DataValidation_Error()
{
var (target, property) = CreateTarget();
var binding = new Binding(nameof(ExceptionValidatingModel.Value))
{
Mode = BindingMode.TwoWay
};
target.DataContext = new ExceptionValidatingModel();
target.Bind(property, binding);
Assert.Equal(20, target.GetValue(property));
target.SetValue(property, 200);
Assert.Equal(200, target.GetValue(property));
Assert.IsType<ArgumentOutOfRangeException>(target.DataValidationError);
target.SetValue(property, 10);
Assert.Equal(10, target.GetValue(property));
Assert.Null(target.DataValidationError);
}
[Fact]
public void Indei_Error_Causes_DataValidation_Error()
{
var (target, property) = CreateTarget();
var binding = new Binding(nameof(IndeiValidatingModel.Value))
{
Mode = BindingMode.TwoWay
};
target.DataContext = new IndeiValidatingModel();
target.Bind(property, binding);
Assert.Equal(20, target.GetValue(property));
target.SetValue(property, 200);
Assert.Equal(200, target.GetValue(property));
Assert.IsType<DataValidationException>(target.DataValidationError);
Assert.Equal("Invalid value: 200.", target.DataValidationError?.Message);
target.SetValue(property, 10);
Assert.Equal(10, target.GetValue(property));
Assert.Null(target.DataValidationError);
}
[Fact]
public void Disposing_Binding_Subscription_Clears_DataValidation()
{
DataContext = new Class1(),
};
var (target, property) = CreateTarget();
var binding = new Binding(nameof(ExceptionValidatingModel.Value))
{
Mode = BindingMode.TwoWay
};
target.DataContext = new IndeiValidatingModel
{
Value = 200,
};
var sub = target.Bind(property, binding);
var target = new Binding(nameof(Class1.Foo));
var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: false);
var subject = (BindingExpression)instanced.Source;
object result = null;
Assert.Equal(200, target.GetValue(property));
Assert.IsType<DataValidationException>(target.DataValidationError);
subject.Subscribe(x => result = x);
sub.Dispose();
Assert.IsType<string>(result);
Assert.Null(target.DataValidationError);
}
private protected abstract (DataValidationTestControl, T) CreateTarget();
}
[Fact]
public void Initiate_Should_Enable_Data_Validation_With_BindingPriority_LocalValue()
public class DirectPropertyTests : TestBase<DirectPropertyBase<int>>
{
var textBlock = new TextBlock
private protected override (DataValidationTestControl, DirectPropertyBase<int>) CreateTarget()
{
DataContext = new Class1(),
};
return (new ValidatedDirectPropertyClass(), ValidatedDirectPropertyClass.ValueProperty);
}
}
var target = new Binding(nameof(Class1.Foo));
var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: true);
var subject = (BindingExpression)instanced.Source;
object result = null;
public class StyledPropertyTests : TestBase<StyledProperty<int>>
{
[Fact]
public void Style_Binding_Supports_Data_Validation()
{
var (target, property) = CreateTarget();
var binding = new Binding(nameof(IndeiValidatingModel.Value))
{
Mode = BindingMode.TwoWay
};
var model = new IndeiValidatingModel();
var root = new TestRoot
{
DataContext = model,
Styles =
{
new Style(x => x.Is<DataValidationTestControl>())
{
Setters =
{
new Setter(property, binding)
}
}
},
Child = target,
};
root.LayoutManager.ExecuteInitialLayoutPass();
Assert.Equal(20, target.GetValue(property));
subject.Subscribe(x => result = x);
model.Value = 200;
Assert.Equal(200, target.GetValue(property));
Assert.IsType<DataValidationException>(target.DataValidationError);
Assert.Equal("Invalid value: 200.", target.DataValidationError?.Message);
model.Value = 10;
Assert.Equal(10, target.GetValue(property));
Assert.Null(target.DataValidationError);
}
[Fact]
public void Style_With_Activator_Binding_Supports_Data_Validation()
{
var (target, property) = CreateTarget();
var binding = new Binding(nameof(IndeiValidatingModel.Value))
{
Mode = BindingMode.TwoWay
};
Assert.Equal(new BindingNotification("foo"), result);
var model = new IndeiValidatingModel
{
Value = 200,
};
var root = new TestRoot
{
DataContext = model,
Styles =
{
new Style(x => x.Is<DataValidationTestControl>().Class("foo"))
{
Setters =
{
new Setter(property, binding)
}
}
},
Child = target,
};
root.LayoutManager.ExecuteInitialLayoutPass();
target.Classes.Add("foo");
Assert.Equal(200, target.GetValue(property));
Assert.IsType<DataValidationException>(target.DataValidationError);
Assert.Equal("Invalid value: 200.", target.DataValidationError?.Message);
target.Classes.Remove("foo");
Assert.Equal(0, target.GetValue(property));
Assert.Null(target.DataValidationError);
target.Classes.Add("foo");
Assert.IsType<DataValidationException>(target.DataValidationError);
Assert.Equal("Invalid value: 200.", target.DataValidationError?.Message);
model.Value = 10;
Assert.Equal(10, target.GetValue(property));
Assert.Null(target.DataValidationError);
}
[Fact]
public void Data_Validation_Can_Switch_Between_Style_And_LocalValue_Binding()
{
var (target, property) = CreateTarget();
var model1 = new IndeiValidatingModel { Value = 200 };
var model2 = new IndeiValidatingModel { Value = 300 };
var binding1 = new Binding(nameof(IndeiValidatingModel.Value));
var binding2 = new Binding(nameof(IndeiValidatingModel.Value)) { Source = model2 };
var root = new TestRoot
{
DataContext = model1,
Styles =
{
new Style(x => x.Is<DataValidationTestControl>())
{
Setters =
{
new Setter(property, binding1)
}
}
},
Child = target,
};
root.LayoutManager.ExecuteInitialLayoutPass();
Assert.Equal(200, target.GetValue(property));
Assert.IsType<DataValidationException>(target.DataValidationError);
Assert.Equal("Invalid value: 200.", target.DataValidationError?.Message);
var sub = target.Bind(property, binding2);
Assert.Equal(300, target.GetValue(property));
Assert.Equal("Invalid value: 300.", target.DataValidationError?.Message);
sub.Dispose();
Assert.Equal(200, target.GetValue(property));
Assert.IsType<DataValidationException>(target.DataValidationError);
Assert.Equal("Invalid value: 200.", target.DataValidationError?.Message);
}
[Fact]
public void Data_Validation_Can_Switch_Between_Style_And_StyleTrigger_Binding()
{
var (target, property) = CreateTarget();
var model1 = new IndeiValidatingModel { Value = 200 };
var model2 = new IndeiValidatingModel { Value = 300 };
var binding1 = new Binding(nameof(IndeiValidatingModel.Value));
var binding2 = new Binding(nameof(IndeiValidatingModel.Value)) { Source = model2 };
var root = new TestRoot
{
DataContext = model1,
Styles =
{
new Style(x => x.Is<DataValidationTestControl>())
{
Setters =
{
new Setter(property, binding1)
}
},
new Style(x => x.Is<DataValidationTestControl>().Class("foo"))
{
Setters =
{
new Setter(property, binding2)
}
},
},
Child = target,
};
root.LayoutManager.ExecuteInitialLayoutPass();
Assert.Equal(200, target.GetValue(property));
Assert.IsType<DataValidationException>(target.DataValidationError);
Assert.Equal("Invalid value: 200.", target.DataValidationError?.Message);
target.Classes.Add("foo");
Assert.Equal(300, target.GetValue(property));
Assert.Equal("Invalid value: 300.", target.DataValidationError?.Message);
target.Classes.Remove("foo");
Assert.Equal(200, target.GetValue(property));
Assert.IsType<DataValidationException>(target.DataValidationError);
Assert.Equal("Invalid value: 200.", target.DataValidationError?.Message);
}
private protected override (DataValidationTestControl, StyledProperty<int>) CreateTarget()
{
return (new ValidatedStyledPropertyClass(), ValidatedStyledPropertyClass.ValueProperty);
}
}
internal class DataValidationTestControl : Control
{
public Exception? DataValidationError { get; protected set; }
}
private class ValidatedStyledPropertyClass : DataValidationTestControl
{
public static readonly StyledProperty<int> ValueProperty =
AvaloniaProperty.Register<ValidatedStyledPropertyClass, int>(
"Value",
enableDataValidation: true);
public int Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
protected override void UpdateDataValidation(AvaloniaProperty property, BindingValueType state, Exception? error)
{
if (property == ValueProperty)
{
DataValidationError = state.HasAnyFlag(BindingValueType.DataValidationError) ? error : null;
}
}
}
[Fact]
public void Initiate_Should_Not_Enable_Data_Validation_With_BindingPriority_TemplatedParent()
private class ValidatedDirectPropertyClass : DataValidationTestControl
{
var textBlock = new TextBlock
public static readonly DirectProperty<ValidatedDirectPropertyClass, int> ValueProperty =
AvaloniaProperty.RegisterDirect<ValidatedDirectPropertyClass, int>(
"Value",
o => o.Value,
(o, v) => o.Value = v,
enableDataValidation: true);
private int _value;
public int Value
{
DataContext = new Class1(),
};
get => _value;
set => SetAndRaise(ValueProperty, ref _value, value);
}
var target = new Binding(nameof(Class1.Foo)) { Priority = BindingPriority.Template };
var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: true);
var subject = (BindingExpression)instanced.Source;
object result = null;
protected override void UpdateDataValidation(AvaloniaProperty property, BindingValueType state, Exception? error)
{
if (property == ValueProperty)
{
DataValidationError = state.HasAnyFlag(BindingValueType.DataValidationError) ? error : null;
}
}
}
subject.Subscribe(x => result = x);
private class ExceptionValidatingModel
{
public const int MaxValue = 100;
private int _value = 20;
Assert.IsType<string>(result);
public int Value
{
get => _value;
set
{
if (value > MaxValue)
throw new ArgumentOutOfRangeException(nameof(value));
_value = value;
}
}
}
private class Class1
private class IndeiValidatingModel : INotifyDataErrorInfo
{
public string Foo { get; set; } = "foo";
public const int MaxValue = 100;
private bool _hasErrors;
private int _value = 20;
public int Value
{
get => _value;
set
{
_value = value;
HasErrors = value > MaxValue;
}
}
public bool HasErrors
{
get => _hasErrors;
private set
{
if (_hasErrors != value)
{
_hasErrors = value;
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
}
}
}
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
public IEnumerable GetErrors(string? propertyName)
{
if (propertyName == nameof(Value) && _value > MaxValue)
yield return $"Invalid value: {_value}.";
}
}
}
}

Loading…
Cancel
Save