Browse Source

Fix AutoCompleteBox out of sync with bound Text (#21121)

* add failing test for #20008

* fix AutoCompleteBox ignores text changes

* Address review comments

* Update tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
pull/21137/head
Tim 6 months ago
committed by GitHub
parent
commit
a59e6cbb0b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 46
      src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs
  2. 98
      tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs

46
src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs

@ -107,16 +107,26 @@ namespace Avalonia.Controls
private AvaloniaList<object>? _view;
/// <summary>
/// Gets or sets a value to ignore a number of pending change handlers.
/// The value is decremented after each use. This is used to reset the
/// value of properties without performing any of the actions in their
/// change handlers.
/// Gets or sets a counter that tracks how many pending Text property change
/// notifications should be ignored. This is used when the Text property is set
/// programmatically and we want to suppress the corresponding change handlers.
/// The counter is decremented after each ignored notification.
/// </summary>
/// <remarks>The int is important as a value because the TextBox
/// TextChanged event does not immediately fire, and this will allow for
/// nested property changes to be ignored.</remarks>
/// <remarks>
/// The counter is important because the TextBox TextChanged event does not fire
/// immediately; using a counter allows nested property changes to be ignored safely.
/// </remarks>
private int _ignoreTextPropertyChange;
/// <summary>
/// Gets or sets a counter that tracks how many pending TextBox TextChanged
/// events should be ignored. This is distinct from <see cref="_ignoreTextPropertyChange"/>
/// and is used when the TextBox's TextChanged event needs to be suppressed
/// (e.g., during programmatic updates to the text box content).
/// The counter is decremented after each ignored event.
/// </summary>
private int _ignoreTextBoxTextChange;
/// <summary>
/// Gets or sets a value indicating whether to ignore calling a pending
/// change handlers.
@ -371,6 +381,12 @@ namespace Avalonia.Controls
/// <param name="e">Event arguments.</param>
private void OnTextPropertyChanged(AvaloniaPropertyChangedEventArgs e)
{
if (_ignoreTextPropertyChange > 0)
{
_ignoreTextPropertyChange--;
return;
}
TextUpdated((string?)e.NewValue, false);
}
@ -1226,6 +1242,12 @@ namespace Avalonia.Controls
/// </summary>
private void OnTextBoxTextChanged()
{
if (_ignoreTextBoxTextChange > 0)
{
_ignoreTextBoxTextChange--;
return;
}
//Uses Dispatcher.Post to allow the TextBox selection to update before processing
Dispatcher.UIThread.Post(() =>
{
@ -1270,7 +1292,7 @@ namespace Avalonia.Controls
// Update the TextBox's Text dependency property
if ((userInitiated == null || userInitiated == false) && TextBox != null && TextBox.Text != value)
{
_ignoreTextPropertyChange++;
_ignoreTextBoxTextChange++;
TextBox.Text = value ?? string.Empty;
// Text dependency property value was set, fire event
@ -1296,14 +1318,6 @@ namespace Avalonia.Controls
/// TextUpdated method is called from a TextBox event handler.</param>
private void TextUpdated(string? newText, bool userInitiated)
{
// Only process this event if it is coming from someone outside
// setting the Text dependency property directly.
if (_ignoreTextPropertyChange > 0)
{
_ignoreTextPropertyChange--;
return;
}
if (newText == null)
{
newText = string.Empty;

98
tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs

@ -8,7 +8,9 @@ using Avalonia.Threading;
using Avalonia.UnitTests;
using Xunit;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Reactive.Subjects;
using System.Runtime.CompilerServices;
using Avalonia.Headless;
using Avalonia.Harfbuzz;
using Avalonia.Input;
@ -1296,5 +1298,101 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(Media.Brushes.Green, control.PlaceholderForeground);
}
}
[Fact]
public void Bound_Text_Will_Update_Always()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var viewModel = new AutoCompleteBoxViewModel();
var control = CreateControl();
// Setup the binding
control[!AutoCompleteBox.TextProperty] = CompiledBinding.Create<AutoCompleteBoxViewModel, string?>
(vm => vm.TextValue, viewModel, mode: BindingMode.TwoWay);
// Ensure the bound text matches "foo"
Assert.Equal("foo", control.Text);
// Change the view model value several times and ensure the bound text is updated
for (var i = 0; i < 10; i++)
{
viewModel.UpdateTextValueTwice();
Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
Assert.Equal("bar", control.Text);
}
}
}
[Fact]
public void Bound_Text_Will_Update_From_Bar_To_Bar_Via_Foo()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var viewModel = new AutoCompleteBoxViewModel();
viewModel.TextValue = "bar";
var control = CreateControl();
control.ApplyTemplate();
// Setup the binding
control[!AutoCompleteBox.TextProperty] = CompiledBinding.Create<AutoCompleteBoxViewModel, string?>
(vm => vm.TextValue, viewModel, mode: BindingMode.TwoWay);
Assert.Equal("bar", control.Text);
int textChangedCount = 0;
control.TextChanged += (s, e) => textChangedCount++;
// Change the view model value "bar" -> "foo" -> "bar"
viewModel.UpdateTextValueTwice();
// Programmatic TextProperty updates should synchronously raise TextChanged, and
// OnTextBoxTextChanged is suppressed for the corresponding TextBox.Text updates.
Assert.Equal("bar", control.Text);
Assert.Equal(2, textChangedCount);
Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken);
Assert.Equal("bar", control.Text);
Assert.Equal(2, textChangedCount);
}
}
}
public class AutoCompleteBoxViewModel : INotifyPropertyChanged
{
public AutoCompleteBoxViewModel()
{
TextValue = "foo";
}
public event PropertyChangedEventHandler? PropertyChanged;
public string? TextValue
{
get;
set => SetField(ref field, value);
}
public void UpdateTextValueTwice()
{
TextValue = "foo";
TextValue = "bar";
}
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
}

Loading…
Cancel
Save