From 75bec7841d930ff258affc0c3989357456af7d9d Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:37:20 +0530 Subject: [PATCH 01/11] Fix-36068 --- .../Items2/CollectionViewHandler2.Windows.cs | 5 +- .../Windows/GroupedItemTemplateCollection2.cs | 8 +-- .../Items2/Windows/MauiItemsView.DragDrop.cs | 70 +++++++++++++++---- 3 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs index 8e4b49e2dfdd..bce800782623 100644 --- a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs @@ -277,7 +277,10 @@ void UpdateVisualStates() if (itemContainer?.Child is ElementWrapper wrapper && wrapper.VirtualView is VisualElement visualElement) { var actualItem = visualElement.BindingContext; - bool isSelected = object.Equals(ItemsView.SelectedItem, actualItem) || ItemsView.SelectedItems.Contains(actualItem); + // Guard against false positives for null items: object.Equals(null, null) = true would + // mark every null-item container as Selected whenever SelectedItem is null (no selection). + bool isSelected = actualItem is not null && + (object.Equals(ItemsView.SelectedItem, actualItem) || ItemsView.SelectedItems.Contains(actualItem)); VisualStateManager.GoToState(visualElement, isSelected ? VisualStateManager.CommonStates.Selected : VisualStateManager.CommonStates.Normal); // When the item template defines a "Selected" visual state, MAUI diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs b/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs index 1a2843059294..12e40124b75d 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs @@ -320,11 +320,9 @@ void HandleGroupItemsReplace(NotifyCollectionChangedEventArgs e, int flatIndex) for (int i = 0; i < e.NewItems.Count; i++) { oldItems.Add(Items[replaceIndex + i]); - var item = e.NewItems[i]; - if (item is null) - continue; - - var newItem = CreateItemContext(e.NewItems[i]!); + // Always create a new context even for null new items — skipping the slot update + // would leave the stale old ItemTemplateContext2 in the flat list (desync). + var newItem = CreateItemContext(e.NewItems[i]); newItems.Add(newItem); Items[replaceIndex + i] = newItem; } diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs index 469ec2e17506..ba8e6b4c0fbc 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs @@ -323,7 +323,7 @@ void ItemsRepeater_ElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPre // item's new position, hide it immediately. This is what makes the // "empty source slot" follow the dragged item without us having to // chase a moving _sourceContainer reference through dispatcher races. - if (_draggedItem is not null && IsContainerBoundToDraggedItem(itemContainer)) + if (_draggedSourceIndex >= 0 && IsContainerBoundToDraggedItem(itemContainer)) { itemContainer.Opacity = 0; itemContainer.IsHitTestVisible = false; @@ -332,7 +332,7 @@ void ItemsRepeater_ElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPre else { // Apply dim if a drag is in progress and this is not the source. - itemContainer.Opacity = _draggedItem is not null ? DragDimOpacity : 1; + itemContainer.Opacity = _draggedSourceIndex >= 0 ? DragDimOpacity : 1; itemContainer.IsHitTestVisible = true; } } @@ -409,21 +409,31 @@ void ItemContainer_DragStarting(UIElement sender, UI.Xaml.DragStartingEventArgs { var itemContainer = (ItemContainer)sender; + // Check whether this container is bound to a source slot at all. + // A container that has an ElementWrapper with a View IS bound — the item + // itself may be null (valid null data row), so we must not treat null as + // "no binding". We store the result so the null-item path shares the same + // cancel logic as the "container not yet set up" path. + bool hasBinding = itemContainer.Child is ElementWrapper _ew && _ew.VirtualView is View; + // Use the container's currently bound item first. The Tag/index can become // stale after a reorder because the element is reused without being recreated. object? item = GetContainerItem(itemContainer); // Fallback: look up by index from the source (works for IList and IEnumerable). - if (item is null && itemContainer.Tag is int index && index >= 0) + // Only run when there is no ElementWrapper binding (container not yet set up), + // NOT when item is null — a null BindingContext is a valid null data row. + if (!hasBinding && itemContainer.Tag is int index && index >= 0) { var sourceList = GetSourceList(); if (sourceList is not null && index < sourceList.Count) { item = GetItemAtIndex(index, sourceList); + hasBinding = true; } } - if (item is null) + if (!hasBinding) { args.Cancel = true; return; @@ -539,10 +549,12 @@ void ScrollViewer_DragOver(object sender, UI.Xaml.DragEventArgs e) bool IsContainerBoundToDraggedItem(ItemContainer container) { - return _draggedItem is not null && IsContainerBoundToItem(container, _draggedItem); + // Use _draggedSourceIndex as the "drag active" sentinel so that a null + // _draggedItem (valid null data row) does not short-circuit the check. + return _draggedSourceIndex >= 0 && IsContainerBoundToItem(container, _draggedItem); } - static bool IsContainerBoundToItem(ItemContainer container, object item) + static bool IsContainerBoundToItem(ItemContainer container, object? item) { if (container.Child is not ElementWrapper wrapper || wrapper.VirtualView is not View view) { @@ -550,6 +562,13 @@ static bool IsContainerBoundToItem(ItemContainer container, object item) } var bound = view.BindingContext; + // Allow null-bound containers to match a null dragged item. + // ReferenceEquals(null, null) == true, Equals(null, null) == true. + if (bound is null && item is null) + { + return true; + } + if (bound is null) { return false; @@ -619,7 +638,9 @@ void ScrollViewer_DragLeave(object sender, UI.Xaml.DragEventArgs e) void ScrollViewer_Drop(object sender, UI.Xaml.DragEventArgs e) { - if (!_canReorderItems || _draggedItem is null || _insertionIndex < 0 || _mauiVirtualView is null) + // _draggedSourceIndex < 0 means no active drag. _draggedItem may be null for + // null data rows, so we cannot use _draggedItem is null as the guard here. + if (!_canReorderItems || _draggedSourceIndex < 0 || _insertionIndex < 0 || _mauiVirtualView is null) { CleanupDragState(); return; @@ -701,7 +722,8 @@ void ScrollViewer_Drop(object sender, UI.Xaml.DragEventArgs e) bool PerformReorder(IList itemsList) { - if (_draggedItem is null) + // _draggedSourceIndex < 0 means no active drag; _draggedItem may be null for null data rows. + if (_draggedSourceIndex < 0) { return false; } @@ -799,7 +821,8 @@ bool PerformReorder(IList itemsList) /// bool PerformGroupedReorder(IList groupsList) { - if (_draggedItem is null || _mauiVirtualView is not GroupableItemsView groupableView) + // _draggedSourceIndex < 0 means no active drag; _draggedItem may be null for null data rows. + if (_draggedSourceIndex < 0 || _mauiVirtualView is not GroupableItemsView groupableView) { return false; } @@ -1179,18 +1202,23 @@ int GetContainerIndex(FrameworkElement container) // index and avoids the ambiguity where group headers and footers share the // same underlying Item (the group object). Validate the tag by checking that // the item at that index still matches the container's current item. + // The containerItem is not null guard was intentionally removed: null-item + // containers (valid null data rows) need tag validation too, and + // Equals(null, null) correctly returns true for them. if (container.Tag is int tagIndex && sourceList is not null && tagIndex >= 0 && tagIndex < sourceList.Count) { var tagItem = GetItemAtIndex(tagIndex, sourceList); - if (containerItem is not null && Equals(tagItem, containerItem)) + if (Equals(tagItem, containerItem)) { return tagIndex; } } // Tag is stale — fall back to a linear search. - if (sourceList is not null && containerItem is not null) + // The containerItem is not null guard was removed: null-item containers + // need linear search fallback just like any other item. + if (sourceList is not null) { var liveIndex = IndexOfItem(containerItem, sourceList); if (liveIndex >= 0) @@ -1256,6 +1284,8 @@ void UpdateAllContainerIndices() return; } + var repeater = ItemsRepeaterControl; + // Derive each container's Tag from its item's actual position in the source. // A positional loop (containers[i].Tag = i) is wrong when ItemsRepeater // virtualizes: FindAllContainers skips unrealized slots, so containers[i] @@ -1271,6 +1301,20 @@ void UpdateAllContainerIndices() container.Tag = actualIndex; } } + else + { + // For null-item containers, IndexOfItem returns the first null which + // may be a different row. Use the ItemsRepeater's authoritative element + // index instead — it is always accurate after a layout pass. + if (repeater is not null && container is ItemContainer ic) + { + int repeaterIndex = repeater.GetElementIndex(ic); + if (repeaterIndex >= 0) + { + container.Tag = repeaterIndex; + } + } + } } } @@ -1664,7 +1708,9 @@ static void RemoveDragGhostAppearance(ItemContainer container) /// void DimNonSourceContainers() { - if (_draggedItem is null) + // _draggedSourceIndex < 0 means no drag is in progress. + // _draggedItem may be null for null data rows, so we cannot use that as the guard. + if (_draggedSourceIndex < 0) { return; } From a1d258ef5982838d58cd00d18f3f7fec1b418e9b Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:59:27 +0530 Subject: [PATCH 02/11] Fix for null check --- .../Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs | 2 +- .../src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs b/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs index 12e40124b75d..cfb46cb11b64 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/GroupedItemTemplateCollection2.cs @@ -48,7 +48,7 @@ protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e) } } - ItemTemplateContext2 CreateItemContext(object item) => + ItemTemplateContext2 CreateItemContext(object? item) => new(_itemTemplate, item, _container, mauiContext: _mauiContext); ItemTemplateContext2? CreateHeaderContext(object group) => diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs index ba8e6b4c0fbc..d9040b69b1f8 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs @@ -1237,7 +1237,7 @@ int GetContainerIndex(FrameworkElement container) return allContainers.IndexOf(container); } - int IndexOfItem(object item, IList itemsList) + int IndexOfItem(object? item, IList itemsList) { // First pass: reference equality — correctly distinguishes two items that are // value-equal but distinct objects (e.g., duplicate records in the list). From c855b1396bbb7e027e546faf456a7d291db70a49 Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:42:13 +0530 Subject: [PATCH 03/11] fix for null selection --- .../Items2/CollectionViewHandler2.Windows.cs | 15 ++++++++------- .../Handlers/Items2/Windows/ItemFactory.cs | 19 +++++++++++++++++++ .../Items2/Windows/MauiItemsView.DragDrop.cs | 4 +++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs index bce800782623..ee625a89a3dd 100644 --- a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs @@ -434,17 +434,18 @@ void UpdatePlatformSelection() switch (PlatformView.SelectionMode) { case ItemsViewSelectionMode.Single: - if (ItemsView.SelectedItem is null) + // FindItemIndexInSource uses object.Equals so it matches null items correctly. + // When SelectedItem is null and a null entry exists in the source, Select(index) + // is called. When SelectedItem is null and no null entry exists (i.e. selection + // was programmatically cleared), selectedIndex is -1 and DeselectAll() is called. + var selectedIndex = FindItemIndexInSource(itemList, ItemsView.SelectedItem); + if (selectedIndex >= 0) { - PlatformView.DeselectAll(); + PlatformView.Select(selectedIndex); } else { - var selectedIndex = FindItemIndexInSource(itemList, ItemsView.SelectedItem); - if (selectedIndex >= 0) - { - PlatformView.Select(selectedIndex); - } + PlatformView.DeselectAll(); } break; diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs b/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs index 97408f237451..f1de6cf4638c 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs @@ -40,6 +40,25 @@ internal partial class ItemFactory(ItemsView view) : IElementFactory // NOTE: 1.6: replace w/ RecyclePool if (args.Data is ItemTemplateContext2 templateContext) { + // Null regular-data items render as blank rows with no template content, + // matching CV1 behaviour (ItemContentControl.Realize() returns early when + // dataContext is null). Header/footer items fall through to the normal + // path so they can inherit the parent ItemsView.BindingContext. + if (templateContext.Item is null && !templateContext.IsHeader && !templateContext.IsFooter) + { + // CV1's ItemContentControl.MeasureOverride returns a 32px height hint when + // _handler is null (virtualization hint only). The actual rendered height + // is driven by WinUI's ListViewItem default MinHeight (40px from the + // ListViewItemMinHeight theme resource). CV2 has no ListViewItem wrapper, so + // set MinHeight = 40 directly on the ItemContainer to match CV1's visual slot. + return new ItemContainer + { + MinHeight = 40, + VerticalAlignment = VerticalAlignment.Stretch, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + } + DataTemplate? template = templateContext.MauiDataTemplate; if (template is DataTemplateSelector selector) { diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs index d9040b69b1f8..6c3e78ee5fc6 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs @@ -558,7 +558,9 @@ static bool IsContainerBoundToItem(ItemContainer container, object? item) { if (container.Child is not ElementWrapper wrapper || wrapper.VirtualView is not View view) { - return false; + // Blank container (no ElementWrapper) represents a null data item. + // It matches when the dragged item is also null. + return item is null; } var bound = view.BindingContext; From 9717de34649c7d72eec74fda7ae35e6dddc63ebd Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:53:37 +0530 Subject: [PATCH 04/11] Update CollectionViewHandler2.Windows.cs --- .../Items2/CollectionViewHandler2.Windows.cs | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs index ee625a89a3dd..b17595538b04 100644 --- a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs @@ -36,6 +36,10 @@ public partial class CollectionViewHandler2 : ReorderableItemsViewHandler2= 0) + if (ItemsView.SelectedItem is null) { - PlatformView.Select(selectedIndex); + PlatformView.DeselectAll(); } else { - PlatformView.DeselectAll(); + var selectedIndex = FindItemIndexInSource(itemList, ItemsView.SelectedItem); + if (selectedIndex >= 0) + { + PlatformView.Select(selectedIndex); + } } break; From d4897ac9085be03a5d812fdef2a644eb1ed0a983 Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:04:44 +0530 Subject: [PATCH 05/11] Fix for review concerns --- .../Items2/CollectionViewHandler2.Windows.cs | 14 ++++++-- .../Items2/Windows/MauiItemsView.DragDrop.cs | 33 +++++++++++++++---- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs index b17595538b04..75f84088788f 100644 --- a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs @@ -329,11 +329,19 @@ void UpdateVirtualSingleSelection() // Use flag instead of detach/re-attach so that MapSelectedItem is suppressed // while SelectedItem is set. Both fire synchronously; the flag is reset after. + // try/finally ensures the flag and event handler are always restored even if + // a BindableProperty callback or user PropertyChanged handler throws. _ignoreVirtualSelectionChange = true; ItemsView.SelectionChanged -= VirtualSelectionChanged; - ItemsView.SelectedItem = selectedItem; - ItemsView.SelectionChanged += VirtualSelectionChanged; - _ignoreVirtualSelectionChange = false; + try + { + ItemsView.SelectedItem = selectedItem; + } + finally + { + ItemsView.SelectionChanged += VirtualSelectionChanged; + _ignoreVirtualSelectionChange = false; + } } void UpdateVirtualMultipleSelection() diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs index 6c3e78ee5fc6..537d2f2a557f 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs @@ -410,12 +410,22 @@ void ItemContainer_DragStarting(UIElement sender, UI.Xaml.DragStartingEventArgs var itemContainer = (ItemContainer)sender; // Check whether this container is bound to a source slot at all. - // A container that has an ElementWrapper with a View IS bound — the item - // itself may be null (valid null data row), so we must not treat null as - // "no binding". We store the result so the null-item path shares the same - // cancel logic as the "container not yet set up" path. + // A container with an ElementWrapper + View is a normal (non-null) data row. + // A blank container (no Child) whose Tag was set during ElementPrepared is a + // null data row — ItemFactory returns a plain ItemContainer for null items. + // In both cases the drag should proceed; only containers with no Tag at all + // (never prepared, or cleared by ElementClearing) must cancel the drag. bool hasBinding = itemContainer.Child is ElementWrapper _ew && _ew.VirtualView is View; + // Blank null-data container: ItemFactory creates an ElementWrapper-less + // ItemContainer for null data items. The Tag is set by ApplyDragAffordance + // during ElementPrepared and cleared to null by ElementClearing, so a valid + // int Tag means the container is currently realised for a null data row. + if (!hasBinding && itemContainer.Child is null && itemContainer.Tag is int) + { + hasBinding = true; + } + // Use the container's currently bound item first. The Tag/index can become // stale after a reorder because the element is reused without being recreated. object? item = GetContainerItem(itemContainer); @@ -549,9 +559,18 @@ void ScrollViewer_DragOver(object sender, UI.Xaml.DragEventArgs e) bool IsContainerBoundToDraggedItem(ItemContainer container) { - // Use _draggedSourceIndex as the "drag active" sentinel so that a null - // _draggedItem (valid null data row) does not short-circuit the check. - return _draggedSourceIndex >= 0 && IsContainerBoundToItem(container, _draggedItem); + if (_draggedSourceIndex < 0) + return false; + + // Blank null-data containers: use the Tag (flat repeater index) to identify + // the specific slot. Value equality (null == null) cannot distinguish multiple + // null items across groups — the Tag is the only per-slot discriminator. + if (container.Child is not ElementWrapper) + { + return container.Tag is int tagIndex && tagIndex == _draggedSourceIndex; + } + + return IsContainerBoundToItem(container, _draggedItem); } static bool IsContainerBoundToItem(ItemContainer container, object? item) From 3f152dc0789f92a3b226f2a0308626a474429c6b Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:53:31 +0530 Subject: [PATCH 06/11] DeviceTest for CV2 --- .../CollectionViewTests.Windows.cs | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs index 7ab563b4198d..8e4261336a92 100644 --- a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs @@ -423,5 +423,127 @@ internal Animal(string name, string location) Location = location; } } + + [Fact] + public async Task NullItem_RendersBlankRow() + { + SetupBuilder(); + + var data = new ObservableCollection { "Item 1", null, "Item 3" }; + + var collectionView = new CollectionView + { + ItemTemplate = new Controls.DataTemplate(() => + { + var label = new Label(); + label.SetBinding(Label.TextProperty, new Binding(".")); + return label; + }), + ItemsSource = data, + HeightRequest = 400, + WidthRequest = 300 + }; + + await CreateHandlerAndAddToWindow(collectionView, async handler => + { + await Task.Delay(500); + + var listView = (UI.Xaml.Controls.ListView)handler.PlatformView; + var containers = listView.GetChildren().ToList(); + + // There should be at least 3 containers (one per item including null) + Assert.True(containers.Count >= 3, $"Expected at least 3 containers, got {containers.Count}"); + + // The null-item container (index 1) should have non-zero height (blank row) + var nullContainer = containers[1]; + var bounds = nullContainer.GetBoundingBox(); + Assert.True(bounds.Height > 0, $"Null item container should have non-zero height, got {bounds.Height}"); + }); + } + + [Fact] + public async Task NullItem_TapDoesNotCrash_SingleSelection() + { + SetupBuilder(); + + var data = new ObservableCollection { "Item 1", null, "Item 3" }; + + var collectionView = new CollectionView + { + ItemTemplate = new Controls.DataTemplate(() => + { + var label = new Label { HeightRequest = 40 }; + label.SetBinding(Label.TextProperty, new Binding(".")); + return label; + }), + ItemsSource = data, + SelectionMode = SelectionMode.Single, + HeightRequest = 400, + WidthRequest = 300 + }; + + var layout = new VerticalStackLayout + { + collectionView + }; + + await CreateHandlerAndAddToWindow(layout, async handler => + { + await Task.Delay(500); + + // Selecting the null item programmatically should not throw + var exception = await Record.ExceptionAsync(async () => + { + collectionView.SelectedItem = null; + await Task.Delay(100); + }); + + Assert.Null(exception); + Assert.Null(collectionView.SelectedItem); + }); + } + + [Fact] + public async Task NullItem_DragReorder_DoesNotMoveWrongRow() + { + SetupBuilder(); + + var data = new ObservableCollection { "Item 1", null, null, "Item 4" }; + + var collectionView = new CollectionView + { + ItemTemplate = new Controls.DataTemplate(() => + { + var label = new Label { HeightRequest = 40 }; + label.SetBinding(Label.TextProperty, new Binding(".")); + return label; + }), + ItemsSource = data, + CanReorderItems = true, + HeightRequest = 400, + WidthRequest = 300 + }; + + var layout = new VerticalStackLayout + { + collectionView + }; + + await CreateHandlerAndAddToWindow(layout, async handler => + { + await Task.Delay(500); + + // Simulate a reorder: move item at index 0 ("Item 1") to index 2 + // This exercises the code path where null items exist in the collection + data.Move(0, 2); + await Task.Delay(200); + + // After the move, the order should be: null, null, "Item 1", "Item 4" + Assert.Null(data[0]); + Assert.Null(data[1]); + Assert.Equal("Item 1", data[2]); + Assert.Equal("Item 4", data[3]); + }); + } } } From 2ca466a3c988bb3909d9c41f003a8e3c9b786b68 Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:46:49 +0530 Subject: [PATCH 07/11] Review files --- .../Items2/Windows/MauiItemsView.DragDrop.cs | 45 ++++++++++++++----- .../CollectionViewTests.Windows.cs | 40 ++++++++++++++--- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs index 537d2f2a557f..8ca51424c741 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs @@ -415,7 +415,7 @@ void ItemContainer_DragStarting(UIElement sender, UI.Xaml.DragStartingEventArgs // null data row — ItemFactory returns a plain ItemContainer for null items. // In both cases the drag should proceed; only containers with no Tag at all // (never prepared, or cleared by ElementClearing) must cancel the drag. - bool hasBinding = itemContainer.Child is ElementWrapper _ew && _ew.VirtualView is View; + bool hasBinding = itemContainer.Child is ElementWrapper { VirtualView: View }; // Blank null-data container: ItemFactory creates an ElementWrapper-less // ItemContainer for null data items. The Tag is set by ApplyDragAffordance @@ -851,38 +851,44 @@ bool PerformGroupedReorder(IList groupsList) bool hasHeaders = groupableView.GroupHeaderTemplate is not null; bool hasFooters = groupableView.GroupFooterTemplate is not null; - // Find which group the dragged item belongs to. - // Groups may be IEnumerable-only (e.g., IGrouping), so enumerate rather - // than requiring IList for the search. IList is still required for mutation. + // Find which group and item within that group the dragged item belongs to, + // using the flat repeater index (_draggedSourceIndex) rather than value equality. + // Value equality cannot distinguish multiple null items across groups — the flat + // index is the only per-slot discriminator for null-item drags. int sourceGroupIndex = -1; int sourceItemIndex = -1; IList? sourceGroup = null; + int flatSrcPos = 0; for (int g = 0; g < groupsList.Count; g++) { - if (groupsList[g] is not IEnumerable groupItems) + if (groupsList[g] is not IEnumerable groupSrcItems) { continue; } + if (hasHeaders) + flatSrcPos++; // skip header + int i = 0; - foreach (var groupItem in groupItems) + foreach (var _ in groupSrcItems) { - if (ReferenceEquals(groupItem, _draggedItem) || Equals(groupItem, _draggedItem)) + if (flatSrcPos == _draggedSourceIndex) { sourceGroupIndex = g; sourceItemIndex = i; sourceGroup = groupsList[g] as IList; break; } - + flatSrcPos++; i++; } if (sourceGroupIndex >= 0) - { break; - } + + if (hasFooters) + flatSrcPos++; // skip footer } // sourceGroup being null means the group is not mutable — reorder not possible. @@ -1219,6 +1225,25 @@ int GetContainerIndex(FrameworkElement container) var sourceList = GetSourceList(); var containerItem = GetContainerItem(container); + // For null-item containers (no ElementWrapper child), GetContainerItem returns null. + // Equals(null, null) == true, so the Tag validation below cannot detect a stale + // Tag — any index that also holds null would be accepted. Use GetElementIndex + // for the authoritative repeater position instead. + if (containerItem is null) + { + var repeater = ItemsRepeaterControl; + if (repeater is not null) + { + int repeaterIndex = repeater.GetElementIndex(container); + if (repeaterIndex >= 0) + return repeaterIndex; + } + // GetElementIndex failed (container not realized); fall back to raw Tag. + if (container.Tag is int fallbackIndex) + return fallbackIndex; + return -1; + } + // Prefer the Tag set during ElementPrepared — it is the authoritative flat // index and avoids the ambiguity where group headers and footers share the // same underlying Item (the group object). Validate the tag by checking that diff --git a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs index 8e4261336a92..e1924d815f06 100644 --- a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs @@ -6,13 +6,16 @@ using System.Threading.Tasks; using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Handlers.Items; +using Microsoft.Maui.Controls.Handlers.Items2; using Microsoft.Maui.Controls.Platform; using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; using Microsoft.Maui.Platform; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; using Xunit; using static Microsoft.Maui.DeviceTests.AssertHelpers; +using WItemsView = Microsoft.UI.Xaml.Controls.ItemsView; using WSetter = Microsoft.UI.Xaml.Setter; namespace Microsoft.Maui.DeviceTests @@ -427,7 +430,14 @@ internal Animal(string name, string location) [Fact] public async Task NullItem_RendersBlankRow() { - SetupBuilder(); + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + handlers.AddHandler(); + handlers.AddHandler(); + }); + }); var data = new ObservableCollection { "Item 1", null, "Item 3" }; @@ -435,7 +445,7 @@ public async Task NullItem_RendersBlankRow() { ItemTemplate = new Controls.DataTemplate(() => { - var label = new Label(); + var label = new Label { HeightRequest = 40 }; label.SetBinding(Label.TextProperty, new Binding(".")); return label; }), @@ -444,12 +454,12 @@ public async Task NullItem_RendersBlankRow() WidthRequest = 300 }; - await CreateHandlerAndAddToWindow(collectionView, async handler => + await CreateHandlerAndAddToWindow(collectionView, async handler => { await Task.Delay(500); - var listView = (UI.Xaml.Controls.ListView)handler.PlatformView; - var containers = listView.GetChildren().ToList(); + var itemsView = (WItemsView)handler.PlatformView; + var containers = itemsView.GetChildren().ToList(); // There should be at least 3 containers (one per item including null) Assert.True(containers.Count >= 3, $"Expected at least 3 containers, got {containers.Count}"); @@ -464,7 +474,15 @@ await CreateHandlerAndAddToWindow(collectionView, async h [Fact] public async Task NullItem_TapDoesNotCrash_SingleSelection() { - SetupBuilder(); + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + }); + }); var data = new ObservableCollection { "Item 1", null, "Item 3" }; @@ -506,7 +524,15 @@ await CreateHandlerAndAddToWindow(layout, async handler => [Fact] public async Task NullItem_DragReorder_DoesNotMoveWrongRow() { - SetupBuilder(); + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handlers => + { + handlers.AddHandler(); + handlers.AddHandler(); + handlers.AddHandler(); + }); + }); var data = new ObservableCollection { "Item 1", null, null, "Item 4" }; From 662726c0ac736442884593d85ac1a3e97a66202b Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:47:18 +0530 Subject: [PATCH 08/11] Review concern addressed --- .../Items2/CollectionViewHandler2.Windows.cs | 29 ++++++++++--------- .../Handlers/Items2/Windows/ItemFactory.cs | 10 +++---- .../Items2/Windows/MauiItemsView.DragDrop.cs | 8 +++++ .../CollectionViewTests.Windows.cs | 8 +++-- 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs index 75f84088788f..9cad02cb8dd7 100644 --- a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs @@ -327,21 +327,24 @@ void UpdateVirtualSingleSelection() ? itemPair.Item : PlatformView.SelectedItem; - // Use flag instead of detach/re-attach so that MapSelectedItem is suppressed - // while SelectedItem is set. Both fire synchronously; the flag is reset after. - // try/finally ensures the flag and event handler are always restored even if - // a BindableProperty callback or user PropertyChanged handler throws. + // Detach the SelectionChanged event handler and set a flag to prevent MapSelectedItem + // from calling UpdatePlatformSelection when SelectedItem is set. This breaks the + // round-trip: WinUI selects item → SelectedItem = item → MapSelectedItem → + // UpdatePlatformSelection (which would undo the selection). Both fire synchronously; + // the flag and handler are restored after. Try/finally ensures the flag and event + // handler are always restored even if a BindableProperty callback or user + // PropertyChanged handler throws. _ignoreVirtualSelectionChange = true; ItemsView.SelectionChanged -= VirtualSelectionChanged; - try - { - ItemsView.SelectedItem = selectedItem; - } - finally - { - ItemsView.SelectionChanged += VirtualSelectionChanged; - _ignoreVirtualSelectionChange = false; - } + try + { + ItemsView.SelectedItem = selectedItem; + } + finally + { + ItemsView.SelectionChanged += VirtualSelectionChanged; + _ignoreVirtualSelectionChange = false; + } } void UpdateVirtualMultipleSelection() diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs b/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs index f1de6cf4638c..e36127d6682c 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs @@ -46,14 +46,12 @@ internal partial class ItemFactory(ItemsView view) : IElementFactory // path so they can inherit the parent ItemsView.BindingContext. if (templateContext.Item is null && !templateContext.IsHeader && !templateContext.IsFooter) { - // CV1's ItemContentControl.MeasureOverride returns a 32px height hint when - // _handler is null (virtualization hint only). The actual rendered height - // is driven by WinUI's ListViewItem default MinHeight (40px from the - // ListViewItemMinHeight theme resource). CV2 has no ListViewItem wrapper, so - // set MinHeight = 40 directly on the ItemContainer to match CV1's visual slot. + // CV2 has no ListViewItem wrapper, so we set both MinHeight and MinWidth on ItemContainer. + // Default sizing hints for null-data item containers in CV2, based on CV1 behavior. return new ItemContainer { - MinHeight = 40, + MinHeight = 32, + MinWidth = 88, VerticalAlignment = VerticalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Stretch }; diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs index 8ca51424c741..2f05147678cb 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/MauiItemsView.DragDrop.cs @@ -582,6 +582,14 @@ static bool IsContainerBoundToItem(ItemContainer container, object? item) return item is null; } + // Prevent header/footer containers from being treated as dragged sources. + // Header/footer items are not draggable and should never match the dragged item, + // even if both have null BindingContext. Only data items can be the drag source. + if (wrapper.IsHeaderOrFooter) + { + return false; + } + var bound = view.BindingContext; // Allow null-bound containers to match a null dragged item. // ReferenceEquals(null, null) == true, Equals(null, null) == true. diff --git a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs index e1924d815f06..47f8381a147b 100644 --- a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs @@ -522,7 +522,7 @@ await CreateHandlerAndAddToWindow(layout, async handler => } [Fact] - public async Task NullItem_DragReorder_DoesNotMoveWrongRow() + public async Task NullItem_CollectionMove_DoesNotMoveWrongRow() { EnsureHandlerCreated(builder => { @@ -559,8 +559,10 @@ await CreateHandlerAndAddToWindow(layout, async handler => { await Task.Delay(500); - // Simulate a reorder: move item at index 0 ("Item 1") to index 2 - // This exercises the code path where null items exist in the collection + // Test ObservableCollection.Move() directly instead of drag/drop simulation. + // This exercises the code path where null items exist in the collection. + // NOTE: This tests data binding behavior, not actual drag-drop UI interaction. + // For real drag-drop testing, use actual pointer events (tap + drag). data.Move(0, 2); await Task.Delay(200); From 2a7fbdeee0702ed6edb6f07b744288ba75366930 Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:44:10 +0530 Subject: [PATCH 09/11] Fix for nullItem recycle --- .../Handlers/Items2/Windows/ItemFactory.cs | 26 ++++++ .../CollectionViewTests.Windows.cs | 69 +++++----------- .../CollectionViewNullItemDragReorder.xaml | 34 ++++++++ .../CollectionViewNullItemDragReorder.xaml.cs | 62 ++++++++++++++ .../CollectionViewNullItemDragReorder.cs | 80 +++++++++++++++++++ 5 files changed, 220 insertions(+), 51 deletions(-) create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml.cs create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/CollectionViewNullItemDragReorder.cs diff --git a/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs b/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs index e36127d6682c..99bafd99cb27 100644 --- a/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs +++ b/src/Controls/src/Core/Handlers/Items2/Windows/ItemFactory.cs @@ -14,6 +14,15 @@ internal partial class ItemFactory(ItemsView view) : IElementFactory readonly ItemsView _view = view; Dictionary> _recyclePool = new(); + /// + /// Dedicated pool for blank (null-item) instances. These have + /// no child and thus no key, so they + /// cannot be stored in . Without this pool, every null item scrolled + /// into view allocates a brand-new ItemContainer instead of reusing one, causing repeated + /// allocations during scrolling of sparse/null-containing collections. + /// + List _nullItemPool = new(); + /// /// A minimal ControlTemplate for ItemContainer that contains no selection visuals /// (no PART_SelectionCheckbox, no PART_SelectionVisual, no PART_CommonVisual). @@ -46,6 +55,15 @@ internal partial class ItemFactory(ItemsView view) : IElementFactory // path so they can inherit the parent ItemsView.BindingContext. if (templateContext.Item is null && !templateContext.IsHeader && !templateContext.IsFooter) { + // Reuse a pooled blank container if one is available instead of allocating a + // new ItemContainer on every scroll pass over null items. + if (_nullItemPool.Count > 0) + { + var pooledNullContainer = _nullItemPool[^1]; + _nullItemPool.RemoveAt(_nullItemPool.Count - 1); + return pooledNullContainer; + } + // CV2 has no ListViewItem wrapper, so we set both MinHeight and MinWidth on ItemContainer. // Default sizing hints for null-data item containers in CV2, based on CV1 behavior. return new ItemContainer @@ -246,6 +264,13 @@ public void RecycleElement(ElementFactoryRecycleArgs args) _recyclePool[template] = new List { item }; } } + else if (item is not null && item.Child is null) + { + // Blank containers created for null regular-data items have no ElementWrapper + // child (and therefore no template key), so they can't go in _recyclePool. + // Pool them separately to avoid reallocating on every scroll over null items. + _nullItemPool.Add(item); + } _view.RemoveLogicalChild(wrapperView); } @@ -277,6 +302,7 @@ internal void CleanUp() } _recyclePool.Clear(); + _nullItemPool.Clear(); } } diff --git a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs index 47f8381a147b..d8b40969071b 100644 --- a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs +++ b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.Windows.cs @@ -509,68 +509,35 @@ await CreateHandlerAndAddToWindow(layout, async handler => { await Task.Delay(500); - // Selecting the null item programmatically should not throw + var itemsView = (WItemsView)collectionView.Handler.PlatformView; + var containers = itemsView.GetChildren().ToList(); + Assert.True(containers.Count >= 3, $"Expected at least 3 containers, got {containers.Count}"); + + // Drive selection from the platform side (as a real tap would), instead of + // setting CollectionView.SelectedItem directly. This exercises the + // PlatformSelectionChanged -> UpdateVirtualSingleSelection round-trip, which + // is the path that previously threw/undid selection for a null-item row. + var nullContainer = containers[1]; var exception = await Record.ExceptionAsync(async () => { - collectionView.SelectedItem = null; + nullContainer.IsSelected = true; await Task.Delay(100); }); Assert.Null(exception); Assert.Null(collectionView.SelectedItem); - }); - } + Assert.True(nullContainer.IsSelected, "Platform container for the null item should remain selected."); - [Fact] - public async Task NullItem_CollectionMove_DoesNotMoveWrongRow() - { - EnsureHandlerCreated(builder => - { - builder.ConfigureMauiHandlers(handlers => + // Deselecting from the platform side should also round-trip cleanly. + exception = await Record.ExceptionAsync(async () => { - handlers.AddHandler(); - handlers.AddHandler(); - handlers.AddHandler(); + nullContainer.IsSelected = false; + await Task.Delay(100); }); - }); - - var data = new ObservableCollection { "Item 1", null, null, "Item 4" }; - var collectionView = new CollectionView - { - ItemTemplate = new Controls.DataTemplate(() => - { - var label = new Label { HeightRequest = 40 }; - label.SetBinding(Label.TextProperty, new Binding(".")); - return label; - }), - ItemsSource = data, - CanReorderItems = true, - HeightRequest = 400, - WidthRequest = 300 - }; - - var layout = new VerticalStackLayout - { - collectionView - }; - - await CreateHandlerAndAddToWindow(layout, async handler => - { - await Task.Delay(500); - - // Test ObservableCollection.Move() directly instead of drag/drop simulation. - // This exercises the code path where null items exist in the collection. - // NOTE: This tests data binding behavior, not actual drag-drop UI interaction. - // For real drag-drop testing, use actual pointer events (tap + drag). - data.Move(0, 2); - await Task.Delay(200); - - // After the move, the order should be: null, null, "Item 1", "Item 4" - Assert.Null(data[0]); - Assert.Null(data[1]); - Assert.Equal("Item 1", data[2]); - Assert.Equal("Item 4", data[3]); + Assert.Null(exception); + Assert.Null(collectionView.SelectedItem); + Assert.False(nullContainer.IsSelected); }); } } diff --git a/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml b/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml new file mode 100644 index 000000000000..53dcb494c84b --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + diff --git a/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml.cs new file mode 100644 index 000000000000..119723af2453 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml.cs @@ -0,0 +1,62 @@ +#nullable enable +using System; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 36068, "CollectionView2 (Windows) drag-and-drop reorder with null items in the ItemsSource", PlatformAffected.All)] +public partial class CollectionViewNullItemDragReorder : ContentPage +{ + public ObservableCollection Items { get; } + + public CollectionViewNullItemDragReorder() + { + InitializeComponent(); + + // A null entry sits between two real items so a drag can either originate from, + // or land on, the blank row rendered for it. This exercises the container-index + // and drag-source lookups used for null-data rows during reorder (GetContainerIndex / + // UpdateAllContainerIndices / the Tag-based blank-container drag detection), not just + // ObservableCollection.Move() semantics. + Items = new ObservableCollection + { + new ReorderItem(0, "Item A"), + null, + new ReorderItem(2, "Item B"), + new ReorderItem(3, "Item C"), + }; + + BindingContext = this; + + ReorderCollectionView.ReorderCompleted += OnReorderCompleted; + + UpdateStatusLabel(); + } + + void OnReorderCompleted(object? sender, EventArgs e) + { + UpdateStatusLabel(); + } + + void UpdateStatusLabel() + { + ReorderStatusLabel.Text = string.Join(", ", Items.Select(item => item?.Text ?? "null")); + } + + public class ReorderItem + { + public ReorderItem(int index, string text) + { + Text = text; + ContainerAutomationId = $"ReorderItem{index}"; + LabelAutomationId = $"ReorderItemLabel{index}"; + } + + public string Text { get; set; } + + public string ContainerAutomationId { get; } + + public string LabelAutomationId { get; } + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/CollectionViewNullItemDragReorder.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/CollectionViewNullItemDragReorder.cs new file mode 100644 index 000000000000..7ac4e403123c --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/CollectionViewNullItemDragReorder.cs @@ -0,0 +1,80 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class CollectionViewNullItemDragReorder : _IssuesUITest +{ + public CollectionViewNullItemDragReorder(TestDevice device) : base(device) + { + } + + public override string Issue => "CollectionView2 (Windows) drag-and-drop reorder with null items in the ItemsSource"; + + [Test] + [Category(UITestCategories.CollectionView)] + public void DraggingNullItemRowDoesNotCrashAndReorders() + { + App.WaitForElement("ReorderItemLabel0"); + App.WaitForElement("ReorderItemLabel2"); + App.WaitForElement("ReorderItemLabel3"); + + var initialOrder = App.WaitForElement("ReorderStatusLabel").GetText(); + Assert.That(initialOrder, Is.EqualTo("Item A, null, Item B, Item C")); + + var itemARect = App.WaitForElement("ReorderItemLabel0").GetRect(); + var itemBRect = App.WaitForElement("ReorderItemLabel2").GetRect(); + var itemCRect = App.WaitForElement("ReorderItemLabel3").GetRect(); + + float nullRowX = itemARect.CenterX(); + float nullRowY = (itemARect.Bottom + itemBRect.Top) / 2f; + + App.DragCoordinates(nullRowX, nullRowY, itemCRect.CenterX(), itemCRect.CenterY()); + + App.WaitForElement("ReorderStatusLabel"); + var reorderedText = App.WaitForElement("ReorderStatusLabel").GetText(); + + Assert.That(reorderedText, Is.Not.EqualTo(initialOrder), + "Dragging the null-item row should trigger ReorderCompleted and change the item order."); + + var reorderedItems = reorderedText?.Split(", "); + Assert.That(reorderedItems, Has.Length.EqualTo(4), "No item should be lost or duplicated during the reorder."); + Assert.That(reorderedItems?.Count(i => i == "null"), Is.EqualTo(1), "The null item must still be present exactly once."); + } + + [Test] + [Category(UITestCategories.CollectionView)] + public void DraggingRealItemOntoNullItemRowDoesNotCrashAndReorders() + { + + App.WaitForElement("ReorderItemLabel0"); + App.WaitForElement("ReorderItemLabel2"); + App.WaitForElement("ReorderItemLabel3"); + + var initialOrder = App.WaitForElement("ReorderStatusLabel").GetText(); + Assert.That(initialOrder, Is.EqualTo("Item A, null, Item B, Item C")); + + var itemARect = App.WaitForElement("ReorderItemLabel0").GetRect(); + var itemBRect = App.WaitForElement("ReorderItemLabel2").GetRect(); + var itemCRect = App.WaitForElement("ReorderItemLabel3").GetRect(); + + float nullRowX = itemARect.CenterX(); + float nullRowY = (itemARect.Bottom + itemBRect.Top) / 2f; + + // Drag "Item C" (last item) and drop it onto the null row, which sits between + // "Item A" and "Item B". This exercises PerformReorder's insertion-index handling + // when the drop target is a blank null-data container. + App.DragCoordinates(itemCRect.CenterX(), itemCRect.CenterY(), nullRowX, nullRowY); + + App.WaitForElement("ReorderStatusLabel"); + var reorderedText = App.WaitForElement("ReorderStatusLabel").GetText(); + + Assert.That(reorderedText, Is.Not.EqualTo(initialOrder), + "Dropping an item onto the null-item row should trigger ReorderCompleted and change the item order."); + + var reorderedItems = reorderedText?.Split(", "); + Assert.That(reorderedItems, Has.Length.EqualTo(4), "No item should be lost or duplicated during the reorder."); + Assert.That(reorderedItems?.Count(i => i == "null"), Is.EqualTo(1), "The null item must still be present exactly once."); + } +} \ No newline at end of file From 18bdfbd04f3c46c3f8dd77256852926e80a0d04a Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:18:46 +0530 Subject: [PATCH 10/11] Fix review changes --- .../Issues/CollectionViewNullItemDragReorder.xaml.cs | 2 +- .../Tests/Issues/CollectionViewNullItemDragReorder.cs | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml.cs b/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml.cs index 119723af2453..d2bf81a8f160 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/CollectionViewNullItemDragReorder.xaml.cs @@ -5,7 +5,7 @@ namespace Maui.Controls.Sample.Issues; -[Issue(IssueTracker.Github, 36068, "CollectionView2 (Windows) drag-and-drop reorder with null items in the ItemsSource", PlatformAffected.All)] +[Issue(IssueTracker.Github, 36068, "CollectionView2 (Windows) drag-and-drop reorder with null items in the ItemsSource", PlatformAffected.UWP)] public partial class CollectionViewNullItemDragReorder : ContentPage { public ObservableCollection Items { get; } diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/CollectionViewNullItemDragReorder.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/CollectionViewNullItemDragReorder.cs index 7ac4e403123c..5dff42a299a7 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/CollectionViewNullItemDragReorder.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/CollectionViewNullItemDragReorder.cs @@ -1,3 +1,4 @@ +#if WINDOWS // CollectionView2 drag-and-drop reorder with null items is only applicable on Windows. using NUnit.Framework; using UITest.Appium; using UITest.Core; @@ -15,7 +16,7 @@ public CollectionViewNullItemDragReorder(TestDevice device) : base(device) [Test] [Category(UITestCategories.CollectionView)] public void DraggingNullItemRowDoesNotCrashAndReorders() - { + { App.WaitForElement("ReorderItemLabel0"); App.WaitForElement("ReorderItemLabel2"); App.WaitForElement("ReorderItemLabel3"); @@ -40,14 +41,13 @@ public void DraggingNullItemRowDoesNotCrashAndReorders() var reorderedItems = reorderedText?.Split(", "); Assert.That(reorderedItems, Has.Length.EqualTo(4), "No item should be lost or duplicated during the reorder."); - Assert.That(reorderedItems?.Count(i => i == "null"), Is.EqualTo(1), "The null item must still be present exactly once."); + Assert.That(reorderedItems, Does.Contain("null"), "The null item must still be present after the reorder."); } [Test] [Category(UITestCategories.CollectionView)] public void DraggingRealItemOntoNullItemRowDoesNotCrashAndReorders() { - App.WaitForElement("ReorderItemLabel0"); App.WaitForElement("ReorderItemLabel2"); App.WaitForElement("ReorderItemLabel3"); @@ -75,6 +75,7 @@ public void DraggingRealItemOntoNullItemRowDoesNotCrashAndReorders() var reorderedItems = reorderedText?.Split(", "); Assert.That(reorderedItems, Has.Length.EqualTo(4), "No item should be lost or duplicated during the reorder."); - Assert.That(reorderedItems?.Count(i => i == "null"), Is.EqualTo(1), "The null item must still be present exactly once."); + Assert.That(reorderedItems, Does.Contain("null"), "The null item must still be present after the reorder."); } -} \ No newline at end of file +} +#endif From f5716e293c5e223ce90d0a382e48faa56653b456 Mon Sep 17 00:00:00 2001 From: SuthiYuvaraj <92777079+SuthiYuvaraj@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:34:21 +0530 Subject: [PATCH 11/11] Fix for tab --- .../Items2/CollectionViewHandler2.Windows.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs index 9cad02cb8dd7..d7cb37b96ba3 100644 --- a/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs +++ b/src/Controls/src/Core/Handlers/Items2/CollectionViewHandler2.Windows.cs @@ -288,11 +288,13 @@ void UpdateVisualStates() if (itemContainer?.Child is ElementWrapper wrapper && wrapper.VirtualView is VisualElement visualElement) { var actualItem = visualElement.BindingContext; - // Guard against false positives for null items: object.Equals(null, null) = true would - // mark every null-item container as Selected whenever SelectedItem is null (no selection). - bool isSelected = actualItem is not null && - (object.Equals(ItemsView.SelectedItem, actualItem) || ItemsView.SelectedItems.Contains(actualItem)); - VisualStateManager.GoToState(visualElement, isSelected ? VisualStateManager.CommonStates.Selected : VisualStateManager.CommonStates.Normal); + bool isSelected = object.Equals(ItemsView.SelectedItem, actualItem) || ItemsView.SelectedItems.Contains(actualItem); + // Use IsItemSelected instead of GoToState directly so that ChangeVisualState() + // has the correct selected state when a pointer-enter/leave or IsEnabled change + // fires later. IsElementInSelectedState() reads IsItemSelected, so bypassing it + // here (as was done before PR #35421) caused PointerOver-exit and re-enable + // events to incorrectly transition the item to Normal instead of Selected. + visualElement.IsItemSelected = isSelected; // When the item template defines a "Selected" visual state, MAUI // handles the selection appearance. Suppress the native WinUI