Browse Source

Implement TextSearch.TextBinding (#18405)

* Implement TextSearch.TextBinding

* Move AssignBinding to TextSearch.GetTextBinding
release/11.3.0-beta1
Julien Lebosquain 10 months ago
committed by GitHub
parent
commit
b09e0d5677
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      samples/ControlCatalog/Pages/ComboBoxPage.xaml
  2. 11
      samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs
  3. 1
      src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs
  4. 38
      src/Avalonia.Controls/Presenters/ItemsPresenter.cs
  5. 96
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  6. 90
      src/Avalonia.Controls/Primitives/TextSearch.cs
  7. 61
      src/Avalonia.Controls/Utils/BindingEvaluator.cs
  8. 5
      src/Avalonia.Controls/VirtualizingPanel.cs
  9. 102
      tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs

5
samples/ControlCatalog/Pages/ComboBoxPage.xaml

@ -102,9 +102,8 @@
<ComboBox
WrapSelection="{Binding WrapSelection}"
ItemsSource="{Binding Values}"
DisplayMemberBinding="{Binding Name}">
</ComboBox>
DisplayMemberBinding="{Binding Name}"
TextSearch.TextBinding="{Binding SearchText, DataType=viewModels:IdAndName}" />
<ComboBox
WrapSelection="{Binding WrapSelection}"

11
samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs

@ -19,11 +19,11 @@ namespace ControlCatalog.ViewModels
public ObservableCollection<IdAndName> Values { get; set; } = new ObservableCollection<IdAndName>
{
new IdAndName(){ Id = "Id 1", Name = "Name 1" },
new IdAndName(){ Id = "Id 2", Name = "Name 2" },
new IdAndName(){ Id = "Id 3", Name = "Name 3" },
new IdAndName(){ Id = "Id 4", Name = "Name 4" },
new IdAndName(){ Id = "Id 5", Name = "Name 5" },
new IdAndName(){ Id = "Id 1", Name = "Name 1", SearchText = "A" },
new IdAndName(){ Id = "Id 2", Name = "Name 2", SearchText = "B" },
new IdAndName(){ Id = "Id 3", Name = "Name 3", SearchText = "C" },
new IdAndName(){ Id = "Id 4", Name = "Name 4", SearchText = "D" },
new IdAndName(){ Id = "Id 5", Name = "Name 5", SearchText = "E" },
};
}
@ -31,5 +31,6 @@ namespace ControlCatalog.ViewModels
{
public string? Id { get; set; }
public string? Name { get; set; }
public string? SearchText { get; set; }
}
}

1
src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs

@ -2036,6 +2036,7 @@ namespace Avalonia.Controls
}
}
// TODO12: Remove, this shouldn't be part of the public API. Use our internal BindingEvaluator instead.
/// <summary>
/// A framework element that permits a binding to be evaluated in a new data
/// context leaf node.

38
src/Avalonia.Controls/Presenters/ItemsPresenter.cs

