Browse Source

Make container IsSelected bindings work again.

Rather than using the `ISelectable` interface to communicate container selection from the `SelectingItemsControl` to the container, use the `SelectingItemsControl.IsSelected` attached property, setting it with `SetCurrentValue` so that bindings defined in a style or item container theme can override the selection. Required an extra virtual `ContainerForItemPreparedOverride` method on `ItemsControl`.
pull/10944/head
Steven Kirk 3 years ago
parent
commit
191a835c6a
  1. 13
      src/Avalonia.Controls/ISelectable.cs
  2. 16
      src/Avalonia.Controls/ItemsControl.cs
  3. 68
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  4. 7
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs
  5. 1
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs
  6. 306
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

13
src/Avalonia.Controls/ISelectable.cs

@ -1,16 +1,9 @@
using Avalonia.Controls.Primitives;
namespace Avalonia.Controls
{
/// <summary>
/// Interface for objects that are selectable.
/// An interface that is implemented by objects that expose their selection state via a
/// boolean <see cref="IsSelected"/> property.
/// </summary>
/// <remarks>
/// Controls such as <see cref="SelectingItemsControl"/> use this interface to indicate the
/// selected control in a list. If changing the control's <see cref="IsSelected"/> property
/// should update the selection in a <see cref="SelectingItemsControl"/> or equivalent, then
/// the control should raise the <see cref="SelectingItemsControl.IsSelectedChangedEvent"/>.
/// </remarks>
public interface ISelectable
{
/// <summary>
@ -18,4 +11,4 @@ namespace Avalonia.Controls
/// </summary>
bool IsSelected { get; set; }
}
}
}

16
src/Avalonia.Controls/ItemsControl.cs

@ -424,6 +424,21 @@ namespace Avalonia.Controls
}
}
/// <summary>
/// Called when a container has been fully prepared to display an item.
/// </summary>
/// <param name="container">The container control.</param>
/// <param name="item">The item being displayed.</param>
/// <param name="index">The index of the item being displayed.</param>
/// <remarks>
/// This method will be called when a container has been fully prepared and added to the
/// logical and visual trees, but may be called before a layout pass has completed. It is
/// called immediately before the <see cref="ContainerPrepared"/> event is raised.
/// </remarks>
protected internal virtual void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
}
/// <summary>
/// Called when the index for a container changes due to an insertion or removal in the
/// items collection.
@ -654,6 +669,7 @@ namespace Avalonia.Controls
internal void ItemContainerPrepared(Control container, object? item, int index)
{
ContainerForItemPreparedOverride(container, item, index);
_childIndexChanged?.Invoke(this, new ChildIndexChangedEventArgs(container, index));
ContainerPrepared?.Invoke(this, new(container, index));
}

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

