using System.Collections.ObjectModel; using System.Linq; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.UnitTests; using Avalonia.VisualTree; using Xunit; namespace Avalonia.Controls.UnitTests; public class ListBoxVirtualizationIssueTests : ScopedTestBase { [Fact] public void Opening_SplitView_Pane_After_Scrolling_ListBox_Does_Not_Show_Unrealized_Containers() { using var app = UnitTestApplication.Start(TestServices.StyledWindow .With(globalClock: new MockGlobalClock())); var items = new[] { new SizedItem(196, 331), new SizedItem(186, 258), new SizedItem(196, 321), new SizedItem(186, 296), new SizedItem(150, 340), new SizedItem(196, 319), }; var target = new ListBox { ItemsSource = items, ItemTemplate = new FuncDataTemplate((item, _) => new Border { Width = item?.Width ?? 0, Height = item?.Height ?? 0, }), ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel()), SelectionMode = SelectionMode.Single | SelectionMode.AlwaysSelected, }; var splitView = new SplitView { DisplayMode = SplitViewDisplayMode.CompactInline, CompactPaneLength = 0, OpenPaneLength = 300, Pane = target, Content = new TextBlock(), }; var window = new Window { Width = 800, Height = 804, Content = new Grid { RowDefinitions = new RowDefinitions("30,*"), Children = { new TextBlock(), splitView }, }, }; Grid.SetRow(splitView, 1); window.Show(); Assert.Equal(0, target.Bounds.Width); // Scroll the selected item into view while the SplitView pane is effectively hidden. for (var index = 1; index <= 3; ++index) { target.SelectedIndex = index; window.LayoutManager.ExecuteLayoutPass(); } // Disable the theme animation so the assertion observes the opened-pane layout directly. var paneRoot = splitView.GetVisualDescendants() .OfType() .Single(x => x.Name == "PART_PaneRoot"); paneRoot.Transitions = null; splitView.IsPaneOpen = true; window.LayoutManager.ExecuteLayoutPass(); Assert.Equal(300, target.Bounds.Width); var panel = Assert.IsType(target.Presenter!.Panel); var realized = target.GetRealizedContainers().ToHashSet(); var visibleChildren = panel.Children.Where(x => x.IsVisible).ToList(); Assert.All(visibleChildren, child => { Assert.NotEqual(-1, target.IndexFromContainer(child)); Assert.Contains(child, realized); }); var selectedContainer = Assert.IsType(target.ContainerFromIndex(target.SelectedIndex)); Assert.Contains(selectedContainer, realized); Assert.True(selectedContainer.Bounds.Bottom > target.Scroll!.Offset.Y); Assert.True(selectedContainer.Bounds.Top < target.Scroll.Offset.Y + target.Scroll.Viewport.Height); } [Fact] public void Opening_SplitView_Pane_After_Scrolling_Keeps_TabOnce_Container_Indexed() { using var app = UnitTestApplication.Start(TestServices.StyledWindow .With(globalClock: new MockGlobalClock())); var target = CreateSizedListBox(); var (window, splitView) = CreateSplitViewWindow(target); ScrollWhilePaneIsClosed(target, window); var tabOnceContainer = Assert.IsType(target.ContainerFromIndex(0)); KeyboardNavigation.SetTabOnceActiveElement(target, tabOnceContainer); OpenPane(splitView, target, window); var activeContainer = Assert.IsType(KeyboardNavigation.GetTabOnceActiveElement(target)); var activeIndex = target.IndexFromContainer(activeContainer); Assert.InRange(activeIndex, 0, 5); Assert.Same(activeContainer, target.ContainerFromIndex(activeIndex)); } [Fact] public void Opening_SplitView_Pane_After_Scrolling_Own_Container_Items_Does_Not_Show_Unrealized_Containers() { using var app = UnitTestApplication.Start(TestServices.StyledWindow .With(globalClock: new MockGlobalClock())); var target = new ListBox { ItemsSource = new[] { new ListBoxItem { Content = new Border { Width = 196, Height = 331 } }, new ListBoxItem { Content = new Border { Width = 186, Height = 258 } }, new ListBoxItem { Content = new Border { Width = 196, Height = 321 } }, new ListBoxItem { Content = new Border { Width = 186, Height = 296 } }, new ListBoxItem { Content = new Border { Width = 150, Height = 340 } }, new ListBoxItem { Content = new Border { Width = 196, Height = 319 } }, }, ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel()), SelectionMode = SelectionMode.Single | SelectionMode.AlwaysSelected, }; var (window, splitView) = CreateSplitViewWindow(target); ScrollWhilePaneIsClosed(target, window); OpenPane(splitView, target, window); AssertVisibleChildrenAreRealized(target); } [Fact] public void Removing_First_Item_After_Scrolling_To_End_Should_Allow_Scrolling_To_Start() { using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { var items = new ObservableCollection(Enumerable.Range(0, 100)); var target = new ListBox { Template = new FuncControlTemplate(CreateListBoxTemplate), ItemsSource = items, ItemTemplate = new FuncDataTemplate((_, _) => new TextBlock { Height = 50 }), ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel()), }; Prepare(target); target.ScrollIntoView(99); items.RemoveAt(0); target.ScrollIntoView(0); var firstContainer = Assert.IsType(target.ContainerFromIndex(0)); Assert.Equal(1, firstContainer.Content); } } [Fact] public void Replaced_ItemsSource_Should_Not_Show_Old_Selected_Item_When_Scrolled_Back() { using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { var letters = "ABCDEFGHIJ".Select(c => c.ToString()).ToList(); var numbers = "0123456789".Select(c => c.ToString()).ToList(); var target = new ListBox { Template = new FuncControlTemplate(CreateListBoxTemplate), ItemsSource = letters, ItemTemplate = new FuncDataTemplate((_, _) => new TextBlock { Height = 50 }), Height = 100, // Show 2 items ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel { CacheLength = 0 }), }; Prepare(target); // 1. Select a ListBoxItem target.SelectedIndex = 0; Assert.True(((ListBoxItem)target.Presenter!.Panel!.Children[0]).IsSelected); // 2. Scroll until the selected ListBoxItem is no longer visible target.ScrollIntoView(letters.Count - 1); // Scroll down to the last item // Verify that the first item is no longer realized var realizedContainers = target.GetRealizedContainers().Cast().ToList(); Assert.DoesNotContain(realizedContainers, x => x.Content as string == "A"); // 3. Change the ItemsSource target.ItemsSource = numbers; // 4. Scroll to the top target.ScrollIntoView(0); // 5. The previously selected ListBoxItem should NOT appear in the ListBox var realizedItems = target.GetRealizedContainers() .Cast() .Select(x => x.Content?.ToString()) .ToList(); Assert.All(realizedItems, item => Assert.DoesNotContain(item, letters)); Assert.Equal("0", realizedItems[0]); } } [Fact] public void AddingItemsAtTopShouldNotCreateGhostItems() { using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { ObservableCollection items = new(); for (int i = 0; i < 100; i++) { items.Add(new Item(i)); } var target = new ListBox { Template = new FuncControlTemplate(CreateListBoxTemplate), ItemsSource = items, Height = 100, // Show 2 items ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel()), }; Prepare(target); // Scroll to some position var scrollViewer = (ScrollViewer)target.VisualChildren[0]; scrollViewer.Offset = new Vector(0, 500); // Scrolled down target.UpdateLayout(); // Add items at the top multiple times for (int i = 0; i < 5; i++) { for (int j = 0; j < 10; j++) { items.Insert(0, new Item(1000 + (i * 100 + j))); } target.UpdateLayout(); // Randomly select something target.SelectedIndex = items.Count - 1; target.UpdateLayout(); // Scroll a bit scrollViewer.ScrollToEnd(); scrollViewer.ScrollToEnd(); target.UpdateLayout(); // Check for ghost items during the process var p = target.Presenter!.Panel!; var visibleChildren = p.Children.Where(c => c.IsVisible).ToList(); var realizedContainers = target.GetRealizedContainers() .Cast() .ToList(); // Only visible children should be considered. Invisible children may be recycled items kept for reuse. Assert.Equal(realizedContainers.Count, visibleChildren.Count); foreach (var child in visibleChildren) { Assert.Contains(child, realizedContainers); } var realizedItems = realizedContainers .Select(x => x.Content) .Cast() .ToList(); // Check for duplicates in realized items var duplicateIds = realizedItems.GroupBy(x => x.Id).Where(g => g.Count() > 1).Select(g => g.Key) .ToList(); Assert.Empty(duplicateIds); // Check if all realized items are actually in the items source foreach (var item in realizedItems) { Assert.Contains(item, items); } // Check if realized items are in the correct order int lastIndex = -1; foreach (var item in realizedItems) { int currentIndex = items.IndexOf(item); Assert.True(currentIndex > lastIndex, $"Item {item.Id} is at index {currentIndex}, but previous item was at index {lastIndex}"); lastIndex = currentIndex; } // New check: verify that all visual children of the panel are accounted for in realizedContainers var panel = target.Presenter!.Panel!; var visualChildren = panel.Children.ToList(); // Realized containers should match exactly the visual children of the panel // (VirtualizingStackPanel manages its children such that they should be the realized containers) // We also check if all children are visible, if not they might be "ghosts" foreach (var child in visualChildren) { Assert.True(child.IsVisible, $"Child {((ListBoxItem)child).Content} should be visible"); } Assert.Equal(realizedContainers.Count, visualChildren.Count); foreach (var child in visualChildren) { Assert.Contains(child, realizedContainers); } } } } [Fact] public void RealizedContainers_Should_Only_Include_Visible_Items_With_CacheLength_Zero() { using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { var letters = "ABCDEFGHIJ".Select(c => c.ToString()).ToList(); var target = new ListBox { ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel { CacheLength = 0 }), Template = new FuncControlTemplate(CreateListBoxTemplate), ItemsSource = letters, ItemTemplate = new FuncDataTemplate((_, _) => new TextBlock { Height = 50 }), Height = 100, // Show 2 items (100 / 50 = 2) }; Prepare(target); // At the top, only 2 items should be visible (items at index 0 and 1) var realizedContainers = target.GetRealizedContainers().Cast().ToList(); // With CacheLength = 0, we should only have the visible items realized Assert.Equal(2, realizedContainers.Count); Assert.Equal("A", realizedContainers[0].Content?.ToString()); Assert.Equal("B", realizedContainers[1].Content?.ToString()); } } [Fact] public void GhostItemTest_FocusManagement() { using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { var items = new ObservableCollection(Enumerable.Range(0, 100).Select(i => $"Item {i}")); var target = new ListBox { Template = new FuncControlTemplate(CreateListBoxTemplate), ItemsSource = items, ItemTemplate = new FuncDataTemplate((_, _) => new TextBlock { Height = 50 }), Height = 100, // Show 2 items ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel { CacheLength = 0 }), }; Prepare(target); // 1. Get the first container and focus it var container = (ListBoxItem)target.Presenter!.Panel!.Children[0]; KeyboardNavigation.SetTabOnceActiveElement(target, container); // 2. Scroll down so the first item is recycled target.ScrollIntoView(10); target.UpdateLayout(); // 3. Verify it is now _focusedElement in the panel var panel = (VirtualizingStackPanel)target.Presenter!.Panel!; var realizedContainers = target.GetRealizedContainers().ToList(); // The focused container should still be in Children, but NOT in realizedContainers Assert.Contains(container, panel.Children); Assert.DoesNotContain(container, realizedContainers); // Now scroll back to top. target.ScrollIntoView(0); target.UpdateLayout(); // Check if we have two containers for the same item or other weirdness var visibleChildren = panel.Children.Where(c => c.IsVisible).ToList(); // If it was a ghost, it might still be there or we might have two items for the same thing Assert.Equal(target.GetRealizedContainers().Count(), visibleChildren.Count); // 4. Test: Re-insert at top might cause issues if _focusedElement is not updated correctly items.Insert(0, "New Item"); target.UpdateLayout(); visibleChildren = panel.Children.Where(c => c.IsVisible).ToList(); Assert.Equal(target.GetRealizedContainers().Count(), visibleChildren.Count); // 5. Remove the focused item while it's recycled target.ScrollIntoView(10); target.UpdateLayout(); Assert.Contains(container, panel.Children); items.RemoveAt(1); // Item 0 was at index 1 because of Insert(0, "New Item") target.UpdateLayout(); // container should be removed from children because RecycleElementOnItemRemoved is called Assert.DoesNotContain(container, panel.Children); Assert.False(container.IsVisible); } } [Fact] public void GhostItemTest_ScrollToManagement() { using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) { var items = new ObservableCollection(Enumerable.Range(0, 100).Select(i => $"Item {i}")); var target = new ListBox { Template = new FuncControlTemplate(CreateListBoxTemplate), ItemsSource = items, ItemTemplate = new FuncDataTemplate((_, _) => new TextBlock { Height = 50 }), Height = 100, // Show 2 items ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel { CacheLength = 0 }), }; Prepare(target); // 1. ScrollIntoView to trigger _scrollToElement // We use a high index and don't call UpdateLayout immediately if we want to catch it in between? // Actually ScrollIntoView calls layout internally. target.ScrollIntoView(50); var panel = (VirtualizingStackPanel)target.Presenter!.Panel!; // 2. Remove the item we just scrolled to items.RemoveAt(50); target.UpdateLayout(); // If it was kept in _scrollToElement and not recycled, it might be a ghost. var visibleChildren = panel.Children.Where(c => c.IsVisible).ToList(); Assert.Equal(target.GetRealizedContainers().Count(), visibleChildren.Count); } } private static ListBox CreateSizedListBox() { var items = new[] { new SizedItem(196, 331), new SizedItem(186, 258), new SizedItem(196, 321), new SizedItem(186, 296), new SizedItem(150, 340), new SizedItem(196, 319), }; return new ListBox { ItemsSource = items, ItemTemplate = new FuncDataTemplate((item, _) => new Border { Width = item?.Width ?? 0, Height = item?.Height ?? 0, }), ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel()), SelectionMode = SelectionMode.Single | SelectionMode.AlwaysSelected, }; } private static (Window Window, SplitView SplitView) CreateSplitViewWindow(ListBox listBox) { var splitView = new SplitView { DisplayMode = SplitViewDisplayMode.CompactInline, CompactPaneLength = 0, OpenPaneLength = 300, Pane = listBox, Content = new TextBlock(), }; var window = new Window { Width = 800, Height = 804, Content = new Grid { RowDefinitions = new RowDefinitions("30,*"), Children = { new TextBlock(), splitView }, }, }; Grid.SetRow(splitView, 1); window.Show(); return (window, splitView); } private static void ScrollWhilePaneIsClosed(ListBox listBox, Window window) { Assert.Equal(0, listBox.Bounds.Width); for (var index = 1; index <= 3; ++index) { listBox.SelectedIndex = index; window.LayoutManager.ExecuteLayoutPass(); } } private static void OpenPane(SplitView splitView, ListBox listBox, Window window) { var paneRoot = splitView.GetVisualDescendants() .OfType() .Single(x => x.Name == "PART_PaneRoot"); paneRoot.Transitions = null; splitView.IsPaneOpen = true; window.LayoutManager.ExecuteLayoutPass(); Assert.Equal(300, listBox.Bounds.Width); } private static void AssertVisibleChildrenAreRealized(ListBox listBox) { var panel = Assert.IsType(listBox.Presenter!.Panel); var realized = listBox.GetRealizedContainers().ToHashSet(); Assert.All(panel.Children.Where(x => x.IsVisible), child => { Assert.NotEqual(-1, listBox.IndexFromContainer(child)); Assert.Contains(child, realized); }); } private Control CreateListBoxTemplate(TemplatedControl parent, INameScope scope) { return new ScrollViewer { Name = "PART_ScrollViewer", Template = new FuncControlTemplate(CreateScrollViewerTemplate), Content = new ItemsPresenter { Name = "PART_ItemsPresenter", [~ItemsPresenter.ItemsPanelProperty] = ((ListBox)parent).GetObservable(ItemsControl.ItemsPanelProperty).ToBinding(), }.RegisterInNameScope(scope) }.RegisterInNameScope(scope); } private Control CreateScrollViewerTemplate(TemplatedControl parent, INameScope scope) { return new ScrollContentPresenter { Name = "PART_ContentPresenter", [~ContentPresenter.ContentProperty] = parent.GetObservable(ContentControl.ContentProperty).ToBinding(), }.RegisterInNameScope(scope); } private static void Prepare(ListBox target) { target.Width = target.Height = 100; var root = new TestRoot(target); root.LayoutManager.ExecuteInitialLayoutPass(); } private class Item { public Item(int id) { Id = id; } public int Id { get; } } private class SizedItem { public SizedItem(double width, double height) { Width = width; Height = height; } public double Width { get; } public double Height { get; } } }