diff --git a/samples/Sandbox/MainWindow.axaml.cs b/samples/Sandbox/MainWindow.axaml.cs index 23d45edf6a..b8e9f0ff42 100644 --- a/samples/Sandbox/MainWindow.axaml.cs +++ b/samples/Sandbox/MainWindow.axaml.cs @@ -6,17 +6,11 @@ using Avalonia.Win32.WinRT.Composition; namespace Sandbox { - public class MainWindow : Window + public partial class MainWindow : Window { public MainWindow() { - this.InitializeComponent(); - this.AttachDevTools(); - } - - private void InitializeComponent() - { - AvaloniaXamlLoader.Load(this); + InitializeComponent(); } } } diff --git a/samples/Sandbox/Sandbox.csproj b/samples/Sandbox/Sandbox.csproj index f23e391a2a..d2e66988e0 100644 --- a/samples/Sandbox/Sandbox.csproj +++ b/samples/Sandbox/Sandbox.csproj @@ -4,6 +4,7 @@ WinExe net6.0 true + true @@ -17,4 +18,5 @@ + diff --git a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs index 57ed67b508..f2b105c901 100644 --- a/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs +++ b/src/Avalonia.Controls/Generators/ItemContainerGenerator.cs @@ -7,8 +7,8 @@ namespace Avalonia.Controls.Generators /// /// /// When creating a container for an item from a , the following - /// method order should be followed: - /// + /// process should be followed: + /// /// - should first be called if the item is /// derived from the class. If this method returns true then the /// item itself should be used as the container. @@ -19,9 +19,29 @@ namespace Avalonia.Controls.Generators /// - The container should then be added to the panel using /// /// - Finally, should be called. - /// - When the item is ready to be recycled, should - /// be called if returned false. /// + /// NOTE: If in the first step above returns true + /// then the above steps should be carried out a single time; the first time the item is + /// displayed. Otherwise the steps should be carried out each time a new container is realized + /// for an item. + /// + /// When unrealizing a container, the following process should be followed: + /// + /// - If for the item returned true then the item + /// cannot be unrealized or recycled. + /// - Otherwise, should be called for the container + /// - If recycling is supported then the container should be added to a recycle pool. + /// - It is assumed that recyclable containers will not be removed from the panel but instead + /// hidden from view using e.g. `container.IsVisible = false`. + /// + /// When recycling an unrealized container, the following process should be followed: + /// + /// - An element should be taken from the recycle pool. + /// - The container should be made visible. + /// - method should be called for the + /// container. + /// - should be called. + /// /// NOTE: Although this class is similar to that found in WPF/UWP, in Avalonia this class only /// concerns itself with generating and clearing item containers; it does not maintain a /// record of the currently realized containers, that responsibility is delegated to the diff --git a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs index 796ee8433a..5a6f9fc4f9 100644 --- a/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs +++ b/src/Avalonia.Controls/Presenters/PanelContainerGenerator.cs @@ -67,9 +67,12 @@ namespace Avalonia.Controls.Presenters for (var i = 0; i < count; ++i) { var c = children[index + i]; + if (!c.IsSet(ItemIsOwnContainerProperty)) + { itemsControl.RemoveLogicalChild(children[i + index]); - generator.ClearItemContainer(c); + generator.ClearItemContainer(c); + } } children.RemoveRange(index, count); diff --git a/src/Avalonia.Controls/TreeViewItem.cs b/src/Avalonia.Controls/TreeViewItem.cs index 806d7e320b..70ffd218b1 100644 --- a/src/Avalonia.Controls/TreeViewItem.cs +++ b/src/Avalonia.Controls/TreeViewItem.cs @@ -45,6 +45,7 @@ namespace Avalonia.Controls private TreeView? _treeView; private Control? _header; + private Control? _headerPresenter; private int _level; private bool _templateApplied; private bool _deferredBringIntoViewFlag; @@ -255,15 +256,16 @@ namespace Avalonia.Controls protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { - if (_header is InputElement previousInputMethod) + if (_headerPresenter is InputElement previousInputMethod) { previousInputMethod.DoubleTapped -= HeaderDoubleTapped; } _header = e.NameScope.Find("PART_Header"); + _headerPresenter = e.NameScope.Find("PART_HeaderPresenter"); _templateApplied = true; - if (_header is InputElement im) + if (_headerPresenter is InputElement im) { im.DoubleTapped += HeaderDoubleTapped; } diff --git a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs index da0ff1eb69..28d6a83309 100644 --- a/src/Avalonia.Controls/VirtualizingCarouselPanel.cs +++ b/src/Avalonia.Controls/VirtualizingCarouselPanel.cs @@ -168,7 +168,13 @@ namespace Avalonia.Controls protected internal override Control? ContainerFromIndex(int index) { - return index == _realizedIndex ? _realized : null; + if (index < 0 || index >= Items.Count) + return null; + if (index == _realizedIndex) + return _realized; + if (Items[index] is Control c && c.GetValue(ItemIsOwnContainerProperty)) + return c; + return null; } protected internal override IEnumerable? GetRealizedContainers() @@ -264,7 +270,6 @@ namespace Avalonia.Controls if (controlItem.IsSet(ItemIsOwnContainerProperty)) { controlItem.IsVisible = true; - generator.ItemContainerPrepared(controlItem, item, index); return controlItem; } else if (generator.IsItemItsOwnContainer(controlItem)) diff --git a/src/Avalonia.Controls/VirtualizingPanel.cs b/src/Avalonia.Controls/VirtualizingPanel.cs index a95d4f1ffa..ed92f30454 100644 --- a/src/Avalonia.Controls/VirtualizingPanel.cs +++ b/src/Avalonia.Controls/VirtualizingPanel.cs @@ -76,6 +76,11 @@ namespace Avalonia.Controls /// The container for the item at the specified index within the item collection, if the /// item is realized; otherwise, null. /// + /// + /// Note for implementors: if the item at the the specified index is an ItemIsOwnContainer + /// item that has previously been realized, then the item should be returned even if it + /// currently falls outside the realized viewport. + /// protected internal abstract Control? ContainerFromIndex(int index); /// diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs index 77268c7831..67ec238ceb 100644 --- a/src/Avalonia.Controls/VirtualizingStackPanel.cs +++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; using System.Linq; -using System.Reflection; using Avalonia.Controls.Primitives; using Avalonia.Controls.Utils; using Avalonia.Input; @@ -326,7 +325,17 @@ namespace Avalonia.Controls return _realizedElements?.Elements.Where(x => x is not null)!; } - protected internal override Control? ContainerFromIndex(int index) => _realizedElements?.GetElement(index); + protected internal override Control? ContainerFromIndex(int index) + { + if (index < 0 || index >= Items.Count) + return null; + if (_realizedElements?.GetElement(index) is { } realized) + return realized; + if (Items[index] is Control c && c.GetValue(ItemIsOwnContainerProperty)) + return c; + return null; + } + protected internal override int IndexFromContainer(Control container) => _realizedElements?.GetIndex(container) ?? -1; protected internal override Control? ScrollIntoView(int index) @@ -578,7 +587,6 @@ namespace Avalonia.Controls if (controlItem.IsSet(ItemIsOwnContainerProperty)) { controlItem.IsVisible = true; - generator.ItemContainerPrepared(controlItem, item, index); return controlItem; } else if (generator.IsItemItsOwnContainer(controlItem)) diff --git a/src/Avalonia.Controls/WindowBase.cs b/src/Avalonia.Controls/WindowBase.cs index d640a0c2dc..ac47e744e0 100644 --- a/src/Avalonia.Controls/WindowBase.cs +++ b/src/Avalonia.Controls/WindowBase.cs @@ -83,6 +83,19 @@ namespace Avalonia.Controls /// /// Occurs when the window is resized. /// + /// + /// Although this event is similar to the event, they are + /// conceptually different: + /// + /// - is a window-level event, fired when a resize notification arrives + /// from the platform windowing subsystem. The event args contain details of the source of + /// the resize event in the property. This + /// event is raised before layout has been run on the window's content. + /// - is a layout-level event, fired when a layout pass + /// completes on a control. is present on all controls + /// and is fired when the control's size changes for any reason, including a + /// event in the case of a Window. + /// public event EventHandler? Resized; public new IWindowBaseImpl? PlatformImpl => (IWindowBaseImpl?) base.PlatformImpl; @@ -237,14 +250,16 @@ namespace Avalonia.Controls { FrameSize = PlatformImpl?.FrameSize; - if (ClientSize != clientSize) + var clientSizeChanged = ClientSize != clientSize; + + ClientSize = clientSize; + OnResized(new WindowResizedEventArgs(clientSize, reason)); + + if (clientSizeChanged) { - ClientSize = clientSize; LayoutManager.ExecuteLayoutPass(); Renderer.Resized(clientSize); } - - OnResized(new WindowResizedEventArgs(clientSize, reason)); } /// diff --git a/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml.cs b/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml.cs index 66be6b5041..b0aea64994 100644 --- a/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml.cs +++ b/src/Avalonia.Diagnostics/Diagnostics/Views/TreePageView.xaml.cs @@ -39,16 +39,6 @@ namespace Avalonia.Diagnostics.Views AdornerLayer.SetIsClipEnabled(_adorner, false); } - protected override void OnDataContextChanged(EventArgs e) - { - base.OnDataContextChanged(e); - - ((TreePageViewModel)DataContext!).ClipboardCopyRequested += (sender, s) => - { - TopLevel.GetTopLevel(this)?.Clipboard?.SetTextAsync(s); - }; - } - protected void AddAdorner(object? sender, PointerEventArgs e) { var node = (TreeNode?)((Control)sender!).DataContext; @@ -108,9 +98,27 @@ namespace Avalonia.Diagnostics.Views _currentLayer = null; } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == DataContextProperty) + { + if (change.GetOldValue() is TreePageViewModel oldViewModel) + oldViewModel.ClipboardCopyRequested -= OnClipboardCopyRequested; + if (change.GetNewValue() is TreePageViewModel newViewModel) + newViewModel.ClipboardCopyRequested += OnClipboardCopyRequested; + } + } + private void InitializeComponent() { AvaloniaXamlLoader.Load(this); } + + private void OnClipboardCopyRequested(object? sender, string e) + { + TopLevel.GetTopLevel(this)?.Clipboard?.SetTextAsync(e); + } } } diff --git a/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj b/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj index d8162c0486..4805c3a034 100644 --- a/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj +++ b/src/Avalonia.FreeDesktop/Avalonia.FreeDesktop.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml b/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml index b5f1220bc8..eff3920b12 100644 --- a/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml +++ b/src/Avalonia.Themes.Fluent/Controls/TreeViewItem.xaml @@ -75,7 +75,6 @@ MinHeight="{TemplateBinding MinHeight}" TemplatedControl.IsTemplateFocusTarget="True"> + { + if (e.Property == Carousel.SelectedIndexProperty) + { + } + }; + Layout(target); + + Assert.Equal(1, target.SelectedIndex); + } + private static IDisposable Start() => UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); private static void Prepare(Carousel target) diff --git a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs index 1a0ea5fdab..5e741cdc1d 100644 --- a/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ItemsControlTests.cs @@ -828,6 +828,19 @@ namespace Avalonia.Controls.UnitTests Layout(target); } + [Fact] + public void ItemIsOwnContainer_Content_Should_Not_Be_Cleared_When_Removed() + { + // Issue #11128. + using var app = Start(); + var item = new ContentPresenter { Content = "foo" }; + var target = CreateTarget(items: new[] { item }); + + target.Items.RemoveAt(0); + + Assert.Equal("foo", item.Content); + } + private static ItemsControl CreateTarget( object? dataContext = null, IBinding? displayMemberBinding = null, diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index db6460e8aa..dfdcd09bf9 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -676,12 +676,8 @@ namespace Avalonia.Controls.UnitTests.Primitives [Fact] public void Moving_Selected_Item_Should_Clear_Selection() { - var items = new AvaloniaList - { - new Item(), - new Item(), - }; - + using var app = Start(); + var items = new ObservableCollection { "foo", "bar" }; var target = new SelectingItemsControl { ItemsSource = items, @@ -706,7 +702,46 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.NotNull(receivedArgs); Assert.Empty(receivedArgs.AddedItems); Assert.Equal(new[] { removed }, receivedArgs.RemovedItems); - Assert.All(items, x => Assert.False(x.IsSelected)); + } + + [Fact] + public void Moving_Selected_Container_Should_Not_Clear_Selection() + { + var items = new AvaloniaList + { + new Item(), + new Item(), + }; + + var target = new SelectingItemsControl + { + ItemsSource = items, + Template = Template(), + }; + + Prepare(target); + target.SelectedIndex = 1; + + Assert.Equal(items[1], target.SelectedItem); + Assert.Equal(1, target.SelectedIndex); + + var receivedArgs = new List(); + + target.SelectionChanged += (_, args) => receivedArgs.Add(args); + + var moved = items[1]; + items.Move(1, 0); + + // Because the moved container is still marked as selected on the insert part of the + // move, it will remain selected. + Assert.Same(moved, target.SelectedItem); + Assert.Equal(0, target.SelectedIndex); + Assert.NotNull(receivedArgs); + Assert.Equal(2, receivedArgs.Count); + Assert.Equal(new[] { moved }, receivedArgs[0].RemovedItems); + Assert.Equal(new[] { moved }, receivedArgs[1].AddedItems); + Assert.True(items[0].IsSelected); + Assert.False(items[1].IsSelected); } [Fact] diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs index 0817979e33..928c0c94ef 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs @@ -1024,6 +1024,56 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal(new[] { 15 }, SelectedContainers(target)); } + [Fact] + public void Can_Change_Selection_For_Containers_Outside_Of_Viewport() + { + // Issue #11119 + using var app = Start(); + var items = Enumerable.Range(0, 100).Select(x => new TestContainer + { + Content = $"Item {x}", + Height = 100, + }).ToList(); + + // Create a SelectingItemsControl with a virtualizing stack panel. + var target = CreateTarget(itemsSource: items, virtualizing: true); + target.AutoScrollToSelectedItem = false; + + var panel = Assert.IsType(target.ItemsPanelRoot); + var scroll = panel.FindAncestorOfType()!; + + // Select item 1. + target.SelectedIndex = 1; + + // Scroll item 1 and 2 out of view. + scroll.Offset = new(0, 1000); + Layout(target); + + Assert.Equal(10, panel.FirstRealizedIndex); + Assert.Equal(19, panel.LastRealizedIndex); + + // Select item 2 now that items 1 and 2 are both unrealized. + target.SelectedIndex = 2; + + // The selection should be updated. + Assert.Empty(SelectedContainers(target)); + Assert.Equal(2, target.SelectedIndex); + Assert.Same(items[2], target.SelectedItem); + Assert.Equal(new[] { 2 }, target.Selection.SelectedIndexes); + Assert.Equal(new[] { items[2] }, target.Selection.SelectedItems); + + // Scroll selected item back into view. + scroll.Offset = new(0, 0); + Layout(target); + + // The selection should be preserved. + Assert.Equal(new[] { 2 }, SelectedContainers(target)); + Assert.Equal(2, target.SelectedIndex); + Assert.Same(items[2], target.SelectedItem); + Assert.Equal(new[] { 2 }, target.Selection.SelectedIndexes); + Assert.Equal(new[] { items[2] }, target.Selection.SelectedItems); + } + [Fact] public void Selection_State_Change_On_Unrealized_Item_Is_Respected_With_IsSelected_Binding() { @@ -1197,7 +1247,7 @@ namespace Avalonia.Controls.UnitTests.Primitives { Setters = { - new Setter(TreeView.TemplateProperty, CreateTestContainerTemplate()), + new Setter(TestContainer.TemplateProperty, CreateTestContainerTemplate()), }, }; }