@ -197,44 +197,6 @@ namespace Avalonia.Controls.Presenters
return Panel?.Children;
}
internal static bool ControlMatchesTextSearch(Control control, string textSearchTerm)
{
if (control is AvaloniaObject ao && ao.IsSet(TextSearch.TextProperty))
{
var searchText = ao.GetValue(TextSearch.TextProperty);
if (searchText?.StartsWith(textSearchTerm, StringComparison.OrdinalIgnoreCase) == true)
{
return true;
}
}
return control is IContentControl cc &&
cc.Content?.ToString()?.StartsWith(textSearchTerm, StringComparison.OrdinalIgnoreCase) == true;
}
internal int GetIndexFromTextSearch(string textSearch)
{
if (Panel is VirtualizingPanel v)
return v.GetIndexFromTextSearch(textSearch);
return GetIndexFromTextSearch(ItemsControl?.Items, textSearch);
}
internal static int GetIndexFromTextSearch(IReadOnlyList<object?>? items, string textSearchTerm)
{
if (items is null)
return -1;
for (var i = 0; i < items.Count; i++)
{
if (items[i] is Control c && ControlMatchesTextSearch(c, textSearchTerm)
|| items[i]?.ToString()?.StartsWith(textSearchTerm, StringComparison.OrdinalIgnoreCase) == true)
{
return i;
}
}
return -1;
}
internal int IndexFromContainer(Control container)
{
if (Panel is VirtualizingPanel v)

96
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@ -5,6 +5,7 @@ using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Avalonia.Controls.Selection;
using Avalonia.Controls.Utils;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
@ -146,7 +147,7 @@ namespace Avalonia.Controls.Primitives
private bool _ignoreContainerSelectionChanged;
private UpdateState? _updateState;
private bool _hasScrolledToSelectedItem;
private BindingHelper? _bindingHelper;
private BindingEvaluator<object?>? _selectedValueBindingEvaluator;
private bool _isSelectionChangeActive;
public SelectingItemsControl()
@ -609,10 +610,10 @@ namespace Avalonia.Controls.Primitives
_textSearchTerm += e.Text;
var newIndex = Presenter?.GetIndexFromTextSearch(_textSearchTerm);
var newIndex = GetIndexFromTextSearch(_textSearchTerm);
if (newIndex >= 0)
{
SelectedIndex = (int)newIndex;
SelectedIndex = newIndex;
}
StartTextSearchTimer();
@ -678,17 +679,10 @@ namespace Avalonia.Controls.Primitives
{
_isSelectionChangeActive = true;
if (_bindingHelper is null)
{
_bindingHelper = new BindingHelper(value);
}
else
{
_bindingHelper.UpdateBinding(value);
}
var bindingEvaluator = GetSelectedValueBindingEvaluator(value);
// Re-evaluate SelectedValue with the new binding
SetCurrentValue(SelectedValueProperty, _bindingHelper.Evaluate(selectedItem));
SetCurrentValue(SelectedValueProperty, bindingEvaluator.Evaluate(selectedItem));
}
finally
{
@ -1067,20 +1061,23 @@ namespace Avalonia.Controls.Primitives
}
}
_bindingHelper ??= new BindingHelper(binding);
var bindingEvaluator = GetSelectedValueBindingEvaluator(binding);
// Matching UWP behavior, if duplicates are present, return the first item matching
// the SelectedValue provided
foreach (var item in items!)
{
var itemValue = _bindingHelper.Evaluate(item);
var itemValue = bindingEvaluator.Evaluate(item);
if (Equals(itemValue, value))
{
bindingEvaluator.ClearDataContext();
return item;
}
}
bindingEvaluator.ClearDataContext();
return AvaloniaProperty.UnsetValue;
}
@ -1107,12 +1104,12 @@ namespace Avalonia.Controls.Primitives
return;
}
_bindingHelper ??= new BindingHelper(binding);
var bindingEvaluator = GetSelectedValueBindingEvaluator(binding);
try
{
_isSelectionChangeActive = true;
SetCurrentValue(SelectedValueProperty, _bindingHelper.Evaluate(item));
SetCurrentValue(SelectedValueProperty, bindingEvaluator.Evaluate(item));
}
finally
{
@ -1338,6 +1335,37 @@ namespace Avalonia.Controls.Primitives
StopTextSearchTimer();
}
private int GetIndexFromTextSearch(string textSearchTerm)
{
if (string.IsNullOrEmpty(textSearchTerm))
return -1;
var count = Items.Count;
if (count == 0)
return -1;
var textBinding = TextSearch.GetTextBinding(this) ?? DisplayMemberBinding;
using var textBindingEvaluator = BindingEvaluator<string?>.TryCreate(textBinding);
for (var i = 0; i < count; i++)
{
var text = TextSearch.GetEffectiveText(Items[i], textBindingEvaluator);
if (text.StartsWith(textSearchTerm, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return -1;
}
private BindingEvaluator<object?> GetSelectedValueBindingEvaluator(IBinding binding)
{
_selectedValueBindingEvaluator ??= new();
_selectedValueBindingEvaluator.UpdateBinding(binding);
return _selectedValueBindingEvaluator;
}
// When in a BeginInit..EndInit block, or when the DataContext is updating, we need to
// defer changes to the selection model because we have no idea in which order properties
// will be set. Consider:
@ -1367,41 +1395,5 @@ namespace Avalonia.Controls.Primitives
public Optional<object?> SelectedItem { get; set; }
public Optional<object?> SelectedValue { get; set; }
}
/// <summary>
/// Helper class for evaluating a binding from an Item and IBinding instance
/// </summary>
private class BindingHelper : StyledElement
{
private BindingExpressionBase? _expression;
private IBinding? _lastBinding;
public BindingHelper(IBinding binding)
{
UpdateBinding(binding);
}
public static readonly StyledProperty<object> ValueProperty =
AvaloniaProperty.Register<BindingHelper, object>("Value");
public object? Evaluate(object? dataContext)
{
// Only update the DataContext if necessary
if (!Equals(dataContext, DataContext))
DataContext = dataContext;
return GetValue(ValueProperty);
}
public void UpdateBinding(IBinding binding)
{
if (binding == _lastBinding)
return;
_expression?.Dispose();
_expression = Bind(ValueProperty, binding);
_lastBinding = binding;
}
}
}
}

