From 0dd628ffb70336bfa5246ee552c4114407058f11 Mon Sep 17 00:00:00 2001 From: Tom Edwards <109803929+TomEdwardsEnscape@users.noreply.github.com> Date: Thu, 2 Jan 2025 10:42:21 +0100 Subject: [PATCH] Added Binding.Delay feature (#16805) --- .../Data/Core/BindingExpression.cs | 46 ++++- .../CompiledBindingExtension.cs | 3 + .../ReflectionBindingExtension.cs | 4 + src/Markup/Avalonia.Markup/Data/Binding.cs | 2 + .../Avalonia.Markup/Data/BindingBase.cs | 11 ++ .../Data/BindingTests_Delay.cs | 174 ++++++++++++++++++ 6 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs diff --git a/src/Avalonia.Base/Data/Core/BindingExpression.cs b/src/Avalonia.Base/Data/Core/BindingExpression.cs index 566a3b6d71..46e4af7e3c 100644 --- a/src/Avalonia.Base/Data/Core/BindingExpression.cs +++ b/src/Avalonia.Base/Data/Core/BindingExpression.cs @@ -11,6 +11,7 @@ using Avalonia.Data.Core.Parsers; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Logging; +using Avalonia.Threading; using Avalonia.Utilities; namespace Avalonia.Data.Core; @@ -40,6 +41,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri /// /// The fallback value. Pass for no fallback. /// + /// The amount of time to wait before updating the binding source after the value on the target changes. /// The converter to use. /// The converter culture to use. /// The converter parameter. @@ -59,6 +61,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri object? source, List? nodes, object? fallbackValue, + TimeSpan delay = default, IValueConverter? converter = null, CultureInfo? converterCulture = null, object? converterParameter = null, @@ -86,7 +89,8 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri _targetTypeConverter = targetTypeConverter; _shouldUpdateOneTimeBindingTarget = _mode == BindingMode.OneTime; - if (converter is not null || + if (delay != default || + converter is not null || converterCulture is not null || converterParameter is not null || fallbackValue != AvaloniaProperty.UnsetValue || @@ -96,6 +100,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri { _uncommon = new() { + _delay = delay, _converter = converter, _converterCulture = converterCulture, _converterParameter = converterParameter, @@ -139,6 +144,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri } public Type? SourceType => (LeafNode as ISettableNode)?.ValueType; + public TimeSpan Delay => _uncommon?._delay ?? default; public IValueConverter? Converter => _uncommon?._converter; public CultureInfo ConverterCulture => _uncommon?._converterCulture ?? CultureInfo.CurrentCulture; public object? ConverterParameter => _uncommon?._converterParameter; @@ -308,6 +314,8 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri internal override bool WriteValueToSource(object? value) { + StopDelayTimer(); + if (_nodes.Count == 0 || LeafNode is not ISettableNode setter || setter.ValueType is not { } type) return false; @@ -399,6 +407,8 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri protected override void StopCore() { + StopDelayTimer(); + foreach (var node in _nodes) node.SetSource(AvaloniaProperty.UnsetValue, null); @@ -496,6 +506,8 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri { Debug.Assert(_mode is BindingMode.TwoWay or BindingMode.OneWayToSource); + StopDelayTimer(); + if (TryGetTarget(out var target) && TargetProperty is not null && target.GetValue(TargetProperty) is var value && @@ -517,12 +529,34 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri Debug.Assert(_mode is BindingMode.TwoWay or BindingMode.OneWayToSource); Debug.Assert(UpdateSourceTrigger is UpdateSourceTrigger.PropertyChanged); - // The value must be read from the target object instead of using the value from the event - // because the value may have changed again between the time the event was raised and now. - if (e.Property == TargetProperty && TryGetTarget(out var target)) - WriteValueToSource(target.GetValue(TargetProperty)); + if (e.Property != TargetProperty) + return; + + if (_uncommon?._delay is not { Ticks: > 0 } delay) + { + // The value must be read from the target object instead of using the value from the event + // because the value may have changed again between the time the event was raised and now. + WriteTargetValueToSource(); + return; + } + + if (_uncommon!._delayTimer is { } delayTimer) + delayTimer.Stop(); + else + delayTimer = _uncommon._delayTimer = new DispatcherTimer(delay, DispatcherPriority.Normal, OnDelayTimerTick) { Tag = this }; + + delayTimer.Start(); } + // This is a static method so that the same delegate object can be reused by all expression instances + private static void OnDelayTimerTick(object? sender, EventArgs e) + { + var expression = (BindingExpression)((DispatcherTimer)sender!).Tag!; + expression.WriteTargetValueToSource(); + } + + private void StopDelayTimer() => _uncommon?._delayTimer?.Stop(); + private object? ConvertFallback(object? fallback, string fallbackName) { if (_targetTypeConverter is null || TargetType == typeof(object) || fallback == AvaloniaProperty.UnsetValue) @@ -561,6 +595,8 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri /// private class UncommonFields { + public TimeSpan _delay; + public DispatcherTimer? _delayTimer; public IValueConverter? _converter; public object? _converterParameter; public CultureInfo? _converterCulture; diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs index 2e80f1ba8a..5190e3e812 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs @@ -26,6 +26,7 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions return new CompiledBindingExtension { Path = Path, + Delay = Delay, Converter = Converter, ConverterCulture = ConverterCulture, ConverterParameter = ConverterParameter, @@ -92,6 +93,7 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions source, nodes, FallbackValue, + delay: TimeSpan.FromMilliseconds(Delay), converter: Converter, converterParameter: ConverterParameter, targetNullValue: TargetNullValue); @@ -125,6 +127,7 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions source, nodes, FallbackValue, + delay: TimeSpan.FromMilliseconds(Delay), converter: Converter, converterCulture: ConverterCulture, converterParameter: ConverterParameter, diff --git a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/ReflectionBindingExtension.cs b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/ReflectionBindingExtension.cs index 1a69830155..312477ef02 100644 --- a/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/ReflectionBindingExtension.cs +++ b/src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/ReflectionBindingExtension.cs @@ -33,6 +33,7 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions Mode = Mode, Path = Path, Priority = Priority, + Delay = Delay, Source = Source, StringFormat = StringFormat, RelativeSource = RelativeSource, @@ -43,6 +44,9 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions }; } + /// + public int Delay { get; set; } + public IValueConverter? Converter { get; set; } [TypeConverter(typeof(CultureInfoIetfLanguageTagConverter))] diff --git a/src/Markup/Avalonia.Markup/Data/Binding.cs b/src/Markup/Avalonia.Markup/Data/Binding.cs index 5b07a1adb0..5d1f3c22a0 100644 --- a/src/Markup/Avalonia.Markup/Data/Binding.cs +++ b/src/Markup/Avalonia.Markup/Data/Binding.cs @@ -120,6 +120,7 @@ namespace Avalonia.Data source, nodes, FallbackValue, + delay: TimeSpan.FromMilliseconds(Delay), converter: Converter, converterParameter: ConverterParameter, targetNullValue: TargetNullValue); @@ -168,6 +169,7 @@ namespace Avalonia.Data source, nodes, FallbackValue, + delay: TimeSpan.FromMilliseconds(Delay), converter: Converter, converterCulture: ConverterCulture, converterParameter: ConverterParameter, diff --git a/src/Markup/Avalonia.Markup/Data/BindingBase.cs b/src/Markup/Avalonia.Markup/Data/BindingBase.cs index 33113c33ca..fa6f733281 100644 --- a/src/Markup/Avalonia.Markup/Data/BindingBase.cs +++ b/src/Markup/Avalonia.Markup/Data/BindingBase.cs @@ -30,6 +30,17 @@ namespace Avalonia.Data Mode = mode; } + /// + /// Gets or sets the amount of time, in milliseconds, to wait before updating the binding + /// source after the value on the target changes. + /// + /// + /// There is no delay when the source is updated via + /// or . Nor is there a delay when + /// is active and a new source object is provided. + /// + public int Delay { get; set; } + /// /// Gets or sets the to use. /// diff --git a/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs b/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs new file mode 100644 index 0000000000..9ebf38a2cd --- /dev/null +++ b/tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs @@ -0,0 +1,174 @@ +using System; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Threading; +using Avalonia.UnitTests; +using Xunit; + +#nullable enable + +namespace Avalonia.Markup.UnitTests.Data; + +public class BindingTests_Delay : IDisposable +{ + private const int DelayMilliseconds = 10; + private const string InitialFooValue = "foo"; + + private readonly ManualTimerDispatcher _dispatcher; + private readonly IDisposable _app; + private readonly BindingTests.Source _source; + private readonly TextBox _target; + private readonly Binding _binding; + private readonly BindingExpressionBase _bindingExpr; + + public BindingTests_Delay() + { + _dispatcher = new ManualTimerDispatcher(); + _app = UnitTestApplication.Start(new(dispatcherImpl: _dispatcher, focusManager: new FocusManager(), keyboardDevice: () => new KeyboardDevice())); + + _source = new BindingTests.Source { Foo = InitialFooValue }; + _target = new TextBox { DataContext = _source }; + _binding = new Binding(nameof(_source.Foo), BindingMode.TwoWay) { Delay = DelayMilliseconds }; + + _bindingExpr = _target.Bind(TextBox.TextProperty, _binding); + + Assert.Equal(_source.Foo, _target.Text); + } + + public void Dispose() + { + _app.Dispose(); + } + + [Fact] + public void Delayed_Binding_Should_Set_Value_Only_After_Delay_Elapsed() + { + _target.Text = "bar"; + Assert.Equal(InitialFooValue, _source.Foo); + + SetTimeAndExecuteTimers(DelayMilliseconds / 2); + Assert.Equal(InitialFooValue, _source.Foo); + + SetTimeAndExecuteTimers(DelayMilliseconds + 1); + + Assert.Equal("bar", _source.Foo); + } + + [Fact] + public void Delayed_Binding_Should_Not_Set_Value_After_Being_Disposed() + { + _target.Text = "bar"; + Assert.Equal(InitialFooValue, _source.Foo); + + _bindingExpr.Dispose(); + + SetTimeAndExecuteTimers(DelayMilliseconds + 1); + + Assert.Equal(InitialFooValue, _source.Foo); + } + + [Fact] + public void Delayed_Binding_Should_Restart_If_Value_Changes_During_Delay() + { + _target.Text = "bar"; + Assert.Equal(InitialFooValue, _source.Foo); + + SetTimeAndExecuteTimers(DelayMilliseconds / 2); + + _target.Text = "baz"; + + SetTimeAndExecuteTimers(DelayMilliseconds + 1); // we set a new value half-way through the delay, so the delay is still in effect at this timestamp + + Assert.Equal(InitialFooValue, _source.Foo); + + SetTimeAndExecuteTimers(DelayMilliseconds * 2); + + Assert.Equal("baz", _source.Foo); + } + + [Fact] + public void Delayed_Binding_Should_Not_Execute_If_Value_Returns_To_Original() + { + _target.Text = "bar"; + Assert.Equal(InitialFooValue, _source.Foo); + + SetTimeAndExecuteTimers(DelayMilliseconds / 2); + + _target.Text = InitialFooValue; + + SetTimeAndExecuteTimers(DelayMilliseconds * 2); + + Assert.Equal(InitialFooValue, _source.Foo); + Assert.Equal(1, _source.FooSetCount); + } + + [Fact] + public void Delayed_Binding_UpdateSource_Call_Should_Update_Source_Immediately() + { + _target.Text = "bar"; + _bindingExpr.UpdateSource(); + + Assert.Equal("bar", _source.Foo); + } + + [Fact] + public void Delayed_Binding_UpdateTrigger_LostFocus_Should_Update_Source_Immediately() + { + var secondBox = new TextBox(); + + new TestRoot() { Child = new Panel() { Children = { _target, secondBox } } }; + + _target.Bind(TextBox.TextProperty, new Binding(nameof(_source.Foo), BindingMode.TwoWay) { Delay = DelayMilliseconds, UpdateSourceTrigger = UpdateSourceTrigger.LostFocus }); + + Assert.True(_target.Focus()); + _target.Text = "bar"; + + Assert.Equal(InitialFooValue, _source.Foo); + + Assert.True(secondBox.Focus()); + Assert.Equal("bar", _source.Foo); + } + + [Fact] + public void Delayed_Binding_OneWayToSource_DataContext_Change_Should_Update_Source_Immediately() + { + _target.Bind(TextBlock.TextProperty, new Binding(nameof(_source.Foo), BindingMode.OneWayToSource) { Delay = DelayMilliseconds }); + + _target.Text = "bar"; + + var newSource = new BindingTests.Source(); + + _target.DataContext = newSource; + + Assert.Equal("bar", newSource.Foo); + } + + [Fact] + public void Delayed_Binding_Should_Update_Target_Immediately() + { + _source.Foo = "bar"; + Assert.Equal("bar", _target.Text); + } + + private void SetTimeAndExecuteTimers(long time) + { + _dispatcher.Now = time; + _dispatcher.RaiseTimerEvent(); + } + + private class ManualTimerDispatcher : IDispatcherImpl + { + public bool CurrentThreadIsLoopThread => true; + public long Now { get; set; } + + public event Action? Signaled; + public event Action? Timer; + + public void Signal() { Signaled?.Invoke(); } + + public void UpdateTimer(long? dueTimeInMs) { } + + public void RaiseTimerEvent() => Timer?.Invoke(); + } +}