ListBoxVirtualizationIssueTests.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. using System.Collections.ObjectModel;
  2. using System.Linq;
  3. using Avalonia.Controls.Presenters;
  4. using Avalonia.Controls.Primitives;
  5. using Avalonia.Controls.Templates;
  6. using Avalonia.Input;
  7. using Avalonia.UnitTests;
  8. using Xunit;
  9. namespace Avalonia.Controls.UnitTests;
  10. public class ListBoxVirtualizationIssueTests : ScopedTestBase
  11. {
  12. [Fact]
  13. public void Replaced_ItemsSource_Should_Not_Show_Old_Selected_Item_When_Scrolled_Back()
  14. {
  15. using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
  16. {
  17. var letters = "ABCDEFGHIJ".Select(c => c.ToString()).ToList();
  18. var numbers = "0123456789".Select(c => c.ToString()).ToList();
  19. var target = new ListBox
  20. {
  21. Template = new FuncControlTemplate(CreateListBoxTemplate),
  22. ItemsSource = letters,
  23. ItemTemplate = new FuncDataTemplate<string>((_, _) => new TextBlock { Height = 50 }),
  24. Height = 100, // Show 2 items
  25. ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel { CacheLength = 0 }),
  26. };
  27. Prepare(target);
  28. // 1. Select a ListBoxItem
  29. target.SelectedIndex = 0;
  30. Assert.True(((ListBoxItem)target.Presenter!.Panel!.Children[0]).IsSelected);
  31. // 2. Scroll until the selected ListBoxItem is no longer visible
  32. target.ScrollIntoView(letters.Count - 1); // Scroll down to the last item
  33. // Verify that the first item is no longer realized
  34. var realizedContainers = target.GetRealizedContainers().Cast<ListBoxItem>().ToList();
  35. Assert.DoesNotContain(realizedContainers, x => x.Content as string == "A");
  36. // 3. Change the ItemsSource
  37. target.ItemsSource = numbers;
  38. // 4. Scroll to the top
  39. target.ScrollIntoView(0);
  40. // 5. The previously selected ListBoxItem should NOT appear in the ListBox
  41. var realizedItems = target.GetRealizedContainers()
  42. .Cast<ListBoxItem>()
  43. .Select(x => x.Content?.ToString())
  44. .ToList();
  45. Assert.All(realizedItems, item => Assert.DoesNotContain(item, letters));
  46. Assert.Equal("0", realizedItems[0]);
  47. }
  48. }
  49. [Fact]
  50. public void AddingItemsAtTopShouldNotCreateGhostItems()
  51. {
  52. using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
  53. {
  54. ObservableCollection<Item> items = new();
  55. for (int i = 0; i < 100; i++)
  56. {
  57. items.Add(new Item(i));
  58. }
  59. var target = new ListBox
  60. {
  61. Template = new FuncControlTemplate(CreateListBoxTemplate),
  62. ItemsSource = items,
  63. Height = 100, // Show 2 items
  64. ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel()),
  65. };
  66. Prepare(target);
  67. // Scroll to some position
  68. var scrollViewer = (ScrollViewer)target.VisualChildren[0];
  69. scrollViewer.Offset = new Vector(0, 500); // Scrolled down
  70. target.UpdateLayout();
  71. // Add items at the top multiple times
  72. for (int i = 0; i < 5; i++)
  73. {
  74. for (int j = 0; j < 10; j++)
  75. {
  76. items.Insert(0, new Item(1000 + (i * 100 + j)));
  77. }
  78. target.UpdateLayout();
  79. // Randomly select something
  80. target.SelectedIndex = items.Count - 1;
  81. target.UpdateLayout();
  82. // Scroll a bit
  83. scrollViewer.ScrollToEnd();
  84. scrollViewer.ScrollToEnd();
  85. target.UpdateLayout();
  86. // Check for ghost items during the process
  87. var p = target.Presenter!.Panel!;
  88. var visibleChildren = p.Children.Where(c => c.IsVisible).ToList();
  89. var realizedContainers = target.GetRealizedContainers()
  90. .Cast<ListBoxItem>()
  91. .ToList();
  92. // Only visible children should be considered. Invisible children may be recycled items kept for reuse.
  93. Assert.Equal(realizedContainers.Count, visibleChildren.Count);
  94. foreach (var child in visibleChildren)
  95. {
  96. Assert.Contains(child, realizedContainers);
  97. }
  98. var realizedItems = realizedContainers
  99. .Select(x => x.Content)
  100. .Cast<Item>()
  101. .ToList();
  102. // Check for duplicates in realized items
  103. var duplicateIds = realizedItems.GroupBy(x => x.Id).Where(g => g.Count() > 1).Select(g => g.Key)
  104. .ToList();
  105. Assert.Empty(duplicateIds);
  106. // Check if all realized items are actually in the items source
  107. foreach (var item in realizedItems)
  108. {
  109. Assert.Contains(item, items);
  110. }
  111. // Check if realized items are in the correct order
  112. int lastIndex = -1;
  113. foreach (var item in realizedItems)
  114. {
  115. int currentIndex = items.IndexOf(item);
  116. Assert.True(currentIndex > lastIndex,
  117. $"Item {item.Id} is at index {currentIndex}, but previous item was at index {lastIndex}");
  118. lastIndex = currentIndex;
  119. }
  120. // New check: verify that all visual children of the panel are accounted for in realizedContainers
  121. var panel = target.Presenter!.Panel!;
  122. var visualChildren = panel.Children.ToList();
  123. // Realized containers should match exactly the visual children of the panel
  124. // (VirtualizingStackPanel manages its children such that they should be the realized containers)
  125. // We also check if all children are visible, if not they might be "ghosts"
  126. foreach (var child in visualChildren)
  127. {
  128. Assert.True(child.IsVisible, $"Child {((ListBoxItem)child).Content} should be visible");
  129. }
  130. Assert.Equal(realizedContainers.Count, visualChildren.Count);
  131. foreach (var child in visualChildren)
  132. {
  133. Assert.Contains(child, realizedContainers);
  134. }
  135. }
  136. }
  137. }
  138. [Fact]
  139. public void RealizedContainers_Should_Only_Include_Visible_Items_With_CacheLength_Zero()
  140. {
  141. using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
  142. {
  143. var letters = "ABCDEFGHIJ".Select(c => c.ToString()).ToList();
  144. var target = new ListBox
  145. {
  146. ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel { CacheLength = 0 }),
  147. Template = new FuncControlTemplate(CreateListBoxTemplate),
  148. ItemsSource = letters,
  149. ItemTemplate = new FuncDataTemplate<string>((_, _) => new TextBlock { Height = 50 }),
  150. Height = 100, // Show 2 items (100 / 50 = 2)
  151. };
  152. Prepare(target);
  153. // At the top, only 2 items should be visible (items at index 0 and 1)
  154. var realizedContainers = target.GetRealizedContainers().Cast<ListBoxItem>().ToList();
  155. // With CacheLength = 0, we should only have the visible items realized
  156. Assert.Equal(2, realizedContainers.Count);
  157. Assert.Equal("A", realizedContainers[0].Content?.ToString());
  158. Assert.Equal("B", realizedContainers[1].Content?.ToString());
  159. }
  160. }
  161. [Fact]
  162. public void GhostItemTest_FocusManagement()
  163. {
  164. using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
  165. {
  166. var items = new ObservableCollection<string>(Enumerable.Range(0, 100).Select(i => $"Item {i}"));
  167. var target = new ListBox
  168. {
  169. Template = new FuncControlTemplate(CreateListBoxTemplate),
  170. ItemsSource = items,
  171. ItemTemplate = new FuncDataTemplate<string>((_, _) => new TextBlock { Height = 50 }),
  172. Height = 100, // Show 2 items
  173. ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel { CacheLength = 0 }),
  174. };
  175. Prepare(target);
  176. // 1. Get the first container and focus it
  177. var container = (ListBoxItem)target.Presenter!.Panel!.Children[0];
  178. KeyboardNavigation.SetTabOnceActiveElement(target, container);
  179. // 2. Scroll down so the first item is recycled
  180. target.ScrollIntoView(10);
  181. target.UpdateLayout();
  182. // 3. Verify it is now _focusedElement in the panel
  183. var panel = (VirtualizingStackPanel)target.Presenter!.Panel!;
  184. var realizedContainers = target.GetRealizedContainers().ToList();
  185. // The focused container should still be in Children, but NOT in realizedContainers
  186. Assert.Contains(container, panel.Children);
  187. Assert.DoesNotContain(container, realizedContainers);
  188. // Now scroll back to top.
  189. target.ScrollIntoView(0);
  190. target.UpdateLayout();
  191. // Check if we have two containers for the same item or other weirdness
  192. var visibleChildren = panel.Children.Where(c => c.IsVisible).ToList();
  193. // If it was a ghost, it might still be there or we might have two items for the same thing
  194. Assert.Equal(target.GetRealizedContainers().Count(), visibleChildren.Count);
  195. // 4. Test: Re-insert at top might cause issues if _focusedElement is not updated correctly
  196. items.Insert(0, "New Item");
  197. target.UpdateLayout();
  198. visibleChildren = panel.Children.Where(c => c.IsVisible).ToList();
  199. Assert.Equal(target.GetRealizedContainers().Count(), visibleChildren.Count);
  200. // 5. Remove the focused item while it's recycled
  201. target.ScrollIntoView(10);
  202. target.UpdateLayout();
  203. Assert.Contains(container, panel.Children);
  204. items.RemoveAt(1); // Item 0 was at index 1 because of Insert(0, "New Item")
  205. target.UpdateLayout();
  206. // container should be removed from children because RecycleElementOnItemRemoved is called
  207. Assert.DoesNotContain(container, panel.Children);
  208. Assert.False(container.IsVisible);
  209. }
  210. }
  211. [Fact]
  212. public void GhostItemTest_ScrollToManagement()
  213. {
  214. using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface))
  215. {
  216. var items = new ObservableCollection<string>(Enumerable.Range(0, 100).Select(i => $"Item {i}"));
  217. var target = new ListBox
  218. {
  219. Template = new FuncControlTemplate(CreateListBoxTemplate),
  220. ItemsSource = items,
  221. ItemTemplate = new FuncDataTemplate<string>((_, _) => new TextBlock { Height = 50 }),
  222. Height = 100, // Show 2 items
  223. ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel { CacheLength = 0 }),
  224. };
  225. Prepare(target);
  226. // 1. ScrollIntoView to trigger _scrollToElement
  227. // We use a high index and don't call UpdateLayout immediately if we want to catch it in between?
  228. // Actually ScrollIntoView calls layout internally.
  229. target.ScrollIntoView(50);
  230. var panel = (VirtualizingStackPanel)target.Presenter!.Panel!;
  231. // 2. Remove the item we just scrolled to
  232. items.RemoveAt(50);
  233. target.UpdateLayout();
  234. // If it was kept in _scrollToElement and not recycled, it might be a ghost.
  235. var visibleChildren = panel.Children.Where(c => c.IsVisible).ToList();
  236. Assert.Equal(target.GetRealizedContainers().Count(), visibleChildren.Count);
  237. }
  238. }
  239. private Control CreateListBoxTemplate(TemplatedControl parent, INameScope scope)
  240. {
  241. return new ScrollViewer
  242. {
  243. Name = "PART_ScrollViewer",
  244. Template = new FuncControlTemplate(CreateScrollViewerTemplate),
  245. Content = new ItemsPresenter
  246. {
  247. Name = "PART_ItemsPresenter",
  248. [~ItemsPresenter.ItemsPanelProperty] =
  249. ((ListBox)parent).GetObservable(ItemsControl.ItemsPanelProperty).ToBinding(),
  250. }.RegisterInNameScope(scope)
  251. }.RegisterInNameScope(scope);
  252. }
  253. private Control CreateScrollViewerTemplate(TemplatedControl parent, INameScope scope)
  254. {
  255. return new ScrollContentPresenter
  256. {
  257. Name = "PART_ContentPresenter",
  258. [~ContentPresenter.ContentProperty] =
  259. parent.GetObservable(ContentControl.ContentProperty).ToBinding(),
  260. }.RegisterInNameScope(scope);
  261. }
  262. private static void Prepare(ListBox target)
  263. {
  264. target.Width = target.Height = 100;
  265. var root = new TestRoot(target);
  266. root.LayoutManager.ExecuteInitialLayoutPass();
  267. }
  268. private class Item
  269. {
  270. public Item(int id)
  271. {
  272. Id = id;
  273. }
  274. public int Id { get; }
  275. }
  276. }