Browse Source

Initial implementation of UpdateSourceTrigger.

refactor/bindingexpressions-in-valuestore
Steven Kirk 3 years ago
parent
commit
bee98ad947
  1. 15
      src/Avalonia.Base/Data/BindingExpressionBase.cs
  2. 50
      src/Avalonia.Base/Data/Core/BindingExpression.cs
  3. 29
      src/Avalonia.Base/Data/UpdateSourceTrigger.cs
  4. 9
      src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs
  5. 7
      src/Markup/Avalonia.Markup/Data/Binding.cs
  6. 28
      src/Markup/Avalonia.Markup/Data/BindingBase.cs
  7. 150
      tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests_UpdateSourceTrigger.cs

15
src/Avalonia.Base/Data/BindingExpressionBase.cs

@ -17,6 +17,21 @@ public abstract class BindingExpressionBase : IDisposable, ISetterInstance
GC.SuppressFinalize(this);
}
/// <summary>
/// Sends the current binding target value to the binding source property in
/// <see cref="BindingMode.TwoWay"/> or <see cref="BindingMode.OneWayToSource"/> bindings.
/// </summary>
/// <remarks>
/// This method does nothing when the Mode of the binding is not
/// <see cref="BindingMode.TwoWay"/> or <see cref="BindingMode.OneWayToSource"/>.
///
/// If the UpdateSourceTrigger value of your binding is set to
/// <see cref="UpdateSourceTrigger.Explicit"/>, you must call the
/// <see cref="UpdateSource"/> method or the changes will not propagate back to the
/// source.
/// </remarks>
public virtual void UpdateSource() { }
/// <summary>
/// Forces a data transfer from the binding source to the binding target.
/// </summary>

50
src/Avalonia.Base/Data/Core/BindingExpression.cs

