diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index b924a3763d..663a315732 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -309,20 +309,9 @@ namespace Avalonia.Controls.Primitives { get { - if (_updateState?.Selection.HasValue == true) - { - return _updateState.Selection.Value; - } - else - { - if (_selection is null) - { - _selection = CreateDefaultSelectionModel(); - InitializeSelectionModel(_selection); - } - - return _selection; - } + return _updateState?.Selection.HasValue == true ? + _updateState.Selection.Value : + GetOrCreateSelectionModel(); } set { @@ -495,6 +484,17 @@ namespace Avalonia.Controls.Primitives } } + protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index) + { + // Ensure that the selection model is created at this point so that accessing it in + // ContainerForItemPreparedOverride doesn't cause it to be initialized (which can + // make containers become deselected when they're synced with the empty selection + // mode). + GetOrCreateSelectionModel(); + + base.PrepareContainerForItemOverride(container, item, index); + } + protected internal override void ContainerForItemPreparedOverride(Control container, object? item, int index) { base.ContainerForItemPreparedOverride(container, item, index); @@ -513,14 +513,7 @@ namespace Avalonia.Controls.Primitives // 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); - } + UpdateSelection(index, containerIsSelected, toggleModifier: true); } } @@ -907,6 +900,17 @@ namespace Avalonia.Controls.Primitives return false; } + private ISelectionModel GetOrCreateSelectionModel() + { + if (_selection is null) + { + _selection = CreateDefaultSelectionModel(); + InitializeSelectionModel(_selection); + } + + return _selection; + } + private void OnItemsViewSourceChanged(object? sender, EventArgs e) { if (_selection is not null && _updateState is null) diff --git a/src/Avalonia.Controls/TreeView.cs b/src/Avalonia.Controls/TreeView.cs index b2a90229f4..e3a9a05951 100644 --- a/src/Avalonia.Controls/TreeView.cs +++ b/src/Avalonia.Controls/TreeView.cs @@ -494,27 +494,18 @@ namespace Avalonia.Controls // 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(SelectingItemsControl.IsSelectedProperty)) - { - // 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, SelectedItems.Contains(item)); - } - else + if (container.IsSet(SelectingItemsControl.IsSelectedProperty)) { // 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 = SelectingItemsControl.GetIsSelected(container); - - if (containerIsSelected != SelectedItems.Contains(item)) - { - if (containerIsSelected) - SelectedItems.Add(item); - else - SelectedItems.Remove(item); - } + UpdateSelectionFromContainer(container, select: containerIsSelected, toggleModifier: 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, SelectedItems.Contains(item)); } /// @@ -681,7 +672,11 @@ namespace Avalonia.Controls var multi = mode.HasAllFlags(SelectionMode.Multiple); var range = multi && rangeModifier && selectedContainer != null; - if (rightButton) + if (!select) + { + SelectedItems.Remove(item); + } + else if (rightButton) { if (!SelectedItems.Contains(item)) { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index f34d090122..c308a9cc92 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -976,6 +976,54 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.False(items[0].IsSelected); } + [Fact] + public void Selection_Is_Updated_On_Container_Realization_With_IsSelected_Binding() + { + using var app = Start(); + var items = Enumerable.Range(0, 100).Select(x => new ItemViewModel($"Item {x}", false)).ToList(); + items[0].IsSelected = true; + items[15].IsSelected = true; + + 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(target.ItemsPanelRoot); + var scroll = panel.FindAncestorOfType()!; + + // The SelectingItemsControl does not yet know anything about item 15's selection state. + Assert.Equal(new[] { 0 }, SelectedContainers(target)); + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(items[0], target.SelectedItem); + Assert.Equal(new[] { 0 }, target.Selection.SelectedIndexes); + Assert.Equal(new[] { items[0] }, target.Selection.SelectedItems); + + // Scroll item 15 into view. + scroll.Offset = new(0, 1000); + Layout(target); + + Assert.Equal(10, panel.FirstRealizedIndex); + Assert.Equal(19, panel.LastRealizedIndex); + + // The final selection should be in place. + Assert.True(items[0].IsSelected); + Assert.True(items[15].IsSelected); + Assert.Equal(0, target.SelectedIndex); + Assert.Equal(items[0], target.SelectedItem); + Assert.Equal(new[] { 0, 15 }, target.Selection.SelectedIndexes); + Assert.Equal(new[] { items[0], items[15] }, target.Selection.SelectedItems); + + // Although item 0 is selected, it's not realized. + Assert.Equal(new[] { 15 }, SelectedContainers(target)); + } + [Fact] public void Selection_State_Change_On_Unrealized_Item_Is_Respected_With_IsSelected_Binding() { diff --git a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs index b2cf51629a..baf8ad5c0e 100644 --- a/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TreeViewTests.cs @@ -1279,7 +1279,7 @@ namespace Avalonia.Controls.UnitTests } }; - var target = CreateTarget(data: data, itemContainerTheme: itemTheme); + var target = CreateTarget(data: data, itemContainerTheme: itemTheme, multiSelect: true); AssertDataSelection(data, selected); AssertContainerSelection(target, selected); @@ -1305,7 +1305,7 @@ namespace Avalonia.Controls.UnitTests } }; - var target = CreateTarget(data: data, styles: new[] { style }); + var target = CreateTarget(data: data, multiSelect: true, styles: new[] { style }); AssertDataSelection(data, selected); AssertContainerSelection(target, selected); @@ -1331,7 +1331,7 @@ namespace Avalonia.Controls.UnitTests } }; - var target = CreateTarget(data: data, itemContainerTheme: itemTheme); + var target = CreateTarget(data: data, itemContainerTheme: itemTheme, multiSelect: true); selected[1].IsSelected = true; @@ -1341,6 +1341,93 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(selected, target.SelectedItems); } + [Fact] + public void Selection_State_Is_Updated_Via_IsSelected_Binding_On_Expand() + { + using var app = Start(); + var data = CreateTestTreeData(); + var selected = new[] { data[0], data[0].Children[1] }; + + foreach (var node in selected) + node.IsSelected = true; + + var itemTheme = new ControlTheme(typeof(TreeViewItem)) + { + BasedOn = CreateTreeViewItemControlTheme(), + Setters = + { + new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")), + } + }; + + var target = CreateTarget( + data: data, + expandAll: false, + itemContainerTheme: itemTheme, + multiSelect: true); + + var rootContainer = Assert.IsType(target.ContainerFromIndex(0)); + + // Root TreeViewItem isn't expanded so selection for child won't have been picked + // up by IsSelected binding yet. + AssertContainerSelection(target, new[] { selected[0] }); + Assert.Equal(selected[0], target.SelectedItem); + Assert.Equal(new[] { selected[0] }, target.SelectedItems); + + rootContainer.IsExpanded = true; + Layout(target); + + // Root is expanded so now all expected items will be selected. + AssertDataSelection(data, selected); + AssertContainerSelection(target, selected); + Assert.Equal(selected[0], target.SelectedItem); + Assert.Equal(selected, target.SelectedItems); + } + + [Fact] + public void Selection_State_Is_Updated_Via_IsSelected_Binding_On_Expand_Single_Select() + { + using var app = Start(); + var data = CreateTestTreeData(); + var selected = new[] { data[0], data[0].Children[1] }; + + foreach (var node in selected) + node.IsSelected = true; + + var itemTheme = new ControlTheme(typeof(TreeViewItem)) + { + BasedOn = CreateTreeViewItemControlTheme(), + Setters = + { + new Setter(SelectingItemsControl.IsSelectedProperty, new Binding("IsSelected")), + } + }; + + var target = CreateTarget( + data: data, + expandAll: false, + itemContainerTheme: itemTheme); + + var rootContainer = Assert.IsType(target.ContainerFromIndex(0)); + + // Root TreeViewItem isn't expanded so selection for child won't have been picked + // up by IsSelected binding yet. + AssertContainerSelection(target, new[] { selected[0] }); + Assert.Equal(selected[0], target.SelectedItem); + Assert.Equal(new[] { selected[0] }, target.SelectedItems); + + rootContainer.IsExpanded = true; + Layout(target); + + // Root is expanded and newly revealed selected node will replace current selection + // given that we're in SelectionMode == Single. + selected = new[] { selected[1] }; + AssertDataSelection(data, selected); + AssertContainerSelection(target, selected); + Assert.Equal(selected[0], target.SelectedItem); + Assert.Equal(selected, target.SelectedItems); + } + private static TreeView CreateTarget(Optional?> data = default, bool expandAll = true, ControlTheme? itemContainerTheme = null,