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 4 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> /// <summary>
/// Gets a description of an observable that van be used in logs. /// Gets a description of an observable that van be used in logs.
/// </summary> /// </summary>

4
src/Avalonia.Base/AvaloniaObjectExtensions.cs

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

18
src/Avalonia.Base/AvaloniaPropertyMetadata.cs

@ -13,10 +13,13 @@ namespace Avalonia
/// Initializes a new instance of the <see cref="AvaloniaPropertyMetadata"/> class. /// Initializes a new instance of the <see cref="AvaloniaPropertyMetadata"/> class.
/// </summary> /// </summary>
/// <param name="defaultBindingMode">The default binding mode.</param> /// <param name="defaultBindingMode">The default binding mode.</param>
/// <param name="enableDataValidation">Whether the property is interested in data validation.</param>
public AvaloniaPropertyMetadata( public AvaloniaPropertyMetadata(
BindingMode defaultBindingMode = BindingMode.Default) BindingMode defaultBindingMode = BindingMode.Default,
bool? enableDataValidation = null)
{ {
_defaultBindingMode = defaultBindingMode; _defaultBindingMode = defaultBindingMode;
EnableDataValidation = enableDataValidation;
} }
/// <summary> /// <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> /// <summary>
/// Merges the metadata with the base metadata. /// Merges the metadata with the base metadata.
/// </summary> /// </summary>
@ -44,6 +58,8 @@ namespace Avalonia
{ {
_defaultBindingMode = baseMetadata.DefaultBindingMode; _defaultBindingMode = baseMetadata.DefaultBindingMode;
} }
EnableDataValidation ??= baseMetadata.EnableDataValidation;
} }
} }
} }

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

@ -21,10 +21,9 @@ namespace Avalonia
TValue unsetValue = default!, TValue unsetValue = default!,
BindingMode defaultBindingMode = BindingMode.Default, BindingMode defaultBindingMode = BindingMode.Default,
bool? enableDataValidation = null) bool? enableDataValidation = null)
: base(defaultBindingMode) : base(defaultBindingMode, enableDataValidation)
{ {
UnsetValue = unsetValue; UnsetValue = unsetValue;
EnableDataValidation = enableDataValidation;
} }
/// <summary> /// <summary>
@ -32,16 +31,6 @@ namespace Avalonia
/// </summary> /// </summary>
public TValue UnsetValue { get; private set; } 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/> /// <inheritdoc/>
object? IDirectPropertyMetadata.UnsetValue => UnsetValue; object? IDirectPropertyMetadata.UnsetValue => UnsetValue;
@ -51,19 +40,9 @@ namespace Avalonia
{ {
base.Merge(baseMetadata, property); base.Merge(baseMetadata, property);
var src = baseMetadata as DirectPropertyMetadata<TValue>; if (baseMetadata is DirectPropertyMetadata<TValue> src)
if (src != null)
{ {
if (UnsetValue == null) UnsetValue ??= src.UnsetValue;
{
UnsetValue = src.UnsetValue;
}
if (EnableDataValidation == null)
{
EnableDataValidation = src.EnableDataValidation;
}
} }
} }
} }

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