@ -119,9 +119,8 @@ namespace Avalonia.Controls.Primitives
AvaloniaProperty.Register<SelectingItemsControl, bool>(nameof(IsTextSearchEnabled), false);
/// <summary>
/// Event that should be raised by items that implement <see cref="ISelectable"/> to
/// notify the parent <see cref="SelectingItemsControl"/> that their selection state
/// has changed.
/// Event that should be raised by containers when their selection state changes to notify
/// the parent <see cref="SelectingItemsControl"/> that their selection state has changed.
/// </summary>
public static readonly RoutedEvent<RoutedEventArgs> IsSelectedChangedEvent =
RoutedEvent.Register<SelectingItemsControl, RoutedEventArgs>(
@ -496,20 +495,32 @@ namespace Avalonia.Controls.Primitives
}
}
/// <inheritdoc />
protected internal override void PrepareContainerForItemOverride(Control element, object? item, int index)
protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(element, item, index);
base.ContainerForItemPreparedOverride(container, item, index);
if ((element as ISelectable)?.IsSelected == true)
// Once the container has been full prepared and added to the tree, any bindings from
// styles or item container themes are guaranteed to be applied.
if (!container.IsSet(IsSelectedProperty))
{
Selection.Select(index);
MarkContainerSelected(element, true);
// The IsSelected property is not set on the container: update the container
// selection based on the current selection as understood by this control.
MarkContainerSelected(container, Selection.IsSelected(index));
}
else
{
var selected = Selection.IsSelected(index);
MarkContainerSelected(element, selected);
// The IsSelected property is set on the container: there is a style or item
// container theme which has bound the IsSelected property. Update our selection
// based on the selection state of the container.
var containerIsSelected = GetIsSelected(container);
if (containerIsSelected != Selection.IsSelected(index))
{
if (containerIsSelected)
Selection.Select(index);
else
Selection.Deselect(index);
}
}
}
@ -531,8 +542,7 @@ namespace Avalonia.Controls.Primitives
KeyboardNavigation.SetTabOnceActiveElement(panel, null);
}
if (element is ISelectable)
MarkContainerSelected(element, false);
element.ClearValue(IsSelectedProperty);
}
/// <inheritdoc/>
@ -1121,11 +1131,14 @@ namespace Avalonia.Controls.Primitives
{
if (!_ignoreContainerSelectionChanged &&
e.Source is Control control &&
e.Source is ISelectable selectable &&
control.Parent == this &&
IndexFromContainer(control) != -1)
IndexFromContainer(control) is var index &&
index >= 0)
{
UpdateSelection(control, selectable.IsSelected);
if (GetIsSelected(control))
Selection.Select(index);
else
Selection.Deselect(index);
}
if (e.Source != this)
@ -1135,31 +1148,18 @@ namespace Avalonia.Controls.Primitives
}
/// <summary>
/// Sets a container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
/// Sets the <see cref="IsSelectedProperty"/> on the specified container.
/// </summary>
/// <param name="container">The container.</param>
/// <param name="selected">Whether the control is selected</param>
/// <returns>The previous selection state.</returns>
private bool MarkContainerSelected(Control container, bool selected)
private void MarkContainerSelected(Control container, bool selected)
{
_ignoreContainerSelectionChanged = true;
try
{
bool result;
_ignoreContainerSelectionChanged = true;
if (container is ISelectable selectable)
{
result = selectable.IsSelected;
selectable.IsSelected = selected;
}
else
{
result = container.Classes.Contains(":selected");
((IPseudoClasses)container.Classes).Set(":selected", selected);
}
return result;
container.SetCurrentValue(IsSelectedProperty, selected);
}
finally
{

7
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@ -2170,7 +2170,12 @@ namespace Avalonia.Controls.UnitTests.Primitives
private class Item : Control, ISelectable
{
public string Value { get; set; }
public bool IsSelected { get; set; }
public bool IsSelected
{
get => SelectingItemsControl.GetIsSelected(this);
set => SelectingItemsControl.SetIsSelected(this, value);
}
}
private class MasterViewModel : NotifyingBase

1
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs

@ -116,7 +116,6 @@ namespace Avalonia.Controls.UnitTests.Primitives
Assert.Equal(0, target.SelectedIndex);
Assert.Equal("bar", target.SelectedItem);
Assert.Equal(new[] { ":selected" }, target.Presenter.Panel.Children[0].Classes);
}
private static FuncControlTemplate Template()

306
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Avalonia.Collections;
using Avalonia.Controls.Mixins;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Selection;
@ -134,7 +135,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
target.SelectedItems.Add("foo");
bool raised = false;
target.PropertyChanged += (s, e) =>
target.PropertyChanged += (s, e) =>
raised |= e.Property.Name == "SelectedIndex" ||
e.Property.Name == "SelectedItem";
@ -152,9 +153,9 @@ namespace Avalonia.Controls.UnitTests.Primitives
target.SelectedItems.Add("foo");
bool raised = false;
target.PropertyChanged += (s, e) =>
raised |= e.Property.Name == "SelectedIndex" &&
(int)e.OldValue! == 0 &&
target.PropertyChanged += (s, e) =>
raised |= e.Property.Name == "SelectedIndex" &&
(int)e.OldValue! == 0 &&
(int)e.NewValue! == -1;
target.SelectedItems.RemoveAt(0);
@ -708,14 +709,14 @@ namespace Avalonia.Controls.UnitTests.Primitives
using var app = Start();
var items = new[]
{
new ItemContainer(),
new ItemContainer(),
new TestContainer(),
new TestContainer(),
};
var target = CreateTarget(items: items);
target.Items.Add(new ItemContainer { IsSelected = true });
target.Items.Add(new ItemContainer { IsSelected = true });
target.Items.Add(new TestContainer { IsSelected = true });
target.Items.Add(new TestContainer { IsSelected = true });
Assert.Equal(2, target.SelectedIndex);
Assert.Equal(target.Items[2], target.SelectedItem);
@ -867,12 +868,169 @@ namespace Avalonia.Controls.UnitTests.Primitives
Assert.Equal(2, raised);
}
[Fact]
public void Can_Bind_Initial_Selected_State_Via_ItemContainerTheme()
{
using var app = Start();
var items = new ItemViewModel[] { new("Item 0", true), new("Item 1", false), new("Item 2", true) };
var itemTheme = new ControlTheme(typeof(ContentPresenter))
{
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(itemsSource: items, itemContainerTheme: itemTheme);
Assert.Equal(new[] { 0, 2 }, SelectedContainers(target));
Assert.Equal(0, target.SelectedIndex);
Assert.Equal(items[0], target.SelectedItem);
Assert.Equal(new[] { 0, 2 }, target.Selection.SelectedIndexes);
Assert.Equal(new[] { items[0], items[2] }, target.Selection.SelectedItems);
}
[Fact]
public void Can_Bind_Initial_Selected_State_Via_Style()
{
using var app = Start();
var items = new ItemViewModel[] { new("Item 0", true), new("Item 1", false), new("Item 2", true) };
var style = new Style(x => x.OfType<ContentPresenter>())
{
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(itemsSource: items, styles: new[] { style });
Assert.Equal(new[] { 0, 2 }, SelectedContainers(target));
Assert.Equal(0, target.SelectedIndex);
Assert.Equal(items[0], target.SelectedItem);
Assert.Equal(new[] { 0, 2 }, target.Selection.SelectedIndexes);
Assert.Equal(new[] { items[0], items[2] }, target.Selection.SelectedItems);
}
[Fact]
public void Selection_State_Is_Updated_Via_IsSelected_Binding()
{
using var app = Start();
var items = new ItemViewModel[] { new("Item 0", true), new("Item 1", false), new("Item 2", true) };
var itemTheme = new ControlTheme(typeof(TestContainer))
{
BasedOn = CreateTestContainerTheme(),
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
// For the container selection state to be communicated back to the SelectingItemsControl
// we need a container which raises the SelectingItemsControl.IsSelectedChangedEvent when
// the IsSelected property changes.
var target = CreateTarget<TestSelectorWithContainers>(
itemsSource: items,
itemContainerTheme: itemTheme);
items[1].IsSelected = true;
Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
Assert.Equal(0, target.SelectedIndex);
Assert.Equal(items[0], target.SelectedItem);
Assert.Equal(new[] { 0, 1, 2 }, target.Selection.SelectedIndexes);
Assert.Equal(new[] { items[0], items[1], items[2] }, target.Selection.SelectedItems);
items[0].IsSelected = false;
Assert.Equal(new[] { 1, 2 }, SelectedContainers(target));
Assert.Equal(1, target.SelectedIndex);
Assert.Equal(items[1], target.SelectedItem);
Assert.Equal(new[] { 1, 2 }, target.Selection.SelectedIndexes);
Assert.Equal(new[] { items[1], items[2] }, target.Selection.SelectedItems);
}
[Fact]
public void Selection_State_Is_Written_Back_To_Item_Via_IsSelected_Binding()
{
using var app = Start();
var items = new ItemViewModel[] { new("Item 0", true), new("Item 1", false), new("Item 2", true) };
var itemTheme = new ControlTheme(typeof(ContentPresenter))
{
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
}
};
var target = CreateTarget(itemsSource: items, itemContainerTheme: itemTheme);
var container0 = Assert.IsAssignableFrom<Control>(target.ContainerFromIndex(0));
var container1 = Assert.IsAssignableFrom<Control>(target.ContainerFromIndex(1));
SelectingItemsControl.SetIsSelected(container1, true);
Assert.True(items[1].IsSelected);
SelectingItemsControl.SetIsSelected(container0, false);
Assert.False(items[0].IsSelected);
}
[Fact]
public void Selection_State_Change_On_Unrealized_Item_Is_Respected_With_IsSelected_Binding()
{
using var app = Start();
var items = Enumerable.Range(0, 100).Select(x => new ItemViewModel($"Item {x}", false)).ToList();
var itemTheme = new ControlTheme(typeof(ContentPresenter))
{
Setters =
{
new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")),
new Setter(Control.HeightProperty, 100.0),
}
};
// Create a SelectingItemsControl with a virtualizing stack panel.
var target = CreateTarget(itemsSource: items, itemContainerTheme: itemTheme, virtualizing: true);
var panel = Assert.IsType<VirtualizingStackPanel>(target.ItemsPanelRoot);
var scroll = panel.FindAncestorOfType<ScrollViewer>()!;
// Scroll item 1 out of view.
scroll.Offset = new(0, 1000);
Layout(target);
Assert.Equal(10, panel.FirstRealizedIndex);
Assert.Equal(19, panel.LastRealizedIndex);
// Select item 1 now it's unrealized.
items[1].IsSelected = true;
// The SelectingItemsControl does not yet know anything about the selection change.
Assert.Empty(SelectedContainers(target));
Assert.Equal(-1, target.SelectedIndex);
Assert.Null(target.SelectedItem);
Assert.Empty(target.Selection.SelectedIndexes);
Assert.Empty(target.Selection.SelectedItems);
// Scroll item 1 back into view.
scroll.Offset = new(0, 0);
Layout(target);
// The item and container should be marked as selected.
Assert.True(items[1].IsSelected);
Assert.Equal(new[] { 1 }, SelectedContainers(target));
Assert.Equal(1, target.SelectedIndex);
Assert.Equal(items[1], target.SelectedItem);
Assert.Equal(new[] { 1 }, target.Selection.SelectedIndexes);
Assert.Equal(new[] { items[1] }, target.Selection.SelectedItems);
}
private static IEnumerable<int> SelectedContainers(SelectingItemsControl target)
{
Assert.NotNull(target.ItemsPanel);
return target.ItemsPanelRoot!.Children
.Select(x => x.Classes.Contains(":selected") ? target.IndexFromContainer(x) : -1)
.Select(x => SelectingItemsControl.GetIsSelected(x) ? target.IndexFromContainer(x) : -1)
.Where(x => x != -1);
}
@ -882,9 +1040,33 @@ namespace Avalonia.Controls.UnitTests.Primitives
IList? itemsSource = null,
ControlTheme? itemContainerTheme = null,
IDataTemplate? itemTemplate = null,
bool performLayout = true)
IEnumerable<Style>? styles = null,
bool performLayout = true,
bool virtualizing = false)
{
var target = new TestSelector
return CreateTarget<TestSelector>(
dataContext: dataContext,
items: items,
itemsSource: itemsSource,
itemContainerTheme: itemContainerTheme,
itemTemplate: itemTemplate,
styles: styles,
performLayout: performLayout,
virtualizing: virtualizing);
}
private static T CreateTarget<T>(
object? dataContext = null,
IList? items = null,
IList? itemsSource = null,
ControlTheme? itemContainerTheme = null,
IDataTemplate? itemTemplate = null,
IEnumerable<Style>? styles = null,
bool performLayout = true,
bool virtualizing = false)
where T : TestSelector, new()
{
var target = new T
{
DataContext = dataContext,
ItemContainerTheme = itemContainerTheme,
@ -899,8 +1081,17 @@ namespace Avalonia.Controls.UnitTests.Primitives
target.Items.Add(item);
}
if (virtualizing)
target.ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel());
var root = CreateRoot(target);
if (styles is not null)
{
foreach (var style in styles)
root.Styles.Add(style);
}
if (performLayout)
root.LayoutManager.ExecuteInitialLayoutPass();
@ -914,6 +1105,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
Resources =
{
{ typeof(TestSelector), CreateTestSelectorControlTheme() },
{ typeof(TestContainer), CreateTestContainerTheme() },
{ typeof(ScrollViewer), CreateScrollViewerTheme() },
},
Child = child,
@ -951,6 +1143,28 @@ namespace Avalonia.Controls.UnitTests.Primitives
});
}
private static ControlTheme CreateTestContainerTheme()
{
return new ControlTheme(typeof(TestContainer))
{
Setters =
{
new Setter(TreeView.TemplateProperty, CreateTestContainerTemplate()),
},
};
}
private static FuncControlTemplate CreateTestContainerTemplate()
{
return new FuncControlTemplate<TestContainer>((parent, scope) =>
new ContentPresenter
{
Name = "PART_ContentPresenter",
[!ContentPresenter.ContentProperty] = parent[!TestContainer.ContentProperty],
[!ContentPresenter.ContentTemplateProperty] = parent[!TestContainer.ContentTemplateProperty],
}.RegisterInNameScope(scope));
}
private static ControlTheme CreateScrollViewerTheme()
{
return new ControlTheme(typeof(ScrollViewer))
@ -1009,7 +1223,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
private class TestSelector : SelectingItemsControl
{
public static readonly new AvaloniaProperty<IList?> SelectedItemsProperty =
public static readonly new AvaloniaProperty<IList?> SelectedItemsProperty =
SelectingItemsControl.SelectedItemsProperty;
public static readonly new DirectProperty<SelectingItemsControl, ISelectionModel> SelectionProperty =
SelectingItemsControl.SelectionProperty;
@ -1043,6 +1257,66 @@ namespace Avalonia.Controls.UnitTests.Primitives
public void Toggle(int index) => UpdateSelection(index, true, false, true);
}
private class TestSelectorWithContainers : TestSelector, IStyleable
{
Type IStyleable.StyleKey => typeof(TestSelector);
protected internal override bool IsItemItsOwnContainerOverride(Control item)
{
return item is TestContainer;
}
protected internal override Control CreateContainerForItemOverride()
{
return new TestContainer();
}
}
private class TestContainer : ContentControl, ISelectable
{
public static readonly StyledProperty<bool> IsSelectedProperty =
SelectingItemsControl.IsSelectedProperty.AddOwner<TestContainer>();
static TestContainer()
{
SelectableMixin.Attach<TestContainer>(SelectingItemsControl.IsSelectedProperty);
}
public bool IsSelected
{
get => GetValue(IsSelectedProperty);
set => SetValue(IsSelectedProperty, value);
}
}
private class ItemViewModel : NotifyingBase
{
private bool _isSelected;
public ItemViewModel(string value, bool isSelected = false)
{
Value = value;
_isSelected = isSelected;
}
public string Value { get; set; }
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected != value)
{
_isSelected = value;
RaisePropertyChanged();
}
}
}
public override string ToString() => Value;
}
private class OldDataContextViewModel
{
public OldDataContextViewModel()
@ -1052,15 +1326,9 @@ namespace Avalonia.Controls.UnitTests.Primitives
Selection = new SelectionModel<string>();
}
public List<string> Items { get; }
public List<string> Items { get; }
public List<string> SelectedItems { get; }
public SelectionModel<string> Selection { get; }
}
private class ItemContainer : Control, ISelectable
{
public string? Value { get; set; }
public bool IsSelected { get; set; }
}
}
}

Loading…
Cancel
Save