From 44f84dd36bd85e847350637afb42239c64b414a5 Mon Sep 17 00:00:00 2001 From: Filip Navara Date: Thu, 30 Apr 2026 10:01:12 +0200 Subject: [PATCH 1/4] Fix iOS CollectionView stale layout invalidations There are two related races in the iOS CollectionView handler when the item source changes while cells are still participating in UIKit layout. The first race happens after a visible templated cell invalidates its measure. MAUI records that state on the cell and the next ViewWillLayoutSubviews pass asks UICollectionViewFlowLayout to invalidate exactly those changed items. Previously the handler collected the cells first and converted them back to index paths later, at the point where the invalidation context was created. If the bound ItemsSource had changed in the meantime, for example an observable source inserted items and the view then cleared or replaced ItemsSource before the next layout pass, UIKit could still report a visible cell whose current index path no longer existed in MAUI's new ItemsSource. Passing that stale index path to InvalidateItems leaves UICollectionView and the data source with inconsistent item counts and can crash during layout. Fix that path by resolving each invalidated visible cell to an NSIndexPath immediately and keeping only paths that are still valid for the current ItemsSource. The invalidation context is then built from the validated paths. This preserves targeted invalidation for normal measure changes, while dropping cells that belong to the previous source state and cannot be safely invalidated by item path anymore. The second race involves measurement cells. ItemsViewController keeps prototype templated cells in _measurementCells so their realized content can be transferred to real UICollectionView cells. When an ItemsSource update, empty-source transition, or source disposal clears that dictionary, the old code only removed the references. Those measurement cells could still be bound to item view models and still subscribed to LayoutAttributesChanged. Later binding or property changes from that stale content could propagate measure/layout invalidations through cells that are no longer owned by the active source state. In the worst case this combines with UIKit's pending layout work after ReloadData or source clearing and contributes to the same stale layout invalidation problem; it can also keep disconnected measurement content behaving as if it were still live. Fix that by centralizing measurement-cell clearing. Before the cache is cleared, each cached cell is detached from the layout-attribute event and unbound so its BindingContext is removed and future measure invalidations from that stale measured content do not flow back into the CollectionView layout. The regression test reproduces the important ordering: a templated CollectionView is displayed, a visible label changes text so the cell measure is invalidated, the observable source mutates, and ItemsSource is immediately cleared before UIKit finishes its next layout pass. The test forces layout afterward and verifies this no longer crashes. --- .../Handlers/Items/iOS/ItemsViewController.cs | 34 +++++++++--- .../CollectionView/CollectionViewTests.iOS.cs | 52 ++++++++++++++++++- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs index 04d41a5607a4..800f71d88478 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/ItemsViewController.cs @@ -139,7 +139,7 @@ void CheckForEmptySource() if (_isEmpty) { - _measurementCells?.Clear(); + ClearMeasurementCells(); ItemsViewLayout?.ClearCellSizeCache(); } @@ -257,19 +257,23 @@ private protected virtual void LayoutSupplementaryViews() void InvalidateLayoutIfItemsMeasureChanged() { var visibleCells = CollectionView.VisibleCells; - List invalidatedCells = null; + List invalidatedIndexPaths = null; var visibleCellsLength = visibleCells.Length; for (int n = 0; n < visibleCellsLength; n++) { if (visibleCells[n] is TemplatedCell { MeasureInvalidated: true } cell) { - invalidatedCells ??= []; - invalidatedCells.Add(cell); + var indexPath = CollectionView.IndexPathForCell(cell); + if (ItemsSource.IsIndexPathValid(indexPath)) + { + invalidatedIndexPaths ??= []; + invalidatedIndexPaths.Add(indexPath); + } } } - if (invalidatedCells is not null) + if (invalidatedIndexPaths is not null) { // GridLayout has a special positioning override when there's only one item // so we have to invalidate the layout entirely to trigger that special case. @@ -280,7 +284,7 @@ void InvalidateLayoutIfItemsMeasureChanged() else { var layoutInvalidationContext = new UICollectionViewFlowLayoutInvalidationContext(); - layoutInvalidationContext.InvalidateItems(invalidatedCells.Select(CollectionView.IndexPathForCell).ToArray()); + layoutInvalidationContext.InvalidateItems(invalidatedIndexPaths.ToArray()); CollectionView.CollectionViewLayout.InvalidateLayout(layoutInvalidationContext); } } @@ -419,7 +423,7 @@ protected virtual IItemsViewSource CreateItemsViewSource() public virtual void UpdateItemsSource() { - _measurementCells?.Clear(); + ClearMeasurementCells(); ItemsViewLayout?.ClearCellSizeCache(); ItemsSource?.Dispose(); ItemsSource = CreateItemsViewSource(); @@ -432,7 +436,7 @@ public virtual void UpdateItemsSource() internal void DisposeItemsSource() { - _measurementCells?.Clear(); + ClearMeasurementCells(); ItemsViewLayout?.ClearCellSizeCache(); ItemsSource?.Dispose(); ItemsSource = new EmptySource(); @@ -514,6 +518,20 @@ protected virtual void UpdateTemplatedCell(TemplatedCell cell, NSIndexPath index ItemsViewLayout.PrepareCellForLayout(cell); } + void ClearMeasurementCells() + { + if (_measurementCells is not null) + { + foreach (var measurementCell in _measurementCells.Values) + { + measurementCell.LayoutAttributesChanged -= CellLayoutAttributesChanged; + measurementCell.Unbind(); + } + + _measurementCells.Clear(); + } + } + public virtual NSIndexPath GetIndexForItem(object item) { return ItemsSource.GetIndexForItem(item); diff --git a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs index 0691177c367e..1bc0efcf3a39 100644 --- a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs @@ -251,6 +251,56 @@ public void IndexPathValidTest() Assert.False(source.IsIndexPathValid(invalidSection)); } + [Fact] + public async Task ClearingItemsSourceAfterCellMeasureInvalidationDoesNotCrash() + { + SetupBuilder(); + + var labels = new List