@ -16,27 +16,37 @@ namespace Avalonia.PropertyStore
private IDisposable? _subscription; private IDisposable? _subscription;
private bool _hasValue; private bool _hasValue;
private TValue? _value; private TValue? _value;
private TValue? _defaultValue; private UncommonFields? _uncommon;
private bool _isDefaultValueInitialized;
protected BindingEntryBase( protected BindingEntryBase(
AvaloniaObject target,
ValueFrame frame, ValueFrame frame,
AvaloniaProperty property, AvaloniaProperty property,
IObservable<BindingValue<TSource>> source) IObservable<BindingValue<TSource>> source)
: this(target, frame, property, (object)source)
{ {
Frame = frame;
Source = source;
Property = property;
} }
protected BindingEntryBase( protected BindingEntryBase(
AvaloniaObject target,
ValueFrame frame, ValueFrame frame,
AvaloniaProperty property, AvaloniaProperty property,
IObservable<TSource> source) IObservable<TSource> source)
: this(target, frame, property, (object)source)
{
}
private BindingEntryBase(
AvaloniaObject target,
ValueFrame frame,
AvaloniaProperty property,
object source)
{ {
Frame = frame; Frame = frame;
Source = source;
Property = property; Property = property;
Source = source;
if (property.GetMetadata(target.GetType()).EnableDataValidation == true)
_uncommon = new() { _hasDataValidation = true };
} }
public bool HasValue public bool HasValue
@ -68,6 +78,20 @@ namespace Avalonia.PropertyStore
return _value!; 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 Start() => Start(true);
public void OnCompleted() => BindingCompleted(); public void OnCompleted() => BindingCompleted();
@ -111,16 +135,28 @@ namespace Avalonia.PropertyStore
{ {
static void Execute(BindingEntryBase<TValue, TSource> instance, BindingValue<TValue> value) static void Execute(BindingEntryBase<TValue, TSource> instance, BindingValue<TValue> value)
{ {
if (instance.Frame.Owner is null) if (instance.Frame.Owner is not { } valueStore)
return; 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; instance._hasValue = true;
if (instance._subscription is not null && instance._subscription != s_creatingQuiet) if (instance._subscription is not null && instance._subscription != s_creatingQuiet)
instance.Frame.Owner?.OnBindingValueChanged(instance, instance.Frame.Priority); instance.Frame.Owner?.OnBindingValueChanged(instance, instance.Frame.Priority);
@ -152,13 +188,23 @@ namespace Avalonia.PropertyStore
private TValue GetCachedDefaultValue() private TValue GetCachedDefaultValue()
{ {
if (!_isDefaultValueInitialized) if (_uncommon?._isDefaultValueInitialized != true)
{ {
_defaultValue = GetDefaultValue(Frame.Owner!.Owner.GetType()); _uncommon ??= new();
_isDefaultValueInitialized = true; _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 IDisposable
{ {
private readonly ValueStore _owner; private readonly ValueStore _owner;
private readonly bool _hasDataValidation;
private IDisposable? _subscription; private IDisposable? _subscription;
public DirectBindingObserver(ValueStore owner, DirectPropertyBase<T> property) public DirectBindingObserver(ValueStore owner, DirectPropertyBase<T> property)
{ {
_owner = owner; _owner = owner;
_hasDataValidation = property.GetMetadata(owner.Owner.GetType())?.EnableDataValidation ?? false;
Property = property; Property = property;
} }
@ -33,10 +35,17 @@ namespace Avalonia.PropertyStore
{ {
_subscription?.Dispose(); _subscription?.Dispose();
_subscription = null; _subscription = null;
OnCompleted();
}
public void OnCompleted()
{
_owner.OnLocalValueBindingCompleted(Property, this); _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 OnError(Exception error) => OnCompleted();
public void OnNext(T value) public void OnNext(T value)

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

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

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

@ -11,9 +11,6 @@ namespace Avalonia.PropertyStore
/// </remarks> /// </remarks>
internal abstract class EffectiveValue internal abstract class EffectiveValue
{ {
private IValueEntry? _valueEntry;
private IValueEntry? _baseValueEntry;
/// <summary> /// <summary>
/// Gets the current effective value as a boxed value. /// Gets the current effective value as a boxed value.
/// </summary> /// </summary>
@ -29,6 +26,16 @@ namespace Avalonia.PropertyStore
/// </summary> /// </summary>
public BindingPriority BasePriority { get; protected set; } 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> /// <summary>
/// Gets a value indicating whether the <see cref="Value"/> was overridden by a call to /// Gets a value indicating whether the <see cref="Value"/> was overridden by a call to
/// <see cref="AvaloniaObject.SetCurrentValue{T}"/>. /// <see cref="AvaloniaObject.SetCurrentValue{T}"/>.
@ -63,14 +70,14 @@ namespace Avalonia.PropertyStore
{ {
if (Priority == BindingPriority.Unset) if (Priority == BindingPriority.Unset)
{ {
_valueEntry?.Unsubscribe(); ValueEntry?.Unsubscribe();
_valueEntry = null; ValueEntry = null;
} }
if (BasePriority == BindingPriority.Unset) if (BasePriority == BindingPriority.Unset)
{ {
_baseValueEntry?.Unsubscribe(); BaseValueEntry?.Unsubscribe();
_baseValueEntry = null; BaseValueEntry = null;
} }
} }
@ -135,40 +142,34 @@ namespace Avalonia.PropertyStore
// value, then the current entry becomes our base entry. // value, then the current entry becomes our base entry.
if (Priority > BindingPriority.LocalValue && Priority < BindingPriority.Inherited) if (Priority > BindingPriority.LocalValue && Priority < BindingPriority.Inherited)
{ {
Debug.Assert(_valueEntry is not null); Debug.Assert(ValueEntry is not null);
_baseValueEntry = _valueEntry; BaseValueEntry = ValueEntry;
_valueEntry = null; ValueEntry = null;
} }
if (_valueEntry != entry) if (ValueEntry != entry)
{ {
_valueEntry?.Unsubscribe(); ValueEntry?.Unsubscribe();
_valueEntry = entry; ValueEntry = entry;
} }
} }
else if (Priority <= BindingPriority.Animation) else if (Priority <= BindingPriority.Animation)
{ {
// We've received a non-animation value and have an active animation value, so the // We've received a non-animation value and have an active animation value, so the
// new entry becomes our base entry. // new entry becomes our base entry.
if (_baseValueEntry != entry) if (BaseValueEntry != entry)
{ {
_baseValueEntry?.Unsubscribe(); BaseValueEntry?.Unsubscribe();
_baseValueEntry = entry; 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 // Both the current value and the new value are non-animation values, so the new
// entry replaces the existing entry. // entry replaces the existing entry.
_valueEntry?.Unsubscribe(); ValueEntry?.Unsubscribe();
_valueEntry = entry; 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;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Avalonia.Data; using Avalonia.Data;
using static Avalonia.Rendering.Composition.Animations.PropertySetSnapshot;
namespace Avalonia.PropertyStore namespace Avalonia.PropertyStore
{ {
@ -61,6 +62,12 @@ namespace Avalonia.PropertyStore
UpdateValueEntry(value, priority); UpdateValueEntry(value, priority);
SetAndRaiseCore(owner, (StyledProperty<T>)value.Property, GetValue(value), priority, false); 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( public void SetLocalValueAndRaise(
@ -128,12 +135,10 @@ namespace Avalonia.PropertyStore
public override void DisposeAndRaiseUnset(ValueStore owner, AvaloniaProperty property) public override void DisposeAndRaiseUnset(ValueStore owner, AvaloniaProperty property)
{ {
UnsubscribeValueEntries(); ValueEntry?.Unsubscribe();
DisposeAndRaiseUnset(owner, (StyledProperty<T>)property); BaseValueEntry?.Unsubscribe();
}
public void DisposeAndRaiseUnset(ValueStore owner, StyledProperty<T> property) var p = (StyledProperty<T>)property;
{
BindingPriority priority; BindingPriority priority;
T oldValue; T oldValue;
@ -150,9 +155,16 @@ namespace Avalonia.PropertyStore
if (!EqualityComparer<T>.Default.Equals(oldValue, Value)) 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) 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 System;
using Avalonia.Data;
namespace Avalonia.PropertyStore namespace Avalonia.PropertyStore
{ {
@ -22,6 +23,16 @@ namespace Avalonia.PropertyStore
/// </exception> /// </exception>
object? GetValue(); 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> /// <summary>
/// Called when the value entry is removed from the value store. /// Called when the value entry is removed from the value store.
/// </summary> /// </summary>

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

@ -1,4 +1,5 @@
using System; using System;
using Avalonia.Data;
namespace Avalonia.PropertyStore namespace Avalonia.PropertyStore
{ {
@ -27,5 +28,12 @@ namespace Avalonia.PropertyStore
object? IValueEntry.GetValue() => _value; object? IValueEntry.GetValue() => _value;
T IValueEntry<T>.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, StyledProperty<T> property,
IObservable<BindingValue<T>> source) IObservable<BindingValue<T>> source)
{ {
var e = new TypedBindingEntry<T>(this, property, source); var e = new TypedBindingEntry<T>(Owner!.Owner, this, property, source);
Add(e); Add(e);
return e; return e;
} }
@ -27,7 +27,7 @@ namespace Avalonia.PropertyStore
StyledProperty<T> property, StyledProperty<T> property,
IObservable<T> source) IObservable<T> source)
{ {
var e = new TypedBindingEntry<T>(this, property, source); var e = new TypedBindingEntry<T>(Owner!.Owner, this, property, source);
Add(e); Add(e);
return e; return e;
} }
@ -36,7 +36,7 @@ namespace Avalonia.PropertyStore
StyledProperty<T> property, StyledProperty<T> property,
IObservable<object?> source) IObservable<object?> source)
{ {
var e = new SourceUntypedBindingEntry<T>(this, property, source); var e = new SourceUntypedBindingEntry<T>(Owner!.Owner, this, property, source);
Add(e); Add(e);
return e; return e;
} }

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

@ -1,121 +1,25 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Data; using Avalonia.Data;
using Avalonia.Threading;
namespace Avalonia.PropertyStore namespace Avalonia.PropertyStore
{ {
internal class LocalValueBindingObserver<T> : IObserver<T>, internal class LocalValueBindingObserver<T> : LocalValueBindingObserverBase<T>,
IObserver<BindingValue<T>>, IObserver<object?>
IDisposable
{ {
private readonly ValueStore _owner;
private IDisposable? _subscription;
private T? _defaultValue;
private bool _isDefaultValueInitialized;
public LocalValueBindingObserver(ValueStore owner, StyledProperty<T> property) public LocalValueBindingObserver(ValueStore owner, StyledProperty<T> property)
: base(owner, property)
{ {
_owner = owner;
Property = property;
} }
public StyledProperty<T> Property { get;} public void Start(IObservable<object?> source) => _subscription = source.Subscribe(this);
public void Start(IObservable<T> source)
{
_subscription = source.Subscribe(this);
}
public void Start(IObservable<BindingValue<T>> source)
{
_subscription = source.Subscribe(this);
}
public void Dispose() [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = TrimmingMessages.ImplicitTypeConvertionSupressWarningMessage)]
public void OnNext(object? value)
{ {
_subscription?.Dispose(); if (value == BindingOperations.DoNothing)
_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)
return; return;
base.OnNext(BindingValue<T>.FromUntyped(value, Property.PropertyType));
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!;
} }
} }
} }

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; private readonly Func<TTarget, bool>? _validate;
public SourceUntypedBindingEntry( public SourceUntypedBindingEntry(
AvaloniaObject target,
ValueFrame frame, ValueFrame frame,
StyledProperty<TTarget> property, StyledProperty<TTarget> property,
IObservable<object?> source) IObservable<object?> source)
: base(frame, property, source) : base(target, frame, property, source)
{ {
_validate = property.ValidateValue; _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> internal sealed class TypedBindingEntry<T> : BindingEntryBase<T, T>
{ {
public TypedBindingEntry( public TypedBindingEntry(
AvaloniaObject target,
ValueFrame frame, ValueFrame frame,
StyledProperty<T> property, StyledProperty<T> property,
IObservable<T> source) IObservable<T> source)
: base(frame, property, source) : base(target, frame, property, source)
{ {
} }
public TypedBindingEntry( public TypedBindingEntry(
AvaloniaObject target,
ValueFrame frame, ValueFrame frame,
StyledProperty<T> property, StyledProperty<T> property,
IObservable<BindingValue<T>> source) 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; private readonly Func<object?, bool>? _validate;
public UntypedBindingEntry( public UntypedBindingEntry(
AvaloniaObject target,
ValueFrame frame, ValueFrame frame,
AvaloniaProperty property, AvaloniaProperty property,
IObservable<object?> source) IObservable<object?> source)
: base(frame, property, source) : base(target, frame, property, source)
{ {
_validate = ((IStyledPropertyAccessor)property).ValidateValue; _validate = ((IStyledPropertyAccessor)property).ValidateValue;
} }

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

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

1
src/Avalonia.Base/StyledElement.cs

@ -46,6 +46,7 @@ namespace Avalonia
defaultBindingMode: BindingMode.OneWay, defaultBindingMode: BindingMode.OneWay,
validate: null, validate: null,
coerce: null, coerce: null,
enableDataValidation: false,
notifying: DataContextNotifying); notifying: DataContextNotifying);
/// <summary> /// <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="defaultValue">The default value of the property.</param>
/// <param name="defaultBindingMode">The default binding mode.</param> /// <param name="defaultBindingMode">The default binding mode.</param>
/// <param name="coerce">A value coercion callback.</param> /// <param name="coerce">A value coercion callback.</param>
/// <param name="enableDataValidation">Whether the property is interested in data validation.</param>
public StyledPropertyMetadata( public StyledPropertyMetadata(
Optional<TValue> defaultValue = default, Optional<TValue> defaultValue = default,
BindingMode defaultBindingMode = BindingMode.Default, BindingMode defaultBindingMode = BindingMode.Default,
Func<AvaloniaObject, TValue, TValue>? coerce = null) Func<AvaloniaObject, TValue, TValue>? coerce = null,
: base(defaultBindingMode) bool enableDataValidation = false)
: base(defaultBindingMode, enableDataValidation)
{ {
_defaultValue = defaultValue; _defaultValue = defaultValue;
CoerceValue = coerce; CoerceValue = coerce;

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

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

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

@ -1,4 +1,5 @@
using System; using System;
using Avalonia.Data;
using Avalonia.PropertyStore; using Avalonia.PropertyStore;
namespace Avalonia.Styling namespace Avalonia.Styling
@ -19,6 +20,13 @@ namespace Avalonia.Styling
public object? GetValue() => _value ??= _template.Build(); 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() { } void IValueEntry.Unsubscribe() { }
} }
} }

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

@ -90,6 +90,13 @@ namespace Avalonia.Styling
object? IValueEntry.GetValue() => Value; object? IValueEntry.GetValue() => Value;
bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error)
{
state = BindingValueType.Value;
error = null;
return false;
}
private AvaloniaProperty EnsureProperty() private AvaloniaProperty EnsureProperty()
{ {
return Property ?? throw new InvalidOperationException("Setter.Property must be set."); return Property ?? throw new InvalidOperationException("Setter.Property must be set.");
@ -99,7 +106,8 @@ namespace Avalonia.Styling
{ {
if (!Property!.IsDirect) 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; var mode = i.Mode;
if (mode == BindingMode.Default) 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 target = new Class1();
var source = new Subject<object?>(); var source = new Subject<object?>();
var called = false; 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) => LogCallback checkLogMessage = (level, area, src, mt, pv) =>
{ {
@ -898,9 +899,7 @@ namespace Avalonia.Base.UnitTests
src == target && src == target &&
pv[0].GetType() == typeof(Class1) && pv[0].GetType() == typeof(Class1) &&
(AvaloniaProperty)pv[1] == Class1.QuxProperty && (AvaloniaProperty)pv[1] == Class1.QuxProperty &&
(Type)pv[2] == typeof(double) && (string)pv[2] == message)
(string)pv[3] == "foo" &&
(Type)pv[4] == typeof(string))
{ {
called = true; called = true;
} }

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

@ -1,115 +1,212 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Reactive.Subjects; using System.Reactive.Subjects;
using Avalonia.Data; using Avalonia.Data;
using Avalonia.UnitTests; using Avalonia.UnitTests;
using Xunit; using Xunit;
#nullable enable
namespace Avalonia.Base.UnitTests namespace Avalonia.Base.UnitTests
{ {
public class AvaloniaObjectTests_DataValidation public class AvaloniaObjectTests_DataValidation
{ {
[Fact] public abstract class TestBase<T>
public void Binding_Non_Validated_Styled_Property_Does_Not_Call_UpdateDataValidation() where T : AvaloniaProperty<int>
{ {
var target = new Class1(); [Fact]
var source = new Subject<BindingValue<int>>(); 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); target.Bind(property, source);
source.OnNext(6); source.OnNext(6);
source.OnNext(BindingValue<int>.BindingError(new Exception())); source.OnNext(BindingValue<int>.BindingError(new Exception()));
source.OnNext(BindingValue<int>.DataValidationError(new Exception())); source.OnNext(BindingValue<int>.DataValidationError(new Exception()));
source.OnNext(6); source.OnNext(6);
Assert.Empty(target.Notifications); Assert.Empty(target.Notifications);
} }
[Fact] [Fact]
public void Binding_Non_Validated_Direct_Property_Does_Not_Call_UpdateDataValidation() public void Binding_Validated_Property_Calls_UpdateDataValidation()
{ {
var target = new Class1(); var target = new Class1();
var source = new Subject<BindingValue<int>>(); var source = new Subject<BindingValue<int>>();
var property = GetProperty();
var error1 = new Exception();
var error2 = new Exception();
target.Bind(Class1.NonValidatedDirectProperty, source); target.Bind(property, source);
source.OnNext(6); source.OnNext(6);
source.OnNext(BindingValue<int>.BindingError(new Exception())); source.OnNext(BindingValue<int>.DataValidationError(error1));
source.OnNext(BindingValue<int>.DataValidationError(new Exception())); source.OnNext(BindingValue<int>.BindingError(error2));
source.OnNext(6); 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] [Fact]
public void Binding_Validated_Direct_Property_Calls_UpdateDataValidation() public void Binding_Validated_Property_Calls_UpdateDataValidation_Untyped()
{ {
var target = new Class1(); var target = new Class1();
var source = new Subject<BindingValue<int>>(); var source = new Subject<object>();
var property = GetProperty();
target.Bind(Class1.ValidatedDirectIntProperty, source); var error1 = new Exception();
source.OnNext(6); var error2 = new Exception();
source.OnNext(BindingValue<int>.BindingError(new Exception()));
source.OnNext(BindingValue<int>.DataValidationError(new Exception())); target.Bind(property, source);
source.OnNext(7); source.OnNext(6);
source.OnNext(new BindingNotification(error1, BindingErrorType.DataValidationError));
var result = target.Notifications; source.OnNext(new BindingNotification(error2, BindingErrorType.Error));
Assert.Equal(4, result.Count); source.OnNext(7);
Assert.Equal(BindingValueType.Value, result[0].type);
Assert.Equal(6, result[0].value); Assert.Equal(new Notification[]
Assert.Equal(BindingValueType.BindingError, result[1].type); {
Assert.Equal(BindingValueType.DataValidationError, result[2].type); new(BindingValueType.Value, 6, null),
Assert.Equal(BindingValueType.Value, result[3].type); new(BindingValueType.DataValidationError, 6, error1),
Assert.Equal(7, result[3].value); 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 class DirectPropertyTests : TestBase<DirectPropertyBase<int>>
public void Binding_Overridden_Validated_Direct_Property_Calls_UpdateDataValidation()
{ {
var target = new Class2(); [Fact]
var source = new Subject<BindingValue<int>>(); 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. var target = new Class1
target.Bind(Class1.NonValidatedDirectProperty, source); {
source.OnNext(1); [!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 class StyledPropertyTests : TestBase<StyledProperty<int>>
public void Bound_Validated_Direct_String_Property_Can_Be_Set_To_Null()
{ {
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 var target = new Class1
{
[!Class1.ValidatedDirectStringProperty] = new Binding
{ {
Path = nameof(ViewModel.StringValue), [!Class1.ValidatedDirectStringProperty] = new Binding
Source = source, {
}, 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 private class Class1 : AvaloniaObject
{ {
public static readonly StyledProperty<int> NonValidatedProperty = public static readonly DirectProperty<Class1, int> NonValidatedDirectIntProperty =
AvaloniaProperty.Register<Class1, int>(
nameof(NonValidated));
public static readonly DirectProperty<Class1, int> NonValidatedDirectProperty =
AvaloniaProperty.RegisterDirect<Class1, int>( AvaloniaProperty.RegisterDirect<Class1, int>(
nameof(NonValidatedDirect), nameof(NonValidatedDirectInt),
o => o.NonValidatedDirect, o => o.NonValidatedDirectInt,
(o, v) => o.NonValidatedDirect = v); (o, v) => o.NonValidatedDirectInt = v);
public static readonly DirectProperty<Class1, int> ValidatedDirectIntProperty = public static readonly DirectProperty<Class1, int> ValidatedDirectIntProperty =
AvaloniaProperty.RegisterDirect<Class1, int>( AvaloniaProperty.RegisterDirect<Class1, int>(
@ -118,27 +215,30 @@ namespace Avalonia.Base.UnitTests
(o, v) => o.ValidatedDirectInt = v, (o, v) => o.ValidatedDirectInt = v,
enableDataValidation: true); enableDataValidation: true);
public static readonly DirectProperty<Class1, string> ValidatedDirectStringProperty = public static readonly DirectProperty<Class1, string?> ValidatedDirectStringProperty =
AvaloniaProperty.RegisterDirect<Class1, string>( AvaloniaProperty.RegisterDirect<Class1, string?>(
nameof(ValidatedDirectString), nameof(ValidatedDirectString),
o => o.ValidatedDirectString, o => o.ValidatedDirectString,
(o, v) => o.ValidatedDirectString = v, (o, v) => o.ValidatedDirectString = v,
enableDataValidation: true); 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 _nonValidatedDirect;
private int _directInt; private int _directInt;
private string _directString; private string? _directString;
public int NonValidated public int NonValidatedDirectInt
{
get { return GetValue(NonValidatedProperty); }
set { SetValue(NonValidatedProperty, value); }
}
public int NonValidatedDirect
{ {
get { return _directInt; } get { return _directInt; }
set { SetAndRaise(NonValidatedDirectProperty, ref _nonValidatedDirect, value); } set { SetAndRaise(NonValidatedDirectIntProperty, ref _nonValidatedDirect, value); }
} }
public int ValidatedDirectInt public int ValidatedDirectInt
@ -147,20 +247,32 @@ namespace Avalonia.Base.UnitTests
set { SetAndRaise(ValidatedDirectIntProperty, ref _directInt, value); } set { SetAndRaise(ValidatedDirectIntProperty, ref _directInt, value); }
} }
public string ValidatedDirectString public string? ValidatedDirectString
{ {
get { return _directString; } get { return _directString; }
set { SetAndRaise(ValidatedDirectStringProperty, ref _directString, value); } 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( protected override void UpdateDataValidation(
AvaloniaProperty property, AvaloniaProperty property,
BindingValueType state, 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() static Class2()
{ {
NonValidatedDirectProperty.OverrideMetadata<Class2>( NonValidatedDirectIntProperty.OverrideMetadata<Class2>(
new DirectPropertyMetadata<int>(enableDataValidation: true)); new DirectPropertyMetadata<int>(enableDataValidation: true));
NonValidatedStyledIntProperty.OverrideMetadata<Class2>(
new StyledPropertyMetadata<int>(enableDataValidation: true));
} }
} }
public class ViewModel : NotifyingBase public class ViewModel : NotifyingBase
{ {
private string _stringValue; private string? _stringValue;
public string StringValue public string? StringValue
{ {
get { return _stringValue; } get { return _stringValue; }
set { _stringValue = value; RaisePropertyChanged(); } set { _stringValue = value; RaisePropertyChanged(); }

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

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

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

@ -1,72 +1,404 @@
using System; using System;
using System.Reactive.Linq; using System.Collections;
using System.ComponentModel;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Data; using Avalonia.Data;
using Avalonia.Data.Core; using Avalonia.Styling;
using Avalonia.Markup.Data; using Avalonia.UnitTests;
using Xunit; using Xunit;
#nullable enable
namespace Avalonia.Markup.UnitTests.Data namespace Avalonia.Markup.UnitTests.Data
{ {
public class BindingTests_DataValidation public class BindingTests_DataValidation
{ {
[Fact] public abstract class TestBase<T>
public void Initiate_Should_Not_Enable_Data_Validation_With_BindingPriority_LocalValue() 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)); Assert.Equal(200, target.GetValue(property));
var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: false); Assert.IsType<DataValidationException>(target.DataValidationError);
var subject = (BindingExpression)instanced.Source;
object result = null;
subject.Subscribe(x => result = x); sub.Dispose();
Assert.IsType<string>(result); Assert.Null(target.DataValidationError);
}
private protected abstract (DataValidationTestControl, T) CreateTarget();
} }
[Fact] public class DirectPropertyTests : TestBase<DirectPropertyBase<int>>
public void Initiate_Should_Enable_Data_Validation_With_BindingPriority_LocalValue()
{ {
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)); public class StyledPropertyTests : TestBase<StyledProperty<int>>
var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: true); {
var subject = (BindingExpression)instanced.Source; [Fact]
object result = null; 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] private class ValidatedDirectPropertyClass : DataValidationTestControl
public void Initiate_Should_Not_Enable_Data_Validation_With_BindingPriority_TemplatedParent()
{ {
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 }; protected override void UpdateDataValidation(AvaloniaProperty property, BindingValueType state, Exception? error)
var instanced = target.Initiate(textBlock, TextBlock.TextProperty, enableDataValidation: true); {
var subject = (BindingExpression)instanced.Source; if (property == ValueProperty)
object result = null; {
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