@ -8,6 +8,8 @@ using System.Text;
using Avalonia.Data.Converters;
using Avalonia.Data.Core.ExpressionNodes;
using Avalonia.Data.Core.Parsers;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Logging;
using Avalonia.Utilities;
@ -49,6 +51,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
/// <param name="targetTypeConverter">
/// A final type converter to be run on the produced value.
/// </param>
/// <param name="updateSourceTrigger">The trigger for updating the source value.</param>
public BindingExpression(
object? source,
IReadOnlyList<ExpressionNode> nodes,
@ -61,11 +64,14 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
BindingPriority priority = BindingPriority.LocalValue,
string? stringFormat = null,
object? targetNullValue = null,
TargetTypeConverter? targetTypeConverter = null)
TargetTypeConverter? targetTypeConverter = null,
UpdateSourceTrigger updateSourceTrigger = UpdateSourceTrigger.PropertyChanged)
: base(priority, enableDataValidation)
{
if (mode == BindingMode.Default)
throw new ArgumentException("Binding mode cannot be Default.", nameof(mode));
if (updateSourceTrigger == UpdateSourceTrigger.Default)
throw new ArgumentException("UpdateSourceTrigger cannot be Default.", nameof(updateSourceTrigger));
if (source == AvaloniaProperty.UnsetValue)
source = null;
@ -79,8 +85,9 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
converterCulture is not null ||
converterParameter is not null ||
fallbackValue != AvaloniaProperty.UnsetValue ||
!string.IsNullOrWhiteSpace(stringFormat) ||
(targetNullValue is not null && targetNullValue != AvaloniaProperty.UnsetValue) ||
!string.IsNullOrWhiteSpace(stringFormat))
updateSourceTrigger is not UpdateSourceTrigger.PropertyChanged)
{
_uncommon = new()
{
@ -88,13 +95,14 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
_converterCulture = converterCulture,
_converterParameter = converterParameter,
_fallbackValue = fallbackValue,
_targetNullValue = targetNullValue ?? AvaloniaProperty.UnsetValue,
_stringFormat = stringFormat switch
{
string s when string.IsNullOrWhiteSpace(s) => null,
string s when !s.Contains('{') => $"{{0:{stringFormat}}}",
_ => stringFormat,
},
_targetNullValue = targetNullValue ?? AvaloniaProperty.UnsetValue,
_updateSourceTrigger = updateSourceTrigger,
};
}
@ -127,9 +135,16 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
public CultureInfo ConverterCulture => _uncommon?._converterCulture ?? CultureInfo.CurrentCulture;
public object? ConverterParameter => _uncommon?._converterParameter;
public object? FallbackValue => _uncommon is not null ? _uncommon._fallbackValue : AvaloniaProperty.UnsetValue;
public object? TargetNullValue => _uncommon?._targetNullValue ?? AvaloniaProperty.UnsetValue;
public ExpressionNode LeafNode => _nodes[_nodes.Count - 1];
public string? StringFormat => _uncommon?._stringFormat;
public object? TargetNullValue => _uncommon?._targetNullValue ?? AvaloniaProperty.UnsetValue;
public UpdateSourceTrigger UpdateSourceTrigger => _uncommon?._updateSourceTrigger ?? UpdateSourceTrigger.PropertyChanged;
public override void UpdateSource()
{
if (_mode is BindingMode.TwoWay or BindingMode.OneWayToSource)
WriteTargetValueToSource();
}
public override void UpdateTarget()
{
@ -353,7 +368,12 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
if (_mode is BindingMode.OneWayToSource)
PublishValue(target.GetValue(TargetProperty));
target.PropertyChanged += OnTargetPropertyChanged;
var trigger = UpdateSourceTrigger;
if (trigger is UpdateSourceTrigger.PropertyChanged)
target.PropertyChanged += OnTargetPropertyChanged;
else if (trigger is UpdateSourceTrigger.LostFocus && target is IInputElement ie)
ie.LostFocus += OnTargetLostFocus;
}
}
else
@ -370,7 +390,12 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
if (_mode is BindingMode.TwoWay or BindingMode.OneWayToSource &&
TryGetTarget(out var target))
{
target.PropertyChanged -= OnTargetPropertyChanged;
var trigger = UpdateSourceTrigger;
if (trigger is UpdateSourceTrigger.PropertyChanged)
target.PropertyChanged += OnTargetPropertyChanged;
else if (trigger is UpdateSourceTrigger.LostFocus && target is IInputElement ie)
ie.LostFocus += OnTargetLostFocus;
}
}
@ -450,7 +475,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
private void WriteTargetValueToSource()
{
Debug.Assert(_mode == BindingMode.OneWayToSource);
Debug.Assert(_mode is BindingMode.TwoWay or BindingMode.OneWayToSource);
if (TryGetTarget(out var target) &&
TargetProperty is not null &&
@ -461,14 +486,20 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
}
}
private void OnTargetLostFocus(object? sender, RoutedEventArgs e)
{
Debug.Assert(UpdateSourceTrigger is UpdateSourceTrigger.LostFocus);
WriteTargetValueToSource();
}
private void OnTargetPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
Debug.Assert(_mode is BindingMode.TwoWay or BindingMode.OneWayToSource);
Debug.Assert(UpdateSourceTrigger is UpdateSourceTrigger.PropertyChanged);
if (e.Property == TargetProperty)
{
WriteValueToSource(e.NewValue);
}
}
private object? ConvertFallback(object? fallback, string fallbackName)
@ -512,5 +543,6 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
public object? _fallbackValue;
public string? _stringFormat;
public object? _targetNullValue;
public UpdateSourceTrigger _updateSourceTrigger;
}
}

29
src/Avalonia.Base/Data/UpdateSourceTrigger.cs

