Browse Source

Added Binding.Delay feature (#16805)

pull/17886/head
Tom Edwards 2 years ago
committed by GitHub
parent
commit
0dd628ffb7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 46
      src/Avalonia.Base/Data/Core/BindingExpression.cs
  2. 3
      src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindingExtension.cs
  3. 4
      src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/ReflectionBindingExtension.cs
  4. 2
      src/Markup/Avalonia.Markup/Data/Binding.cs
  5. 11
      src/Markup/Avalonia.Markup/Data/BindingBase.cs
  6. 174
      tests/Avalonia.Markup.UnitTests/Data/BindingTests_Delay.cs

46
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
/// <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>
@ -59,6 +61,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri
object? source,
List<ExpressionNode>? 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
/// </summary>
private class UncommonFields
{
public TimeSpan _delay;
public DispatcherTimer? _delayTimer;
public IValueConverter? _converter;
public object? _converterParameter;
public CultureInfo? _converterCulture;

3
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,

4
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
};
}
/// <inheritdoc cref="BindingBase.Delay"/>
public int Delay { get; set; }
public IValueConverter? Converter { get; set; }
[TypeConverter(typeof(CultureInfoIetfLanguageTagConverter))]

2
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,

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

@ -30,6 +30,17 @@ namespace Avalonia.Data
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>

174
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();
}
}
Loading…
Cancel
Save