From bee98ad9479a2c27dfd68dc38b09b01882b1c762 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 24 Nov 2023 23:05:02 +0100 Subject: [PATCH] Initial implementation of UpdateSourceTrigger. --- .../Data/BindingExpressionBase.cs | 15 ++ .../Data/Core/BindingExpression.cs | 50 ++++-- src/Avalonia.Base/Data/UpdateSourceTrigger.cs | 29 ++++ .../CompiledBindingExtension.cs | 9 +- src/Markup/Avalonia.Markup/Data/Binding.cs | 7 +- .../Avalonia.Markup/Data/BindingBase.cs | 28 +++- ...dingExpressionTests_UpdateSourceTrigger.cs | 150 ++++++++++++++++++ 7 files changed, 268 insertions(+), 20 deletions(-) create mode 100644 src/Avalonia.Base/Data/UpdateSourceTrigger.cs create mode 100644 tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests_UpdateSourceTrigger.cs diff --git a/src/Avalonia.Base/Data/BindingExpressionBase.cs b/src/Avalonia.Base/Data/BindingExpressionBase.cs index 24bed9ddd5..6e774405fa 100644 --- a/src/Avalonia.Base/Data/BindingExpressionBase.cs +++ b/src/Avalonia.Base/Data/BindingExpressionBase.cs @@ -17,6 +17,21 @@ public abstract class BindingExpressionBase : IDisposable, ISetterInstance GC.SuppressFinalize(this); } + /// + /// Sends the current binding target value to the binding source property in + /// or bindings. + /// + /// + /// This method does nothing when the Mode of the binding is not + /// or . + /// + /// If the UpdateSourceTrigger value of your binding is set to + /// , you must call the + /// method or the changes will not propagate back to the + /// source. + /// + public virtual void UpdateSource() { } + /// /// Forces a data transfer from the binding source to the binding target. /// diff --git a/src/Avalonia.Base/Data/Core/BindingExpression.cs b/src/Avalonia.Base/Data/Core/BindingExpression.cs index f4fd076f90..ae884fd37f 100644 --- a/src/Avalonia.Base/Data/Core/BindingExpression.cs +++ b/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 /// /// A final type converter to be run on the produced value. /// + /// The trigger for updating the source value. public BindingExpression( object? source, IReadOnlyList 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; } } diff --git a/src/Avalonia.Base/Data/UpdateSourceTrigger.cs b/src/Avalonia.Base/Data/UpdateSourceTrigger.cs new file mode 100644 index 0000000000..846671b92f --- /dev/null +++ b/src/Avalonia.Base/Data/UpdateSourceTrigger.cs @@ -0,0 +1,29 @@ +namespace Avalonia.Data; + +/// +/// Describes the timing of binding source updates. +/// +public enum UpdateSourceTrigger +{ + /// + /// The default value of the binding target property. + /// This currently defaults to . + /// + Default, + + /// + /// Updates the binding source immediately whenever the binding target property changes. + /// + PropertyChanged, + + /// + /// Updates the binding source whenever the binding target element loses focus. + /// + LostFocus, + + /// + /// Updates the binding source only when you call the + /// method. + /// + Explicit, +} diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs index 9b933450ab..0c302dc0ac 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs +++ b/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); } } } diff --git a/src/Markup/Avalonia.Markup/Data/Binding.cs b/src/Markup/Avalonia.Markup/Data/Binding.cs index 9dc78483d4..c310257199 100644 --- a/src/Markup/Avalonia.Markup/Data/Binding.cs +++ b/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() diff --git a/src/Markup/Avalonia.Markup/Data/BindingBase.cs b/src/Markup/Avalonia.Markup/Data/BindingBase.cs index 49504156cc..598b056165 100644 --- a/src/Markup/Avalonia.Markup/Data/BindingBase.cs +++ b/src/Markup/Avalonia.Markup/Data/BindingBase.cs @@ -75,6 +75,12 @@ namespace Avalonia.Data /// public string? StringFormat { get; set; } + /// + /// Gets or sets a value that determines the timing of binding source updates for + /// and bindings. + /// + public UpdateSourceTrigger UpdateSourceTrigger { get; set; } + public WeakReference? DefaultAnchor { get; set; } public WeakReference? 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); diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests_UpdateSourceTrigger.cs b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests_UpdateSourceTrigger.cs new file mode 100644 index 0000000000..985e293b4f --- /dev/null +++ b/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; } + } + } +}