| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- 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 Xunit;
- namespace Avalonia.Controls.UnitTests;
- public class ListBoxVirtualizationIssueTests : ScopedTestBase
- {
- [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<string>((_, _) => new TextBlock { Height = 50 }),
- Height = 100, // Show 2 items
- ItemsPanel = new FuncTemplate<Panel?>(() => 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<ListBoxItem>().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<ListBoxItem>()
- .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<Item> 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<Panel?>(() => 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<ListBoxItem>()
- .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<Item>()
- .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<Panel?>(() => new VirtualizingStackPanel { CacheLength = 0 }),
- Template = new FuncControlTemplate(CreateListBoxTemplate),
- ItemsSource = letters,
- ItemTemplate = new FuncDataTemplate<string>((_, _) => 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<ListBoxItem>().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<string>(Enumerable.Range(0, 100).Select(i => $"Item {i}"));
- var target = new ListBox
- {
- Template = new FuncControlTemplate(CreateListBoxTemplate),
- ItemsSource = items,
- ItemTemplate = new FuncDataTemplate<string>((_, _) => new TextBlock { Height = 50 }),
- Height = 100, // Show 2 items
- ItemsPanel = new FuncTemplate<Panel?>(() => 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<string>(Enumerable.Range(0, 100).Select(i => $"Item {i}"));
- var target = new ListBox
- {
- Template = new FuncControlTemplate(CreateListBoxTemplate),
- ItemsSource = items,
- ItemTemplate = new FuncDataTemplate<string>((_, _) => new TextBlock { Height = 50 }),
- Height = 100, // Show 2 items
- ItemsPanel = new FuncTemplate<Panel?>(() => 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 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; }
- }
- }
|