diff --git a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs index 5e82c2e91d57..f0ef75743234 100644 --- a/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs +++ b/src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs @@ -136,6 +136,12 @@ public override void LayoutSubviews() // We now have to apply the new bounds size to the virtual view // which will automatically set the frame on the platform view too. var frame = new Rect(Point.Zero, boundsSize); + + // Inject per-cell safe area insets into the MauiView for CrossPlatformArrange + // to apply as internal padding. UICollectionView bypasses MAUI's arrange chain, + // so cells cannot use the standard safe area flow (#33604, #34635). + MauiView.ApplyCellSafeAreaOverride(this, virtualView, PlatformHandler.ToPlatform()); + virtualView.Arrange(frame); } } diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs index 235472aeab54..d36432e8936e 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs @@ -129,6 +129,10 @@ static UICollectionViewLayout CreateListLayout(UICollectionViewScrollDirection s // Create our section layout var section = NSCollectionLayoutSection.Create(group: group); section.InterGroupSpacing = new NFloat(itemSpacing); + // Disable section-level safe area insets — MAUI handles safe area via CellSafeAreaOverride. + // On iOS 26.1+, the default (.automatic → .safeArea) actively insets cells at section level. + if (OperatingSystem.IsIOSVersionAtLeast(26)) + section.ContentInsetsReference = UIContentInsetsReference.None; // Create header and footer for group section.BoundarySupplementaryItems = CreateSupplementaryItems( @@ -176,6 +180,8 @@ static UICollectionViewLayout CreateGridLayout(UICollectionViewScrollDirection s // Create our section layout var section = NSCollectionLayoutSection.Create(group: group); + if (OperatingSystem.IsIOSVersionAtLeast(26)) + section.ContentInsetsReference = UIContentInsetsReference.None; if (scrollDirection == UICollectionViewScrollDirection.Vertical) section.InterGroupSpacing = new NFloat(verticalItemSpacing); @@ -330,6 +336,9 @@ public static UICollectionViewLayout CreateCarouselLayout( } var section = NSCollectionLayoutSection.Create(group: group); + if (OperatingSystem.IsIOSVersionAtLeast(26)) + section.ContentInsetsReference = UIContentInsetsReference.None; + if (itemsView.ItemsLayout is LinearItemsLayout linearItemsLayout) { section.InterGroupSpacing = (nfloat)linearItemsLayout.ItemSpacing; diff --git a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs index 1e7f74e7a807..b8dad3ef4324 100644 --- a/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs +++ b/src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs @@ -197,6 +197,12 @@ public override void LayoutSubviews() // We now have to apply the new bounds size to the virtual view // which will automatically set the frame on the platform view too. var frame = new Rect(Point.Zero, boundsSize); + + // Inject per-cell safe area insets into the MauiView for CrossPlatformArrange + // to apply as internal padding. UICollectionView bypasses MAUI's arrange chain, + // so cells cannot use the standard safe area flow (#33604, #34635). + MauiView.ApplyCellSafeAreaOverride(this, virtualView, PlatformView); + virtualView.Arrange(frame); } } diff --git a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs index 1a9dfd28b7ce..21bf9724b6a2 100644 --- a/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs +++ b/src/Controls/tests/DeviceTests/Elements/CollectionView/CollectionViewTests.iOS.cs @@ -319,5 +319,70 @@ Rect GetCollectionViewCellBounds(IView cellContent) return cellContent.ToPlatform().GetParentOfType().GetBoundingBox(); } + + // Regression test for https://github.com/dotnet/maui/issues/34635 + [Fact("CollectionView cell MauiViews should be treated as UIScrollView descendants and not apply safe area independently")] + [Category(TestCategory.CollectionView)] + public async Task CollectionViewCellContentShouldBeScrollViewDescendant() + { + SetupBuilder(); + + var collectionView = new CollectionView + { + ItemsSource = Enumerable.Range(0, 5).Select(i => $"Item {i}").ToList(), + ItemTemplate = new DataTemplate(() => + { + var grid = new Grid { Padding = new Thickness(10) }; + var label = new Label(); + label.SetBinding(Label.TextProperty, "."); + grid.Add(label); + return grid; + }), + }; + + await CreateHandlerAndAddToWindow(collectionView, async handler => + { + await Task.Delay(500); + + var platformCV = collectionView.ToPlatform(); + Assert.NotNull(platformCV); + + var uiCollectionView = platformCV as UICollectionView + ?? platformCV.GetParentOfType(); + + if (uiCollectionView is null && platformCV is UIView pv) + uiCollectionView = pv.Subviews.OfType().FirstOrDefault(); + + Assert.NotNull(uiCollectionView); + + var visibleCells = uiCollectionView.VisibleCells; + Assert.NotEmpty(visibleCells); + + foreach (var cell in visibleCells) + { + foreach (var mv in FindAllSubviews(cell)) + { + mv.SetNeedsLayout(); + mv.LayoutIfNeeded(); + + Assert.False(mv.AppliesSafeAreaAdjustments, + $"CollectionView cell MauiView '{mv.View?.GetType().Name}' should not apply safe area adjustments. " + + "Cell views inside UICollectionView must be treated as scroll view descendants."); + } + } + }); + } + + static List FindAllSubviews(UIView root) where T : UIView + { + var result = new List(); + foreach (var subview in root.Subviews) + { + if (subview is T match) + result.Add(match); + result.AddRange(FindAllSubviews(subview)); + } + return result; + } } } \ No newline at end of file diff --git a/src/Core/src/Platform/iOS/MauiView.cs b/src/Core/src/Platform/iOS/MauiView.cs index 4a2b0972f82e..68439a781489 100644 --- a/src/Core/src/Platform/iOS/MauiView.cs +++ b/src/Core/src/Platform/iOS/MauiView.cs @@ -62,6 +62,78 @@ public abstract class MauiView : UIView, ICrossPlatformLayoutBacking, IVisualTre /// bool _appliesSafeAreaAdjustments; + /// + /// Safe area override injected by CollectionView cells. + /// UICollectionView bypasses MAUI's arrange chain, so cells cannot use . + /// Applied as internal padding by and + /// (#33604, #34635). + /// + internal SafeAreaPadding CellSafeAreaOverride { get; set; } = SafeAreaPadding.Empty; + + /// + /// Computes and applies per-cell safe area insets for a CollectionView cell. + /// Called from TemplatedCell/TemplatedCell2 LayoutSubviews before Arrange. + /// + internal static void ApplyCellSafeAreaOverride(UIView cell, IView virtualView, UIView platformView) + { + if (virtualView is ISafeAreaView2 safeView && platformView is MauiView mauiView) + { + var insets = ComputeCellSafeAreaInsets(cell, safeView); + mauiView.CellSafeAreaOverride = insets != UIEdgeInsets.Zero + ? insets.ToSafeAreaInsets() + : SafeAreaPadding.Empty; + } + else if (platformView is MauiView mv && !mv.CellSafeAreaOverride.IsEmpty) + { + // Clear stale override from a previous template that implemented ISafeAreaView2. + mv.CellSafeAreaOverride = SafeAreaPadding.Empty; + } + } + + /// + /// Computes per-cell safe area insets based on geometric overlap with the window's unsafe regions. + /// Returns when all edges share the same region (e.g., default + /// Container×4), as the parent layout chain handles uniform safe area (#33604, #34635). + /// + static UIEdgeInsets ComputeCellSafeAreaInsets(UIView cell, ISafeAreaView2 safeView) + { + var window = cell.Window; + if (window is null) + return UIEdgeInsets.Zero; + + var windowSA = window.SafeAreaInsets; + if (windowSA == UIEdgeInsets.Zero) + return UIEdgeInsets.Zero; + + var leftRegion = safeView.GetSafeAreaRegionsForEdge(0); + var topRegion = safeView.GetSafeAreaRegionsForEdge(1); + var rightRegion = safeView.GetSafeAreaRegionsForEdge(2); + var bottomRegion = safeView.GetSafeAreaRegionsForEdge(3); + + // Uniform edges (Container×4, None×4, All×4) are handled by the parent layout chain. + bool allSameRegion = leftRegion == topRegion + && topRegion == rightRegion + && rightRegion == bottomRegion; + + if (allSameRegion) + return UIEdgeInsets.Zero; + + // Only apply insets for Container edges; SoftInput-only edges are excluded. + var cellInWindow = cell.ConvertRectToView(cell.Bounds, window); + var windowBounds = window.Bounds; + + nfloat left = SafeAreaEdges.IsContainer(leftRegion) && windowSA.Left > 0 + ? (nfloat)Math.Max(0, (double)(windowSA.Left - cellInWindow.X)) : 0; + nfloat top = SafeAreaEdges.IsContainer(topRegion) && windowSA.Top > 0 + ? (nfloat)Math.Max(0, (double)(windowSA.Top - cellInWindow.Y)) : 0; + nfloat right = SafeAreaEdges.IsContainer(rightRegion) && windowSA.Right > 0 + ? (nfloat)Math.Max(0, (double)(cellInWindow.Right - (windowBounds.Width - windowSA.Right))) : 0; + nfloat bottom = SafeAreaEdges.IsContainer(bottomRegion) && windowSA.Bottom > 0 + ? (nfloat)Math.Max(0, (double)(cellInWindow.Bottom - (windowBounds.Height - windowSA.Bottom))) : 0; + + return new UIEdgeInsets(top, left, bottom, right); + } + // Indicates whether this view should respond to safe area insets. // Cached to avoid repeated hierarchy checks. // True if the view is an ISafeAreaView, does not ignore safe area, and is not inside a UIScrollView; @@ -72,13 +144,6 @@ public abstract class MauiView : UIView, ICrossPlatformLayoutBacking, IVisualTre // Null means not yet determined. Invalidated when view hierarchy changes. bool? _parentHandlesSafeArea; - // Cached UICollectionView parent detection to avoid repeated hierarchy checks. - bool? _collectionViewDescendant; - - // Cached Window safe area padding for CollectionView children to detect changes. - // Uses SafeAreaPadding with EqualsAtPixelLevel() to absorb sub-pixel animation noise. - SafeAreaPadding _lastWindowSafeAreaPadding; - // Keyboard tracking CGRect _keyboardFrame = CGRect.Empty; bool _isKeyboardShowing; @@ -132,18 +197,9 @@ bool RespondsToSafeArea() // To prevent this, we ignore safe area calculations on child views when they are inside a scroll view. // The scrollview itself is responsible for applying the correct insets, and child views should not apply additional safe area logic. // - // EXCEPTION: CollectionView items must handle their own safe area because UICollectionView (which inherits from UIScrollView) - // does not automatically apply safe area insets to individual cells. Without this exception, CollectionView content - // would render under the notch and home indicator. - // // For more details and implementation specifics, see MauiScrollView.cs, which contains the logic for safe area management // within scroll views and explains how this interacts with the overall layout system. - var scrollViewParent = this.GetParentOfType(); - _scrollViewDescendant = scrollViewParent is not null && scrollViewParent is not UICollectionView; - - // Cache whether this view is inside a UICollectionView for use in CrossPlatformArrange() - _collectionViewDescendant = scrollViewParent is UICollectionView; - + _scrollViewDescendant = this.GetParentOfType() is not null; return !_scrollViewDescendant.Value; } @@ -310,12 +366,7 @@ void OnKeyboardWillHide(NSNotification notification) SafeAreaPadding GetAdjustedSafeAreaInsets() { - // CollectionView cells don't receive SafeAreaInsetsDidChange notifications, so their SafeAreaInsets - // property may be stale during layout (especially after rotation). Use Window.SafeAreaInsets instead, - // which always reflects the current device orientation and safe area state. - var baseSafeArea = _collectionViewDescendant == true && Window is not null - ? Window.SafeAreaInsets.ToSafeAreaInsets() - : SafeAreaInsets.ToSafeAreaInsets(); + var baseSafeArea = SafeAreaInsets.ToSafeAreaInsets(); // Check if keyboard-aware safe area adjustments are needed if (View is ISafeAreaView2 safeAreaPage && _isKeyboardShowing) @@ -520,19 +571,23 @@ public ICrossPlatformLayout? CrossPlatformLayout /// The desired size of the view Size CrossPlatformMeasure(double widthConstraint, double heightConstraint) { - if (_appliesSafeAreaAdjustments) + var effectiveSafeArea = _appliesSafeAreaAdjustments ? _safeArea + : !CellSafeAreaOverride.IsEmpty ? CellSafeAreaOverride + : SafeAreaPadding.Empty; + + if (!effectiveSafeArea.IsEmpty) { // When responding to safe area, we need to adjust the constraints to account for the safe area. - widthConstraint -= _safeArea.HorizontalThickness; - heightConstraint -= _safeArea.VerticalThickness; + widthConstraint -= effectiveSafeArea.HorizontalThickness; + heightConstraint -= effectiveSafeArea.VerticalThickness; } var crossPlatformSize = CrossPlatformLayout?.CrossPlatformMeasure(widthConstraint, heightConstraint) ?? Size.Zero; - if (_appliesSafeAreaAdjustments) + if (!effectiveSafeArea.IsEmpty) { // If we're responding to the safe area, we need to add the safe area back to the size so the container can allocate the correct space - crossPlatformSize = new Size(crossPlatformSize.Width + _safeArea.HorizontalThickness, crossPlatformSize.Height + _safeArea.VerticalThickness); + crossPlatformSize = new Size(crossPlatformSize.Width + effectiveSafeArea.HorizontalThickness, crossPlatformSize.Height + effectiveSafeArea.VerticalThickness); } return crossPlatformSize; @@ -545,22 +600,14 @@ Size CrossPlatformMeasure(double widthConstraint, double heightConstraint) /// The bounds rectangle to arrange within void CrossPlatformArrange(CGRect bounds) { - // Force safe area revalidation for CollectionView cells when Window safe area changes. - if (View is ISafeAreaView or ISafeAreaView2 && _collectionViewDescendant == true && Window is not null) - { - var currentWindowPadding = Window.SafeAreaInsets.ToSafeAreaInsets(); - if (!currentWindowPadding.EqualsAtPixelLevel(_lastWindowSafeAreaPadding)) - { - _lastWindowSafeAreaPadding = currentWindowPadding; - _safeAreaInvalidated = true; - ValidateSafeArea(); - } - } - if (_appliesSafeAreaAdjustments) { bounds = AdjustForSafeArea(bounds); } + else if (!CellSafeAreaOverride.IsEmpty) + { + bounds = CellSafeAreaOverride.InsetRect(bounds); + } CrossPlatformLayout?.CrossPlatformArrange(bounds.ToRectangle()); } @@ -801,8 +848,6 @@ public override void MovedToWindow() _scrollViewDescendant = null; _parentHandlesSafeArea = null; - _collectionViewDescendant = null; - _lastWindowSafeAreaPadding = SafeAreaPadding.Empty; // Notify any subscribers that this view has been moved to a window _movedToWindow?.Invoke(this, EventArgs.Empty);