90
src/Avalonia.Controls/Primitives/TextSearch.cs

@ -1,3 +1,5 @@
using Avalonia.Controls.Utils;
using Avalonia.Data;
using Avalonia.Interactivity;
namespace Avalonia.Controls.Primitives
@ -9,29 +11,95 @@ namespace Avalonia.Controls.Primitives
{
/// <summary>
/// Defines the Text attached property.
/// This text will be considered during text search in <see cref="SelectingItemsControl"/> (such as <see cref="ComboBox"/>)
/// This text will be considered during text search in <see cref="SelectingItemsControl"/> (such as <see cref="ComboBox"/>).
/// This property is usually applied to an item container directly.
/// </summary>
public static readonly AttachedProperty<string?> TextProperty
= AvaloniaProperty.RegisterAttached<Interactive, string?>("Text", typeof(TextSearch));
/// <summary>
/// Sets the <see cref="TextProperty"/> for a control.
/// Defines the TextBinding attached property.
/// The binding will be applied to each item during text search in <see cref="SelectingItemsControl"/> (such as <see cref="ComboBox"/>).
/// </summary>
/// <param name="control">The control</param>
/// <param name="text">The search text to set</param>
public static readonly AttachedProperty<IBinding?> TextBindingProperty
= AvaloniaProperty.RegisterAttached<Interactive, IBinding?>("TextBinding", typeof(TextSearch));
// TODO12: Control should be Interactive to match the property definition.
/// <summary>
/// Sets the value of the <see cref="TextProperty"/> attached property to a given <see cref="Control"/>.
/// </summary>
/// <param name="control">The control.</param>
/// <param name="text">The search text to set.</param>
public static void SetText(Control control, string? text)
{
control.SetValue(TextProperty, text);
}
=> control.SetValue(TextProperty, text);
// TODO12: Control should be Interactive to match the property definition.
/// <summary>
/// Gets the <see cref="TextProperty"/> of a control.
/// Gets the value of the <see cref="TextProperty"/> attached property from a given <see cref="Control"/>.
/// </summary>
/// <param name="control">The control</param>
/// <returns>The property value</returns>
/// <param name="control">The control.</param>
/// <returns>The search text.</returns>
public static string? GetText(Control control)
=> control.GetValue(TextProperty);
/// <summary>
/// Sets the value of the <see cref="TextBindingProperty"/> attached property to a given <see cref="Interactive"/>.
/// </summary>
/// <param name="interactive">The interactive element.</param>
/// <param name="value">The search text binding to set.</param>
public static void SetTextBinding(Interactive interactive, IBinding? value)
=> interactive.SetValue(TextBindingProperty, value);
/// <summary>
/// Gets the value of the <see cref="TextBindingProperty"/> attached property from a given <see cref="Interactive"/>.
/// </summary>
/// <param name="interactive">The interactive element.</param>
/// <returns>The search text binding.</returns>
[AssignBinding]
public static IBinding? GetTextBinding(Interactive interactive)
=> interactive.GetValue(TextBindingProperty);
/// <summary>
/// <para>Gets the effective text of a given item.</para>
/// <para>
/// This method uses the first non-empty text from the following list:
/// <list>
/// <item><see cref="TextSearch.TextProperty"/> (if the item is a control)</item>
/// <item><see cref="TextSearch.TextBindingProperty"/></item>
/// <item><see cref="ItemsControl.DisplayMemberBinding"/></item>
/// <item><see cref="IContentControl.Content"/>.<see cref="object.ToString"/> (if the item is a <see cref="IContentControl"/>)</item>
/// <item><see cref="object.ToString"/></item>
/// </list>
/// </para>
/// </summary>
/// <param name="item">The item.</param>
/// <param name="textBindingEvaluator">A <see cref="BindingEvaluator{T}"/> used to get the item's text from a binding.</param>
/// <returns>The item's text.</returns>
internal static string GetEffectiveText(object? item, BindingEvaluator<string?>? textBindingEvaluator)
{
return control.GetValue(TextProperty);
if (item is null)
return string.Empty;
string? text;
if (item is Interactive interactive)
{
text = interactive.GetValue(TextProperty);
if (!string.IsNullOrEmpty(text))
return text;
}
if (textBindingEvaluator is not null)
{
text = textBindingEvaluator.Evaluate(item);
if (!string.IsNullOrEmpty(text))
return text;
}
if (item is IContentControl contentControl)
return contentControl.Content?.ToString() ?? string.Empty;
return item.ToString() ?? string.Empty;
}
}
}

61
src/Avalonia.Controls/Utils/BindingEvaluator.cs

@ -0,0 +1,61 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Data;
namespace Avalonia.Controls.Utils;
/// <summary>
/// Helper class for evaluating a binding from an Item and IBinding instance
/// </summary>
internal sealed class BindingEvaluator<T> : StyledElement, IDisposable
{
private BindingExpressionBase? _expression;
private IBinding? _lastBinding;
[SuppressMessage(
"AvaloniaProperty",
"AVP1002:AvaloniaProperty objects should not be owned by a generic type",
Justification = "This property is not supposed to be used from XAML.")]
public static readonly StyledProperty<T> ValueProperty =
AvaloniaProperty.Register<BindingEvaluator<T>, T>("Value");
public T Evaluate(object? dataContext)
{
// Only update the DataContext if necessary
if (!Equals(dataContext, DataContext))
DataContext = dataContext;
return GetValue(ValueProperty);
}
public void UpdateBinding(IBinding binding)
{
if (binding == _lastBinding)
return;
_expression?.Dispose();
_expression = Bind(ValueProperty, binding);
_lastBinding = binding;
}
public void ClearDataContext()
=> DataContext = this;
public void Dispose()
{
_expression?.Dispose();
_expression = null;
_lastBinding = null;
DataContext = null;
}
public static BindingEvaluator<T>? TryCreate(IBinding? binding)
{
if (binding is null)
return null;
var evaluator = new BindingEvaluator<T>();
evaluator.UpdateBinding(binding);
return evaluator;
}
}

5
src/Avalonia.Controls/VirtualizingPanel.cs

@ -193,11 +193,6 @@ namespace Avalonia.Controls
Children.RemoveRange(index, count);
}
internal int GetIndexFromTextSearch(string textSearchTerm)
{
return ItemsPresenter.GetIndexFromTextSearch(Items, textSearchTerm);
}
private protected override void InvalidateMeasureOnChildrenChanged()
{
// Don't invalidate measure when children are added or removed: the panel is responsible

102
tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs

@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Subjects;
using Avalonia.Controls.Presenters;
@ -253,25 +255,105 @@ namespace Avalonia.Controls.UnitTests
int initialSelectedIndex,
int expectedSelectedIndex,
string searchTerm,
params string[] items)
params string[] contents)
{
TestTextSearch(
initialSelectedIndex,
expectedSelectedIndex,
searchTerm,
_ => { },
contents.Select(content => new ComboBoxItem { Content = content }));
}
[Theory]
[InlineData(-1, 1, "c", new[] { "A item", "B item", "C item" }, new[] { "B search", "C search", "A search" })]
[InlineData(0, 2, "baz", new[] { "A item", "B item", "C item" }, new[] { "foo", "bar", "baz" })]
public void TextSearch_With_TextSearchText_Should_Have_Expected_SelectedIndex(
int initialSelectedIndex,
int expectedSelectedIndex,
string searchTerm,
string[] contents,
string[] searchTexts)
{
Assert.Equal(contents.Length, searchTexts.Length);
TestTextSearch(
initialSelectedIndex,
expectedSelectedIndex,
searchTerm,
_ => { },
contents.Select((item, index) =>
{
var comboBoxItem = new ComboBoxItem { Content = item };
TextSearch.SetText(comboBoxItem, searchTexts[index]);
return comboBoxItem;
}));
}
[Theory]
[InlineData(-1, 1, "c", new[] { "A item", "B item", "C item" }, new[] { "B search", "C search", "A search" })]
[InlineData(0, 2, "baz", new[] { "A item", "B item", "C item" }, new[] { "foo", "bar", "baz" })]
public void TextSearch_With_DisplayMemberBinding_Should_Have_Expected_SelectedIndex(
int initialSelectedIndex,
int expectedSelectedIndex,
string searchTerm,
string[] values,
string[] displays)
{
Assert.Equal(values.Length, displays.Length);
TestTextSearch(
initialSelectedIndex,
expectedSelectedIndex,
searchTerm,
comboBox => comboBox.DisplayMemberBinding = new Binding(nameof(Item.Display)),
values.Select((value, index) => new Item(value, displays[index])));
}
[Theory]
[InlineData(-1, 1, "c", new[] { "A item", "B item", "C item" }, new[] { "B search", "C search", "A search" })]
[InlineData(0, 2, "baz", new[] { "A item", "B item", "C item" }, new[] { "foo", "bar", "baz" })]
public void TextSearch_With_TextSearchBinding_Should_Have_Expected_SelectedIndex(
int initialSelectedIndex,
int expectedSelectedIndex,
string searchTerm,
string[] values,
string[] displays)
{
Assert.Equal(values.Length, displays.Length);
TestTextSearch(
initialSelectedIndex,
expectedSelectedIndex,
searchTerm,
comboBox => TextSearch.SetTextBinding(comboBox, new Binding(nameof(Item.Display))),
values.Select((value, index) => new Item(value, displays[index])));
}
private static void TestTextSearch(
int initialSelectedIndex,
int expectedSelectedIndex,
string searchTerm,
Action<ComboBox> configureComboBox,
IEnumerable<object> itemsSource)
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var target = new ComboBox
{
Template = GetTemplate(),
ItemsSource = items.Select(x => new ComboBoxItem { Content = x }),
Template = GetTemplate(),
ItemsSource = itemsSource.ToArray(),
};
configureComboBox(target);
TestRoot root = new(target)
{
ClientSize = new(500,500),
ClientSize = new(500,500)
};
target.ApplyTemplate();
target.Presenter.ApplyTemplate();
target.SelectedIndex = initialSelectedIndex;
root.LayoutManager.ExecuteInitialLayoutPass();
target.SelectedIndex = initialSelectedIndex;
var args = new TextInputEventArgs
{
@ -284,7 +366,7 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(expectedSelectedIndex, target.SelectedIndex);
}
}
[Fact]
public void SelectedItem_Validation()
{
@ -551,5 +633,7 @@ namespace Avalonia.Controls.UnitTests
target.SelectedItem = null;
Assert.Null(target.SelectionBoxItem);
}
private sealed record Item(string Value, string Display);
}
}

Loading…
Cancel
Save