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
144 changes: 144 additions & 0 deletions src/Controls/tests/TestCases.HostApp/Issues/Issue33604.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
namespace Maui.Controls.Sample.Issues;

[Issue(IssueTracker.Github, 33604, "CollectionView does not respect content SafeAreaEdges choices", PlatformAffected.iOS | PlatformAffected.Android)]
public class Issue33604 : Shell
{
public Issue33604()
{
Shell.SetNavBarIsVisible(this, false);
Items.Add(new ShellContent
{
Route = "MainPage",
ContentTemplate = new DataTemplate(typeof(Issue33604Page)),
});
}
}

public class Issue33604Page : ContentPage
{
public Issue33604Page()
{
BackgroundColor = Colors.Blue;

var labelStyle = new Style(typeof(Label));
labelStyle.Setters.Add(new Setter { Property = Label.HorizontalOptionsProperty, Value = LayoutOptions.Fill });
labelStyle.Setters.Add(new Setter { Property = Label.BackgroundColorProperty, Value = Colors.Aquamarine });
labelStyle.Setters.Add(new Setter { Property = Label.PaddingProperty, Value = new Thickness(4, 0) });
labelStyle.Setters.Add(new Setter { Property = Label.FontSizeProperty, Value = 32.0 });
Resources = new ResourceDictionary { labelStyle };

var containerNone = new SafeAreaEdges(SafeAreaRegions.Container, SafeAreaRegions.None);

var topLabel = new Label
{
AutomationId = "TopLabel",
Text = "Hello, World! Left Side Test",
HorizontalTextAlignment = TextAlignment.Start,
};
var topView = new ContentView { BackgroundColor = Colors.Plum, Content = topLabel };
topView.SafeAreaEdges = new SafeAreaEdges(SafeAreaRegions.Container);

var collectionView = new CollectionView
{
AutomationId = "TestCollectionView",
BackgroundColor = Colors.Red,
IsGrouped = true,
GroupHeaderTemplate = new DataTemplate(() =>
{
var grid = new Grid
{
RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) },
BackgroundColor = Color.FromArgb("#2F4F4F"),
Margin = new Thickness(0, 2),
RowSpacing = 4,
SafeAreaEdges = containerNone,
};

var leftLabel = new Label { FontSize = 22.0, HorizontalTextAlignment = TextAlignment.Start };
leftLabel.SetBinding(Label.TextProperty, "LeftTest");

var rightLabel = new Label { FontSize = 22.0, HorizontalTextAlignment = TextAlignment.End };
rightLabel.SetBinding(Label.TextProperty, "RightTest");

grid.Add(leftLabel, 0, 0);
grid.Add(rightLabel, 0, 1);
return grid;
}),
ItemTemplate = new DataTemplate(() =>
{
var grid = new Grid
{
RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) },
BackgroundColor = Colors.LightGray,
Margin = new Thickness(0, 2),
RowSpacing = 4,
SafeAreaEdges = containerNone,
};

var leftLabel = new Label { FontSize = 12.0, HorizontalTextAlignment = TextAlignment.Start };
leftLabel.SetBinding(Label.TextProperty, "LeftTest");

var rightLabel = new Label { FontSize = 12.0, HorizontalTextAlignment = TextAlignment.End };
rightLabel.SetBinding(Label.TextProperty, "RightTest");

grid.Add(leftLabel, 0, 0);
grid.Add(rightLabel, 0, 1);
return grid;
}),
};

var bottomLabel = new Label
{
AutomationId = "BottomLabel",
Text = "Testing Right Side Here",
HorizontalTextAlignment = TextAlignment.End,
};
var bottomView = new ContentView { BackgroundColor = Colors.Plum, Content = bottomLabel };
bottomView.SafeAreaEdges = new SafeAreaEdges(SafeAreaRegions.Container);

var rootGrid = new Grid
{
RowDefinitions = { new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Star), new RowDefinition(GridLength.Auto) },
BackgroundColor = Colors.Yellow,
SafeAreaEdges = SafeAreaEdges.None,
};

rootGrid.Add(topView, 0, 0);
rootGrid.Add(collectionView, 0, 1);
rootGrid.Add(bottomView, 0, 2);

Content = rootGrid;

var groups = new List<Issue33604ModelGroup>();
for (var i = 1; i <= 10; i++)
{
var group = new Issue33604ModelGroup
{
LeftTest = $"Title Left {i}",
RightTest = $"Title Right {i}",
};
for (var j = 1; j <= i; j++)
{
group.Add(new Issue33604Model
{
LeftTest = $"Content Test for Left Side {i}",
RightTest = $"Content Test for Right Side {i}",
});
}
groups.Add(group);
}
collectionView.ItemsSource = groups;
}
}

public class Issue33604ModelGroup : List<Issue33604Model>
{
public string LeftTest { get; set; }
public string RightTest { get; set; }
}

public class Issue33604Model
{
public string LeftTest { get; set; }
public string RightTest { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// iOS only: Android CI devices lack notch/cutout, making SafeArea screenshot verification ineffective.
#if IOS
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;

namespace Microsoft.Maui.TestCases.Tests.Issues
{
public class Issue33604 : _IssuesUITest
{
public override string Issue => "CollectionView does not respect content SafeAreaEdges choices";

public Issue33604(TestDevice device) : base(device) { }

[Test]
[Category(UITestCategories.CollectionView)]
public void CollectionViewItemsShouldRespectSafeAreaEdges()
{
App.WaitForElement("TestCollectionView");
App.SetOrientationLandscape();

// Allow layout to settle after orientation change
App.WaitForElement("TopLabel");

VerifyScreenshot(retryTimeout: TimeSpan.FromSeconds(2));
}

[TearDown]
public void TearDown()
{
App.SetOrientationPortrait();
}
}
}
#endif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion src/Core/src/Platform/Android/MauiWindowInsetListener.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,10 @@ internal void RegisterView(AView view)
// Skip setting listener on views inside nested scroll containers or AppBarLayout (except MaterialToolbar)
// We want the layout listener logic to get applied to the MaterialToolbar itself
// But we don't want any layout listeners to get applied to the children of MaterialToolbar (like the TitleView)
// CollectionView/CarouselView items are not excluded to enable per-item SafeAreaEdges control.
// Performance overhead is negligible due to early pass-through for items without insets.
if (view is not MaterialToolbar &&
(parent is AppBarLayout || parent is MauiScrollView || parent is IMauiRecyclerView))
(parent is AppBarLayout || parent is MauiScrollView))
{
return null;
}
Expand Down
39 changes: 37 additions & 2 deletions src/Core/src/Platform/iOS/MauiView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ 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 @@ -125,9 +132,18 @@ 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.
_scrollViewDescendant = this.GetParentOfType<UIScrollView>() is not null;
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;

return !_scrollViewDescendant.Value;
}

Expand Down Expand Up @@ -294,7 +310,12 @@ void OnKeyboardWillHide(NSNotification notification)

SafeAreaPadding GetAdjustedSafeAreaInsets()
{
var baseSafeArea = SafeAreaInsets.ToSafeAreaInsets();
// 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();

// Check if keyboard-aware safe area adjustments are needed
if (View is ISafeAreaView2 safeAreaPage && _isKeyboardShowing)
Expand Down Expand Up @@ -524,6 +545,18 @@ 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);
Expand Down Expand Up @@ -768,6 +801,8 @@ 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