@ -0,0 +1,29 @@
namespace Avalonia.Data;
/// <summary>
/// Describes the timing of binding source updates.
/// </summary>
public enum UpdateSourceTrigger
{
/// <summary>
/// The default <see cref="UpdateSourceTrigger"/> value of the binding target property.
/// This currently defaults to <see cref="PropertyChanged"/>.
/// </summary>
Default,
/// <summary>
/// Updates the binding source immediately whenever the binding target property changes.
/// </summary>
PropertyChanged,
/// <summary>
/// Updates the binding source whenever the binding target element loses focus.
/// </summary>
LostFocus,
/// <summary>
/// Updates the binding source only when you call the
/// <see cref="BindingExpressionBase.UpdateSource()"/> method.
/// </summary>
Explicit,
}

9
src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs

@ -114,7 +114,9 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions
? sn.SelectSource(Source, target, DefaultAnchor?.Target)
: Source != AvaloniaProperty.UnsetValue ? Source : target;
return new BindingExpression(
var (mode, trigger) = ResolveDefaultsFromMetadata(target, targetProperty);
return new BindingExpression(
source,
nodes,
FallbackValue,
@ -122,10 +124,11 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions
converterCulture: ConverterCulture,
converterParameter: ConverterParameter,
enableDataValidation: enableDataValidation,
mode: ResolveBindingMode(target, targetProperty),
mode: mode,
stringFormat: StringFormat,
targetNullValue: TargetNullValue,
targetTypeConverter: TargetTypeConverter.GetDefaultConverter());
targetTypeConverter: TargetTypeConverter.GetDefaultConverter(),
updateSourceTrigger: trigger);
}
}
}

7
src/Markup/Avalonia.Markup/Data/Binding.cs

@ -156,6 +156,8 @@ namespace Avalonia.Data
sn.SelectSource(Source, target, DefaultAnchor?.Target) :
Source != AvaloniaProperty.UnsetValue ? Source : target;
var (mode, trigger) = ResolveDefaultsFromMetadata(target, targetProperty);
return new BindingExpression(
source,
nodes,
@ -164,10 +166,11 @@ namespace Avalonia.Data
converterCulture: ConverterCulture,
converterParameter: ConverterParameter,
enableDataValidation: enableDataValidation,
mode: ResolveBindingMode(target, targetProperty),
mode: mode,
stringFormat: StringFormat,
targetNullValue: TargetNullValue,
targetTypeConverter: TargetTypeConverter.GetReflectionConverter());
targetTypeConverter: TargetTypeConverter.GetReflectionConverter(),
updateSourceTrigger: trigger);
}
private INameScope? GetNameScope()

28
src/Markup/Avalonia.Markup/Data/BindingBase.cs

@ -75,6 +75,12 @@ namespace Avalonia.Data
/// </summary>
public string? StringFormat { get; set; }
/// <summary>
/// Gets or sets a value that determines the timing of binding source updates for
/// <see cref="BindingMode.TwoWay"/> and <see cref="BindingMode.OneWayToSource"/> bindings.
/// </summary>
public UpdateSourceTrigger UpdateSourceTrigger { get; set; }
public WeakReference? DefaultAnchor { get; set; }
public WeakReference<INameScope?>? NameScope { get; set; }
@ -90,13 +96,23 @@ namespace Avalonia.Data
private protected abstract BindingExpressionBase Instance(AvaloniaProperty targetProperty, AvaloniaObject target);
private protected BindingMode ResolveBindingMode(AvaloniaObject target, AvaloniaProperty? targetProperty)
private protected (BindingMode, UpdateSourceTrigger) ResolveDefaultsFromMetadata(
AvaloniaObject target,
AvaloniaProperty? targetProperty)
{
if (Mode != BindingMode.Default)
return Mode;
if (targetProperty is null)
return BindingMode.OneWay;
return targetProperty.GetMetadata(target.GetType()).DefaultBindingMode;
var mode = Mode;
var trigger = UpdateSourceTrigger == UpdateSourceTrigger.Default ?
UpdateSourceTrigger.PropertyChanged : UpdateSourceTrigger;
if (mode == BindingMode.Default)
{
if (targetProperty?.GetMetadata(target.GetType()) is { } metadata)
mode = metadata.DefaultBindingMode;
else
mode = BindingMode.OneWay;
}
return (mode, trigger);
}
BindingExpressionBase IBinding2.Instance(AvaloniaObject target, AvaloniaProperty property) => Instance(property, target);

