Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Controls/src/Core/Handlers/Items/iOS/TemplatedCell.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/Controls/src/Core/Handlers/Items2/iOS/TemplatedCell2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,5 +319,70 @@ Rect GetCollectionViewCellBounds(IView cellContent)

return cellContent.ToPlatform().GetParentOfType<UIKit.UICollectionViewCell>().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<CollectionViewHandler>(collectionView, async handler =>
{
await Task.Delay(500);

var platformCV = collectionView.ToPlatform();
Assert.NotNull(platformCV);

var uiCollectionView = platformCV as UICollectionView
?? platformCV.GetParentOfType<UICollectionView>();

if (uiCollectionView is null && platformCV is UIView pv)
uiCollectionView = pv.Subviews.OfType<UICollectionView>().FirstOrDefault();

Assert.NotNull(uiCollectionView);

var visibleCells = uiCollectionView.VisibleCells;
Assert.NotEmpty(visibleCells);

foreach (var cell in visibleCells)
{
foreach (var mv in FindAllSubviews<MauiView>(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<T> FindAllSubviews<T>(UIView root) where T : UIView
{
var result = new List<T>();
foreach (var subview in root.Subviews)
{
if (subview is T match)
result.Add(match);
result.AddRange(FindAllSubviews<T>(subview));
}
return result;
}
}
}
129 changes: 87 additions & 42 deletions src/Core/src/Platform/iOS/MauiView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,78 @@ public abstract class MauiView : UIView, ICrossPlatformLayoutBacking, IVisualTre
/// </summary>
bool _appliesSafeAreaAdjustments;

/// <summary>
/// Safe area override injected by CollectionView cells.
/// UICollectionView bypasses MAUI's arrange chain, so cells cannot use <see cref="_safeArea"/>.
/// Applied as internal padding by <see cref="CrossPlatformArrange"/> and
/// <see cref="CrossPlatformMeasure"/> (#33604, #34635).
/// </summary>
internal SafeAreaPadding CellSafeAreaOverride { get; set; } = SafeAreaPadding.Empty;

/// <summary>
/// Computes and applies per-cell safe area insets for a CollectionView cell.
/// Called from TemplatedCell/TemplatedCell2 LayoutSubviews before Arrange.
/// </summary>
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;
}
}

/// <summary>
/// Computes per-cell safe area insets based on geometric overlap with the window's unsafe regions.
/// Returns <see cref="UIEdgeInsets.Zero"/> when all edges share the same region (e.g., default
/// Container×4), as the parent layout chain handles uniform safe area (#33604, #34635).
/// </summary>
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<UIScrollView>();
_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<UIScrollView>() is not null;
return !_scrollViewDescendant.Value;
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -520,19 +571,23 @@ public ICrossPlatformLayout? CrossPlatformLayout
/// <returns>The desired size of the view</returns>
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;
Expand All @@ -545,22 +600,14 @@ Size CrossPlatformMeasure(double widthConstraint, double heightConstraint)
/// <param name="bounds">The bounds rectangle to arrange within</param>
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());
}
Expand Down Expand Up @@ -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);
Expand Down
Loading