diff --git a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs index 0520d7643f..f7f5250649 100644 --- a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -107,16 +107,26 @@ namespace Avalonia.Controls private AvaloniaList? _view; /// - /// 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. /// - /// 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. + /// + /// The counter is important because the TextBox TextChanged event does not fire + /// immediately; using a counter allows nested property changes to be ignored safely. + /// private int _ignoreTextPropertyChange; + /// + /// Gets or sets a counter that tracks how many pending TextBox TextChanged + /// events should be ignored. This is distinct from + /// 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. + /// + private int _ignoreTextBoxTextChange; + /// /// Gets or sets a value indicating whether to ignore calling a pending /// change handlers. @@ -371,6 +381,12 @@ namespace Avalonia.Controls /// Event arguments. private void OnTextPropertyChanged(AvaloniaPropertyChangedEventArgs e) { + if (_ignoreTextPropertyChange > 0) + { + _ignoreTextPropertyChange--; + return; + } + TextUpdated((string?)e.NewValue, false); } @@ -1226,6 +1242,12 @@ namespace Avalonia.Controls /// 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. 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; diff --git a/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs b/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs index 26ece37acf..3bdec3bc7e 100644 --- a/tests/Avalonia.Controls.UnitTests/AutoCompleteBoxTests.cs +++ b/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 + (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 + (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(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return false; + field = value; + OnPropertyChanged(propertyName); + return true; + } } }