150
tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests_UpdateSourceTrigger.cs

@ -0,0 +1,150 @@
using System;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Base.UnitTests.Data.Core
{
[InvariantCulture]
public class BindingExpressionTests_UpdateSourceTrigger
{
[Fact]
public void TwoWay_PropertyChanged_Should_Update_Source_On_Property_Changed()
{
using var app = Start();
var data = new ViewModel();
var binding = new Binding
{
Path = "Foo",
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
};
var target = new TextBox { DataContext = data };
target.Bind(TextBox.TextProperty, binding);
var root = new TestRoot(target);
Assert.Equal("foo", target.Text);
Assert.Equal("foo", data.Foo);
target.Text = "bar";
Assert.Equal("bar", target.Text);
Assert.Equal("bar", data.Foo);
}
[Fact]
public void TwoWay_LostFocus_Should_Update_Source_On_Lost_Focus()
{
using var app = Start();
var data = new ViewModel();
var binding = new Binding
{
Path = "Foo",
UpdateSourceTrigger = UpdateSourceTrigger.LostFocus,
};
var target = new TextBox { DataContext = data };
target.Bind(TextBox.TextProperty, binding);
var root = new TestRoot(target);
root.Focusable = true;
target.Focus();
Assert.Equal("foo", target.Text);
Assert.Equal("foo", data.Foo);
target.Text = "bar";
Assert.Equal("bar", target.Text);
Assert.Equal("foo", data.Foo);
root.Focus();
Assert.Equal("bar", target.Text);
Assert.Equal("bar", data.Foo);
}
[Fact]
public void OneWayToSource_LostFocus_Should_Update_Source_On_Lost_Focus()
{
using var app = Start();
var data = new ViewModel();
var binding = new Binding
{
Path = "Foo",
Mode = BindingMode.OneWayToSource,
UpdateSourceTrigger = UpdateSourceTrigger.LostFocus,
};
var target = new TextBox { DataContext = data };
target.Bind(TextBox.TextProperty, binding);
var root = new TestRoot(target);
root.Focusable = true;
target.Focus();
Assert.Null(target.Text);
Assert.Null(data.Foo);
target.Text = "bar";
Assert.Equal("bar", target.Text);
Assert.Null(data.Foo);
root.Focus();
Assert.Equal("bar", target.Text);
Assert.Equal("bar", data.Foo);
}
[Fact]
public void TwoWay_Explicit_Should_Update_Source_On_Call_To_UpdateSource()
{
using var app = Start();
var data = new ViewModel();
var binding = new Binding
{
Path = "Foo",
UpdateSourceTrigger = UpdateSourceTrigger.Explicit,
};
var target = new TextBox { DataContext = data };
var expression = target.Bind(TextBox.TextProperty, binding);
var root = new TestRoot(target);
root.Focusable = true;
target.Focus();
Assert.Equal("foo", target.Text);
Assert.Equal("foo", data.Foo);
target.Text = "bar";
Assert.Equal("bar", target.Text);
Assert.Equal("foo", data.Foo);
root.Focus();
Assert.Equal("bar", target.Text);
Assert.Equal("foo", data.Foo);
expression.UpdateSource();
Assert.Equal("bar", target.Text);
Assert.Equal("bar", data.Foo);
}
private static IDisposable Start()
{
return UnitTestApplication.Start(TestServices.RealFocus);
}
private class ViewModel
{
public ViewModel(string foo = "foo") => Foo = foo;
public string Foo { get; set; }
}
}
}
Loading…
Cancel
Save