Browse Source

Merge pull request #10180 from amwx/feat/SelectedValue_SelectedValueBinding

Implement `SelectedValue` and `SelectedValueBinding` for SelectingItemsControls
pull/10284/head
Max Katz 3 years ago
committed by GitHub
parent
commit
2348e13586
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 31
      src/Avalonia.Controls/ItemsControl.cs
  2. 284
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  3. 330
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_SelectedValue.cs

31
src/Avalonia.Controls/ItemsControl.cs

@ -2,7 +2,6 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Automation.Peers;
using Avalonia.Collections;
using Avalonia.Controls.Generators;
@ -17,7 +16,6 @@ using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Metadata;
using Avalonia.Styling;
using Avalonia.VisualTree;
namespace Avalonia.Controls
{
@ -91,10 +89,11 @@ namespace Avalonia.Controls
/// Gets or sets the <see cref="IBinding"/> to use for binding to the display member of each item.
/// </summary>
[AssignBinding]
[InheritDataTypeFromItems(nameof(Items))]
public IBinding? DisplayMemberBinding
{
get { return GetValue(DisplayMemberBindingProperty); }
set { SetValue(DisplayMemberBindingProperty, value); }
get => GetValue(DisplayMemberBindingProperty);
set => SetValue(DisplayMemberBindingProperty, value);
}
private IEnumerable? _items = new AvaloniaList<object>();
@ -134,8 +133,8 @@ namespace Avalonia.Controls
[Content]
public IEnumerable? Items
{
get { return _items; }
set { SetAndRaise(ItemsProperty, ref _items, value); }
get => _items;
set => SetAndRaise(ItemsProperty, ref _items, value);
}
/// <summary>
@ -143,8 +142,8 @@ namespace Avalonia.Controls
/// </summary>
public ControlTheme? ItemContainerTheme
{
get { return GetValue(ItemContainerThemeProperty); }
set { SetValue(ItemContainerThemeProperty, value); }
get => GetValue(ItemContainerThemeProperty);
set => SetValue(ItemContainerThemeProperty, value);
}
/// <summary>
@ -161,8 +160,8 @@ namespace Avalonia.Controls
/// </summary>
public ITemplate<Panel> ItemsPanel
{
get { return GetValue(ItemsPanelProperty); }
set { SetValue(ItemsPanelProperty, value); }
get => GetValue(ItemsPanelProperty);
set => SetValue(ItemsPanelProperty, value);
}
/// <summary>
@ -171,8 +170,8 @@ namespace Avalonia.Controls
[InheritDataTypeFromItems(nameof(Items))]
public IDataTemplate? ItemTemplate
{
get { return GetValue(ItemTemplateProperty); }
set { SetValue(ItemTemplateProperty, value); }
get => GetValue(ItemTemplateProperty);
set => SetValue(ItemTemplateProperty, value);
}
/// <summary>
@ -264,8 +263,8 @@ namespace Avalonia.Controls
/// </summary>
public bool AreHorizontalSnapPointsRegular
{
get { return GetValue(AreHorizontalSnapPointsRegularProperty); }
set { SetValue(AreHorizontalSnapPointsRegularProperty, value); }
get => GetValue(AreHorizontalSnapPointsRegularProperty);
set => SetValue(AreHorizontalSnapPointsRegularProperty, value);
}
/// <summary>
@ -273,8 +272,8 @@ namespace Avalonia.Controls
/// </summary>
public bool AreVerticalSnapPointsRegular
{
get { return GetValue(AreVerticalSnapPointsRegularProperty); }
set { SetValue(AreVerticalSnapPointsRegularProperty, value); }
get => GetValue(AreVerticalSnapPointsRegularProperty);
set => SetValue(AreVerticalSnapPointsRegularProperty, value);
}
/// <summary>

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

@ -4,15 +4,14 @@ using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Xml.Linq;
using Avalonia.Controls.Generators;
using Avalonia.Controls.Selection;
using Avalonia.Controls.Utils;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Metadata;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace Avalonia.Controls.Primitives
{
@ -66,6 +65,19 @@ namespace Avalonia.Controls.Primitives
(o, v) => o.SelectedItem = v,
defaultBindingMode: BindingMode.TwoWay, enableDataValidation: true);
/// <summary>
/// Defines the <see cref="SelectedValue"/> property
/// </summary>
public static readonly StyledProperty<object?> SelectedValueProperty =
AvaloniaProperty.Register<SelectingItemsControl, object?>(nameof(SelectedValue),
defaultBindingMode: BindingMode.TwoWay);
/// <summary>
/// Defines the <see cref="SelectedValueBinding"/> property
/// </summary>
public static readonly StyledProperty<IBinding?> SelectedValueBindingProperty =
AvaloniaProperty.Register<SelectingItemsControl, IBinding?>(nameof(SelectedValueBinding));
/// <summary>
/// Defines the <see cref="SelectedItems"/> property.
/// </summary>
@ -129,6 +141,8 @@ namespace Avalonia.Controls.Primitives
private bool _ignoreContainerSelectionChanged;
private UpdateState? _updateState;
private bool _hasScrolledToSelectedItem;
private BindingHelper? _bindingHelper;
private bool _isSelectionChangeActive;
/// <summary>
/// Initializes static members of the <see cref="SelectingItemsControl"/> class.
@ -143,8 +157,8 @@ namespace Avalonia.Controls.Primitives
/// </summary>
public event EventHandler<SelectionChangedEventArgs>? SelectionChanged
{
add { AddHandler(SelectionChangedEvent, value); }
remove { RemoveHandler(SelectionChangedEvent, value); }
add => AddHandler(SelectionChangedEvent, value);
remove => RemoveHandler(SelectionChangedEvent, value);
}
/// <summary>
@ -152,8 +166,8 @@ namespace Avalonia.Controls.Primitives
/// </summary>
public bool AutoScrollToSelectedItem
{
get { return GetValue(AutoScrollToSelectedItemProperty); }
set { SetValue(AutoScrollToSelectedItemProperty, value); }
get => GetValue(AutoScrollToSelectedItemProperty);
set => SetValue(AutoScrollToSelectedItemProperty, value);
}
/// <summary>
@ -209,6 +223,28 @@ namespace Avalonia.Controls.Primitives
}
}
/// <summary>
/// Gets the <see cref="IBinding"/> instance used to obtain the
/// <see cref="SelectedValue"/> property
/// </summary>
[AssignBinding]
[InheritDataTypeFromItems(nameof(Items))]
public IBinding? SelectedValueBinding
{
get => GetValue(SelectedValueBindingProperty);
set => SetValue(SelectedValueBindingProperty, value);
}
/// <summary>
/// Gets or sets the value of the selected item, obtained using
/// <see cref="SelectedValueBinding"/>
/// </summary>
public object? SelectedValue
{
get => GetValue(SelectedValueProperty);
set => SetValue(SelectedValueProperty, value);
}
/// <summary>
/// Gets or sets the selected items.
/// </summary>
@ -322,8 +358,8 @@ namespace Avalonia.Controls.Primitives
/// </summary>
public bool IsTextSearchEnabled
{
get { return GetValue(IsTextSearchEnabledProperty); }
set { SetValue(IsTextSearchEnabledProperty, value); }
get => GetValue(IsTextSearchEnabledProperty);
set => SetValue(IsTextSearchEnabledProperty, value);
}
/// <summary>
@ -332,8 +368,8 @@ namespace Avalonia.Controls.Primitives
/// </summary>
public bool WrapSelection
{
get { return GetValue(WrapSelectionProperty); }
set { SetValue(WrapSelectionProperty, value); }
get => GetValue(WrapSelectionProperty);
set => SetValue(WrapSelectionProperty, value);
}
/// <summary>
@ -345,8 +381,8 @@ namespace Avalonia.Controls.Primitives
/// </remarks>
protected SelectionMode SelectionMode
{
get { return GetValue(SelectionModeProperty); }
set { SetValue(SelectionModeProperty, value); }
get => GetValue(SelectionModeProperty);
set => SetValue(SelectionModeProperty, value);
}
/// <summary>
@ -609,6 +645,60 @@ namespace Avalonia.Controls.Primitives
{
WrapFocus = WrapSelection;
}
else if (change.Property == SelectedValueProperty)
{
if (_isSelectionChangeActive)
return;
if (_updateState is not null)
{
_updateState.SelectedValue = change.NewValue;
return;
}
SelectItemWithValue(change.NewValue);
}
else if (change.Property == SelectedValueBindingProperty)
{
var idx = SelectedIndex;
// If no selection is active, don't do anything as SelectedValue is already null
if (idx == -1)
{
return;
}
var value = change.GetNewValue<IBinding>();
if (value is null)
{
// Clearing SelectedValueBinding makes the SelectedValue the item itself
SelectedValue = SelectedItem;
return;
}
var selectedItem = SelectedItem;
try
{
_isSelectionChangeActive = true;
if (_bindingHelper is null)
{
_bindingHelper = new BindingHelper(value);
}
else
{
_bindingHelper.UpdateBinding(value);
}
// Re-evaluate SelectedValue with the new binding
SelectedValue = _bindingHelper.Evaluate(selectedItem);
}
finally
{
_isSelectionChangeActive = false;
}
}
}
/// <summary>
@ -815,6 +905,10 @@ namespace Avalonia.Controls.Primitives
new BindingValue<IList?>(SelectedItems));
_oldSelectedItems = SelectedItems;
}
else if (e.PropertyName == nameof(ISelectionModel.Source))
{
ClearValue(SelectedValueProperty);
}
}
/// <summary>
@ -845,6 +939,11 @@ namespace Avalonia.Controls.Primitives
Mark(i, false);
}
if (!_isSelectionChangeActive)
{
UpdateSelectedValueFromItem();
}
var route = BuildEventRoute(SelectionChangedEvent);
if (route.HasHandlers)
@ -871,6 +970,109 @@ namespace Avalonia.Controls.Primitives
}
}
private void SelectItemWithValue(object? value)
{
if (ItemCount == 0 || _isSelectionChangeActive)
return;
try
{
_isSelectionChangeActive = true;
var si = FindItemWithValue(value);
if (si != AvaloniaProperty.UnsetValue)
{
SelectedItem = si;
}
else
{
SelectedItem = null;
}
}
finally
{
_isSelectionChangeActive = false;
}
}
private object FindItemWithValue(object? value)
{
if (ItemCount == 0 || value is null)
{
return AvaloniaProperty.UnsetValue;
}
var items = Items;
var binding = SelectedValueBinding;
if (binding is null)
{
// No SelectedValueBinding set, SelectedValue is the item itself
// Still verify the value passed in is in the Items list
var index = items!.IndexOf(value);
if (index >= 0)
{
return value;
}
else
{
return AvaloniaProperty.UnsetValue;
}
}
_bindingHelper ??= new BindingHelper(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);
if (itemValue.Equals(value))
{
return item;
}
}
return AvaloniaProperty.UnsetValue;
}
private void UpdateSelectedValueFromItem()
{
if (_isSelectionChangeActive)
return;
var binding = SelectedValueBinding;
var item = SelectedItem;
if (binding is null || item is null)
{
// No SelectedValueBinding, SelectedValue is Item itself
try
{
_isSelectionChangeActive = true;
SelectedValue = item;
}
finally
{
_isSelectionChangeActive = false;
}
return;
}
_bindingHelper ??= new BindingHelper(binding);
try
{
_isSelectionChangeActive = true;
SelectedValue = _bindingHelper.Evaluate(item);
}
finally
{
_isSelectionChangeActive = false;
}
}
private void AutoScrollToSelectedItemIfNecessary()
{
if (AutoScrollToSelectedItem &&
@ -1037,6 +1239,13 @@ namespace Avalonia.Controls.Primitives
Selection.Clear();
}
if (state.SelectedValue.HasValue)
{
var item = FindItemWithValue(state.SelectedValue.Value);
if (item != AvaloniaProperty.UnsetValue)
state.SelectedItem = item;
}
if (state.SelectedIndex.HasValue)
{
SelectedIndex = state.SelectedIndex.Value;
@ -1098,6 +1307,7 @@ namespace Avalonia.Controls.Primitives
{
private Optional<int> _selectedIndex;
private Optional<object?> _selectedItem;
private Optional<object?> _selectedValue;
public int UpdateCount { get; set; }
public Optional<ISelectionModel> Selection { get; set; }
@ -1122,6 +1332,54 @@ namespace Avalonia.Controls.Primitives
_selectedIndex = default;
}
}
public Optional<object?> SelectedValue
{
get => _selectedValue;
set
{
_selectedValue = value;
}
}
}
/// <summary>
/// Helper class for evaluating a binding from an Item and IBinding instance
/// </summary>
private class BindingHelper : StyledElement
{
public BindingHelper(IBinding binding)
{
UpdateBinding(binding);
}
public static readonly StyledProperty<object> ValueProperty =
AvaloniaProperty.Register<BindingHelper, object>("Value");
public object Evaluate(object? dataContext)
{
dataContext = dataContext ?? throw new ArgumentNullException(nameof(dataContext));
// Only update the DataContext if necessary
if (!dataContext.Equals(DataContext))
DataContext = dataContext;
return GetValue(ValueProperty);
}
public void UpdateBinding(IBinding binding)
{
_lastBinding = binding;
var ib = binding.Initiate(this, ValueProperty);
if (ib is null)
{
throw new InvalidOperationException("Unable to create binding");
}
BindingOperations.Apply(this, ValueProperty, ib, null);
}
private IBinding? _lastBinding;
}
}
}

