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();
+ }
+}