Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Binding.Delay feature #16805

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions src/Avalonia.Base/Data/Core/BindingExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Logging;
using Avalonia.Threading;
using Avalonia.Utilities;

namespace Avalonia.Data.Core;
Expand Down Expand Up @@ -40,6 +41,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
/// <param name="fallbackValue">
/// The fallback value. Pass <see cref="AvaloniaProperty.UnsetValue"/> for no fallback.
/// </param>
/// <param name="delay">The amount of time to wait before updating the binding source after the value on the target changes.</param>
/// <param name="converter">The converter to use.</param>
/// <param name="converterCulture">The converter culture to use.</param>
/// <param name="converterParameter">The converter parameter.</param>
Expand All @@ -59,6 +61,7 @@ public BindingExpression(
object? source,
List<ExpressionNode>? nodes,
object? fallbackValue,
TimeSpan delay = default,
IValueConverter? converter = null,
CultureInfo? converterCulture = null,
object? converterParameter = null,
Expand Down Expand Up @@ -86,7 +89,8 @@ public BindingExpression(
_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 ||
Expand All @@ -96,6 +100,7 @@ converterParameter is not null ||
{
_uncommon = new()
{
_delay = delay,
_converter = converter,
_converterCulture = converterCulture,
_converterParameter = converterParameter,
Expand Down Expand Up @@ -139,6 +144,7 @@ public override string Description
}

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;
Expand Down Expand Up @@ -308,6 +314,8 @@ internal void OnDataValidationError(Exception error)

internal override bool WriteValueToSource(object? value)
{
StopDelayTimer();

if (_nodes.Count == 0 || LeafNode is not ISettableNode setter || setter.ValueType is not { } type)
return false;

Expand Down Expand Up @@ -399,6 +407,8 @@ protected override void StartCore()

protected override void StopCore()
{
StopDelayTimer();

foreach (var node in _nodes)
node.SetSource(AvaloniaProperty.UnsetValue, null);

Expand Down Expand Up @@ -496,6 +506,8 @@ private void WriteTargetValueToSource()
{
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 &&
Expand All @@ -517,12 +529,34 @@ private void OnTargetPropertyChanged(object? sender, AvaloniaPropertyChangedEven
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();
TomEdwardsEnscape marked this conversation as resolved.
Show resolved Hide resolved
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)
Expand Down Expand Up @@ -561,6 +595,8 @@ private void OnTargetPropertyChanged(object? sender, AvaloniaPropertyChangedEven
/// </summary>
private class UncommonFields
{
public TimeSpan _delay;
public DispatcherTimer? _delayTimer;
public IValueConverter? _converter;
public object? _converterParameter;
public CultureInfo? _converterCulture;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public CompiledBindingExtension ProvideValue(IServiceProvider provider)
return new CompiledBindingExtension
{
Path = Path,
Delay = Delay,
Converter = Converter,
ConverterCulture = ConverterCulture,
ConverterParameter = ConverterParameter,
Expand Down Expand Up @@ -92,6 +93,7 @@ internal BindingExpression CreateObservableForTreeDataTemplate(object source)
source,
nodes,
FallbackValue,
delay: TimeSpan.FromMilliseconds(Delay),
converter: Converter,
converterParameter: ConverterParameter,
targetNullValue: TargetNullValue);
Expand Down Expand Up @@ -125,6 +127,7 @@ private BindingExpression InstanceCore(
source,
nodes,
FallbackValue,
delay: TimeSpan.FromMilliseconds(Delay),
converter: Converter,
converterCulture: ConverterCulture,
converterParameter: ConverterParameter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public Binding ProvideValue(IServiceProvider serviceProvider)
Mode = Mode,
Path = Path,
Priority = Priority,
Delay = Delay,
Source = Source,
StringFormat = StringFormat,
RelativeSource = RelativeSource,
Expand All @@ -43,6 +44,9 @@ public Binding ProvideValue(IServiceProvider serviceProvider)
};
}

/// <inheritdoc cref="BindingBase.Delay"/>
public int Delay { get; set; }

public IValueConverter? Converter { get; set; }

[TypeConverter(typeof(CultureInfoIetfLanguageTagConverter))]
Expand Down
2 changes: 2 additions & 0 deletions src/Markup/Avalonia.Markup/Data/Binding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ internal BindingExpression CreateObservableForTreeDataTemplate(object source)
source,
nodes,
FallbackValue,
delay: TimeSpan.FromMilliseconds(Delay),
converter: Converter,
converterParameter: ConverterParameter,
targetNullValue: TargetNullValue);
Expand Down Expand Up @@ -168,6 +169,7 @@ private UntypedBindingExpressionBase InstanceCore(
source,
nodes,
FallbackValue,
delay: TimeSpan.FromMilliseconds(Delay),
converter: Converter,
converterCulture: ConverterCulture,
converterParameter: ConverterParameter,
Expand Down
11 changes: 11 additions & 0 deletions src/Markup/Avalonia.Markup/Data/BindingBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ public BindingBase(BindingMode mode = BindingMode.Default)
Mode = mode;
}

/// <summary>
/// Gets or sets the amount of time, in milliseconds, to wait before updating the binding
/// source after the value on the target changes.
/// </summary>
/// <remarks>
/// There is no delay when the source is updated via <see cref="UpdateSourceTrigger.LostFocus"/>
/// or <see cref="BindingExpressionBase.UpdateSource"/>. Nor is there a delay when
/// <see cref="BindingMode.OneWayToSource"/> is active and a new source object is provided.
/// </remarks>
public int Delay { get; set; }

/// <summary>
/// Gets or sets the <see cref="IValueConverter"/> to use.
/// </summary>
Expand Down
174 changes: 174 additions & 0 deletions tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading