diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue33604.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue33604.cs new file mode 100644 index 000000000000..c468f1f1d3af --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue33604.cs @@ -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(); + 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 +{ + public string LeftTest { get; set; } + public string RightTest { get; set; } +} + +public class Issue33604Model +{ + public string LeftTest { get; set; } + public string RightTest { get; set; } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33604.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33604.cs new file mode 100644 index 000000000000..537e535562be --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue33604.cs @@ -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 diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/CollectionViewItemsShouldRespectSafeAreaEdges.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/CollectionViewItemsShouldRespectSafeAreaEdges.png new file mode 100644 index 000000000000..bb3cac59fef5 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/CollectionViewItemsShouldRespectSafeAreaEdges.png differ diff --git a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs index dd26fea690a1..b9b3a1328ab6 100644 --- a/src/Core/src/Platform/Android/MauiWindowInsetListener.cs +++ b/src/Core/src/Platform/Android/MauiWindowInsetListener.cs @@ -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; } diff --git a/src/Core/src/Platform/iOS/MauiView.cs b/src/Core/src/Platform/iOS/MauiView.cs index 8319ff4246d6..4a2b0972f82e 100644 --- a/src/Core/src/Platform/iOS/MauiView.cs +++ b/src/Core/src/Platform/iOS/MauiView.cs @@ -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; @@ -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() is not null; + 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; + return !_scrollViewDescendant.Value; } @@ -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) @@ -524,6 +545,18 @@ 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); @@ -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);