330
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_SelectedValue.cs

@ -0,0 +1,330 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Styling;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Controls.UnitTests.Primitives
{
public class SelectingItemsControlTests_SelectedValue
{
[Fact]
public void Setting_SelectedItem_Sets_SelectedValue()
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
SelectedValueBinding = new Binding("Name"),
Template = Template()
};
sic.SelectedItem = items[0];
Assert.Equal(items[0].Name, sic.SelectedValue);
}
[Fact]
public void Setting_SelectedIndex_Sets_SelectedValue()
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
SelectedValueBinding = new Binding("Name"),
Template = Template()
};
sic.SelectedIndex = 0;
Assert.Equal(items[0].Name, sic.SelectedValue);
}
[Fact]
public void Setting_SelectedItems_Sets_SelectedValue()
{
var items = TestClass.GetItems();
var sic = new ListBox
{
Items = items,
SelectedValueBinding = new Binding("Name"),
Template = Template()
};
sic.SelectedItems = new List<TestClass>
{
items[1],
items[3],
items[4]
};
// When interacting, SelectedItem is the first item in the SelectedItems collection
// But when set here, it's the last
Assert.Equal(items[4].Name, sic.SelectedValue);
}
[Fact]
public void Setting_SelectedValue_Sets_SelectedIndex()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
SelectedValueBinding = new Binding("Name"),
Template = Template()
};
Prepare(sic);
sic.SelectedValue = items[1].Name;
Assert.Equal(1, sic.SelectedIndex);
}
}
[Fact]
public void Setting_SelectedValue_Sets_SelectedItem()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
SelectedValueBinding = new Binding("Name"),
Template = Template()
};
Prepare(sic);
sic.SelectedValue = "Item2";
Assert.Equal(items[1], sic.SelectedItem);
}
}
[Fact]
public void Changing_SelectedValueBinding_Updates_SelectedValue()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
SelectedValueBinding = new Binding("Name"),
Template = Template()
};
sic.SelectedValue = "Item2";
sic.SelectedValueBinding = new Binding("AltProperty");
// Ensure SelectedItem didn't change
Assert.Equal(items[1], sic.SelectedItem);
Assert.Equal("Alt2", sic.SelectedValue);
}
}
[Fact]
public void SelectedValue_With_Null_SelectedValueBinding_Is_Item()
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
Template = Template()
};
sic.SelectedIndex = 0;
Assert.Equal(items[0], sic.SelectedValue);
}
[Fact]
public void Setting_SelectedValue_Before_Initialize_Should_Retain_Selection()
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
Template = Template(),
SelectedValueBinding = new Binding("Name"),
SelectedValue = "Item2"
};
sic.BeginInit();
sic.EndInit();
Assert.Equal(items[1].Name, sic.SelectedValue);
}
[Fact]
public void Setting_SelectedValue_During_Initialize_Should_Take_Priority_Over_Previous_Value()
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
Template = Template(),
SelectedValueBinding = new Binding("Name"),
SelectedValue = "Item2"
};
sic.BeginInit();
sic.SelectedValue = "Item1";
sic.EndInit();
Assert.Equal(items[0].Name, sic.SelectedValue);
}
[Fact]
public void Changing_Items_Should_Clear_SelectedValue()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
Template = Template(),
SelectedValueBinding = new Binding("Name"),
SelectedValue = "Item2"
};
Prepare(sic);
sic.Items = new List<TestClass>
{
new TestClass("NewItem", string.Empty)
};
Assert.Equal(null, sic.SelectedValue);
}
}
[Fact]
public void Setting_SelectedValue_Should_Raise_SelectionChanged_Event()
{
// Unlike SelectedIndex/SelectedItem tests, we need the ItemsControl to
// initialize so that SelectedValue can actually be looked up
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
Template = Template(),
SelectedValueBinding = new Binding("Name"),
};
Prepare(sic);
var called = false;
sic.SelectionChanged += (s, e) =>
{
Assert.Same(items[1], e.AddedItems.Cast<object>().Single());
Assert.Empty(e.RemovedItems);
called = true;
};
sic.SelectedValue = "Item2";
Assert.True(called);
}
}
[Fact]
public void Clearing_SelectedValue_Should_Raise_SelectionChanged_Event()
{
var items = TestClass.GetItems();
var sic = new SelectingItemsControl
{
Items = items,
Template = Template(),
SelectedValueBinding = new Binding("Name"),
SelectedValue = "Item2"
};
var called = false;
sic.SelectionChanged += (s, e) =>
{
Assert.Same(items[1], e.RemovedItems.Cast<object>().Single());
Assert.Empty(e.AddedItems);
called = true;
};
sic.SelectedValue = null;
Assert.True(called);
}
private static FuncControlTemplate Template()
{
return new FuncControlTemplate<SelectingItemsControl>((control, scope) =>
new ItemsPresenter
{
Name = "itemsPresenter",
[~ItemsPresenter.ItemsPanelProperty] = control[~ItemsControl.ItemsPanelProperty],
}.RegisterInNameScope(scope));
}
private static void Prepare(SelectingItemsControl target)
{
var root = new TestRoot
{
Child = target,
Width = 100,
Height = 100,
Styles =
{
new Style(x => x.Is<SelectingItemsControl>())
{
Setters =
{
new Setter(ListBox.TemplateProperty, Template()),
},
},
},
};
root.LayoutManager.ExecuteInitialLayoutPass();
}
}
internal class TestClass
{
public TestClass(string name, string alt)
{
Name = name;
AltProperty = alt;
}
public string Name { get; set; }
public string AltProperty { get; set; }
public static List<TestClass> GetItems()
{
return new List<TestClass>
{
new TestClass("Item1", "Alt1"),
new TestClass("Item2", "Alt2"),
new TestClass("Item3", "Alt3"),
new TestClass("Item4", "Alt4"),
new TestClass("Item5", "Alt5"),
};
}
}
}
Loading…
Cancel
Save