-
Notifications
You must be signed in to change notification settings - Fork 2k
[iOS] ScrollView: Fix landscape safe-area handling around the notch #35533
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
204f473
6234226
55dca58
7b01b23
e257cde
5ddaa66
a41126c
3234a47
6912aa8
7619953
fdc6433
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -168,6 +168,16 @@ public override void SafeAreaInsetsDidChange() | |
| base.SafeAreaInsetsDidChange(); | ||
| _parentHandlesSafeArea = null; | ||
| _safeAreaInvalidated = true; | ||
|
|
||
| // When CIAB = Never, UIKit does NOT automatically trigger a layout pass on rotation. | ||
| // We must explicitly invalidate the measure so the new safe-area insets are consumed | ||
| // by MAUI's layout engine (fix for rotation re-layout freeze). | ||
| if (ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Never | ||
| && View is IScrollView { Orientation: ScrollOrientation.Vertical }) | ||
| { | ||
| ((IPlatformMeasureInvalidationController)this).InvalidateMeasure(); | ||
| this.InvalidateAncestorsMeasures(); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
|
|
@@ -202,6 +212,7 @@ SafeAreaRegions GetSafeAreaRegionForEdge(int edge) | |
| } | ||
|
|
||
| SafeAreaEdges? _previousEdges; | ||
| ScrollOrientation? _previousScrollOrientation; | ||
|
|
||
| UIEdgeInsets GetInset() | ||
| { | ||
|
|
@@ -231,11 +242,15 @@ bool UpdateContentInsetAdjustmentBehavior() | |
| var bottomRegion = GetSafeAreaRegionForEdge(3); | ||
|
|
||
| SafeAreaEdges safeAreaEdges = new SafeAreaEdges(leftRegion, topRegion, rightRegion, bottomRegion); | ||
| var scrollOrientation = View is IScrollView scrollView ? scrollView.Orientation : (ScrollOrientation?)null; | ||
|
|
||
| if (_previousEdges is not null && _previousEdges.Equals(safeAreaEdges)) | ||
| // Also check orientation: changing from Vertical↔Horizontal changes which CIAB is needed | ||
| // even if SafeAreaEdges are unchanged (stale-cache fix). | ||
| if (_previousEdges is not null && _previousEdges.Equals(safeAreaEdges) && _previousScrollOrientation == scrollOrientation) | ||
| return false; | ||
|
|
||
| _previousEdges = safeAreaEdges; | ||
| _previousScrollOrientation = scrollOrientation; | ||
|
|
||
| // Check if all edges have the same SafeAreaRegions value | ||
| if (leftRegion == topRegion && topRegion == rightRegion && rightRegion == bottomRegion) | ||
|
|
@@ -246,6 +261,9 @@ bool UpdateContentInsetAdjustmentBehavior() | |
|
|
||
| ContentInsetAdjustmentBehavior = region switch | ||
| { | ||
| // Vertical scroll under Default: MAUI owns all edges (Never) so we can apply | ||
| // device insets directly and force re-layout on rotation via SafeAreaInsetsDidChange. | ||
| SafeAreaRegions.Default when scrollOrientation == ScrollOrientation.Vertical => UIScrollViewContentInsetAdjustmentBehavior.Never, | ||
| SafeAreaRegions.Default => UIScrollViewContentInsetAdjustmentBehavior.Automatic, // Default behavior | ||
| SafeAreaRegions.None => UIScrollViewContentInsetAdjustmentBehavior.Never, // Edge-to-edge content | ||
| SafeAreaRegions.All => UIScrollViewContentInsetAdjustmentBehavior.Never, // We calculate insets ourselves and include keyboard | ||
|
|
@@ -380,10 +398,22 @@ bool ValidateSafeArea() | |
| // it can push ContentSize over the Bounds, causing AdjustedContentInset to become non-zero and SafeAreaInsets on the child to reset to zero. | ||
| // This can result in a loop of invalidations as the layout toggles between these states. | ||
| // To prevent this, we ignore safe area calculations on child views when they are inside a scroll view. | ||
| if (SystemAdjustedContentInset == UIEdgeInsets.Zero || ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Never) | ||
| _safeArea = GetInset().ToSafeAreaInsets(); | ||
| else | ||
| _safeArea = SystemAdjustedContentInset.ToSafeAreaInsets(); | ||
| // | ||
| // Safe area source is chosen per-edge based on who owns each edge: | ||
| // | ||
| // Never / SACI=Zero : MAUI fully manages all edges → read SafeAreaInsets via GetInset(). | ||
| // | ||
| // Automatic (default for vertical scroll views): | ||
| // UIKit adds top+bottom to AdjustedContentInset but does NOT add left+right for | ||
| // non-horizontal scroll views. In landscape-left, SACI.Left=0 even though | ||
| // SafeAreaInsets.Left=44, causing content to render under the notch (#35410). | ||
| // Fix: use GetInset() for L/R (MAUI-owned), SACI for T/B (UIKit-owned). | ||
| // | ||
| // Always : UIKit manages ALL edges in AdjustedContentInset → use SACI for all edges | ||
| // to avoid double-applying horizontal safe area that UIKit already handles. | ||
| var aci = SystemAdjustedContentInset; | ||
| var isHorizontalScrollInValidate = View is IScrollView { Orientation: ScrollOrientation.Horizontal or ScrollOrientation.Both }; | ||
| _safeArea = ComputeSafeArea(aci, ContentInsetAdjustmentBehavior, GetInset(), isHorizontalScrollInValidate); | ||
|
|
||
| var oldApplyingSafeAreaAdjustments = _appliesSafeAreaAdjustments; | ||
| _appliesSafeAreaAdjustments = !IsParentHandlingSafeArea() && RespondsToSafeArea() && !_safeArea.IsEmpty; | ||
|
|
@@ -423,6 +453,61 @@ UIEdgeInsets SystemAdjustedContentInset | |
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Chooses the correct safe-area source for each edge based on who owns it. | ||
| /// </summary> | ||
| /// <param name="aci">SystemAdjustedContentInset (AdjustedContentInset minus developer ContentInset).</param> | ||
| /// <param name="ciab">The scroll view's ContentInsetAdjustmentBehavior.</param> | ||
| /// <param name="deviceInset">Raw SafeAreaInsets from GetInset() — the actual device notch/home-indicator insets.</param> | ||
| /// <param name="isHorizontalScroll"> | ||
| /// True when the scroll view scrolls horizontally (Horizontal or Both orientation). | ||
| /// UIKit's Automatic mode includes L/R in ACI for horizontal scroll views, but only T/B for vertical ones. | ||
| /// </param> | ||
| /// <returns>The safe-area padding to apply to the MAUI layout.</returns> | ||
| internal static SafeAreaPadding ComputeSafeArea( | ||
| UIEdgeInsets aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior ciab, | ||
| UIEdgeInsets deviceInset, | ||
| bool isHorizontalScroll = false) | ||
| { | ||
| if (ciab == UIScrollViewContentInsetAdjustmentBehavior.Never | ||
| || aci == UIEdgeInsets.Zero) | ||
| { | ||
| // MAUI-managed, or UIKit hasn't applied ACI yet: use SafeAreaInsets for all edges. | ||
| return deviceInset.ToSafeAreaInsets(); | ||
| } | ||
|
|
||
| if (ciab == UIScrollViewContentInsetAdjustmentBehavior.Always) | ||
| { | ||
| // UIKit manages ALL edges in ACI — use SACI to stay in sync with UIKit's model. | ||
| return aci.ToSafeAreaInsets(); | ||
| } | ||
|
|
||
| // Automatic: UIKit ownership depends on scroll orientation. | ||
| // Normalize both sources via ToSafeAreaInsets() to suppress sub-pixel UIKit floating-point noise | ||
| // (e.g. 3.5e-15), consistent with the Never/Always branches above. | ||
| var normDevice = deviceInset.ToSafeAreaInsets(); | ||
| var normAci = aci.ToSafeAreaInsets(); | ||
|
|
||
| if (isHorizontalScroll) | ||
| { | ||
| // Horizontal scroll: UIKit includes L/R in ACI; MAUI must supply T/B from device insets. | ||
| return new SafeAreaPadding( | ||
| Left: normAci.Left, | ||
| Right: normAci.Right, | ||
| Top: normDevice.Top, | ||
| Bottom: normDevice.Bottom); | ||
| } | ||
|
|
||
| // Default (vertical): UIKit includes T/B in ACI but NOT L/R (landscape notch fix #35410). | ||
| // MAUI must supply L/R from device insets; UIKit owns T/B via contentOffset. | ||
| return new SafeAreaPadding( | ||
| Left: normDevice.Left, | ||
| Right: normDevice.Right, | ||
| Top: normAci.Top, | ||
| Bottom: normAci.Bottom); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Arranges the cross-platform content within the specified bounds, accounting for safe area adjustments. | ||
| /// This method applies safe area insets to the bounds before arranging the content. | ||
|
|
@@ -438,21 +523,39 @@ Size CrossPlatformArrange(CGRect bounds) | |
| bounds = _safeArea.InsetRect(bounds); | ||
| } | ||
|
|
||
| // For horizontal scroll views, UIKit manages Left/Right in AdjustedContentInset | ||
| // under Automatic CIAB — exactly as it manages Top/Bottom for vertical scroll views. | ||
| // We must NOT additionally offset content horizontally; UIKit's ACI.Left/Right | ||
| // handles the visual positioning. Using arrangeX=bounds.X here would double-apply | ||
| // the offset (arrangeX=44 + ACI.Left=44 → 88pt leading gap instead of 44pt). | ||
| var isHorizontalScroll = View is IScrollView { Orientation: ScrollOrientation.Horizontal or ScrollOrientation.Both }; | ||
|
|
||
| Size contentSize; | ||
|
|
||
|
|
||
| double width; | ||
| double height; | ||
| if (SystemAdjustedContentInset == UIEdgeInsets.Zero || ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Never) | ||
| if (SystemAdjustedContentInset != UIEdgeInsets.Zero | ||
| && ContentInsetAdjustmentBehavior != UIScrollViewContentInsetAdjustmentBehavior.Never) | ||
| { | ||
| contentSize = CrossPlatformLayout?.CrossPlatformArrange(bounds.ToRectangle()) ?? Size.Zero; | ||
| // arrangeX = 0 when UIKit owns the horizontal edges via ACI: | ||
| // - CIAB.Always: UIKit manages ALL edges | ||
| // - CIAB.Automatic + horizontal scroll: UIKit manages Left/Right for horizontal scroll views | ||
| // arrangeX = bounds.X when MAUI owns the horizontal edges: | ||
| // - CIAB.Automatic + vertical scroll: UIKit does NOT add L/R to ACI → MAUI must | ||
| // position content past the landscape notch manually (fix for #35410) | ||
| var arrangeX = (ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Always || isHorizontalScroll) | ||
| ? 0 | ||
| : bounds.X; | ||
| contentSize = CrossPlatformLayout?.CrossPlatformArrange(new Rect(arrangeX, 0, bounds.Width, bounds.Height)) ?? Size.Zero; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major] Safe Area / Layout Arrange — In
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major] Safe Area and Window Insets - In |
||
|
|
||
| width = contentSize.Width; | ||
| height = contentSize.Height; | ||
| } | ||
| else | ||
| { | ||
| contentSize = CrossPlatformLayout?.CrossPlatformArrange(new Rect(new Point(), bounds.Size.ToSize())) ?? Size.Zero; | ||
| // Never CIAB (or zero ACI): MAUI fully controls safe area — apply full inset bounds. | ||
| contentSize = CrossPlatformLayout?.CrossPlatformArrange(bounds.ToRectangle()) ?? Size.Zero; | ||
|
|
||
| width = contentSize.Width; | ||
| height = contentSize.Height; | ||
|
|
@@ -475,15 +578,29 @@ Size CrossPlatformArrange(CGRect bounds) | |
| // This avoids inset flip-flopping and keeps layout behavior stable and predictable. | ||
| if (ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Automatic) | ||
| { | ||
| // We do this to keep the content scrollable | ||
| // if we don't do this the ContentAdjustedInset + contentSize will cause the content to go off the screen and not be scrollable | ||
| // So the bottom content will just go off the screen until the contentsize triggers the scrollable area | ||
| // UIKit flip-flop prevention: when content is just barely smaller than the frame but | ||
| // would exceed it once the horizontal safe area is included, force ContentSize large | ||
| // enough that UIKit keeps the scroll view in "scrollable" mode and doesn't push safe | ||
| // area insets down into child views (which would create a layout loop). | ||
| if (width <= Bounds.Width && | ||
| (_safeArea.HorizontalThickness + width) > Bounds.Width) | ||
| { | ||
| width += Bounds.Width + 1; | ||
| } | ||
|
|
||
| else if (_appliesSafeAreaAdjustments && !isHorizontalScroll) | ||
| { | ||
| // Content is arranged at x = _safeArea.Left (via arrangeX = bounds.X) for vertical scroll. | ||
| // CrossPlatformArrange returns the content's own width without that x-offset, | ||
| // so ContentSize.Width would fall short by _safeArea.Left, making the trailing | ||
| // portion of content unreachable by scrolling. | ||
| // We add only _safeArea.Left (not HorizontalThickness) because content starts | ||
| // at x=Left — its physical end is Left+contentWidth. Adding Right would create | ||
| // an extra Right-sized trailing empty gap (visible as "double padding" on | ||
| // symmetric devices like iPhone X where Left=Right=44). | ||
| // For horizontal scroll this branch is skipped — UIKit's ACI.Left/Right | ||
| // manages horizontal offsets (arrangeX=0), so ContentSize needs no adjustment. | ||
| width += _safeArea.Left; | ||
| } | ||
| if (height <= Bounds.Height && | ||
| (_safeArea.VerticalThickness + height) > Bounds.Height) | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,5 +65,174 @@ await scrollViewHandler.PlatformView.AttachAndRun(() => | |
| }); | ||
| }); | ||
| } | ||
|
|
||
| // ── ComputeSafeArea unit tests (Issue #35410) ────────────────────────────────── | ||
| // These tests call the static helper directly with controlled inputs, verifying | ||
| // that each CIAB mode picks the correct edge ownership without requiring a live | ||
| // UIScrollView or a notch device. | ||
|
|
||
| [Fact] | ||
| public void ComputeSafeArea_Never_UsesDeviceInsetForAllEdges() | ||
| { | ||
| // landscape-left scenario: notch is on the left | ||
| var aci = new UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0); | ||
| var deviceInset = new UIEdgeInsets(top: 20, left: 44, bottom: 0, right: 0); | ||
|
|
||
| var result = MauiScrollView.ComputeSafeArea( | ||
| aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior.Never, | ||
| deviceInset); | ||
|
|
||
| Assert.Equal(44, result.Left); | ||
| Assert.Equal(20, result.Top); | ||
| Assert.Equal(0, result.Bottom); | ||
| Assert.Equal(0, result.Right); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ComputeSafeArea_Automatic_AciZero_UsesDeviceInset() | ||
| { | ||
| var aci = UIEdgeInsets.Zero; | ||
| var deviceInset = new UIEdgeInsets(top: 20, left: 44, bottom: 0, right: 0); | ||
|
|
||
| var result = MauiScrollView.ComputeSafeArea( | ||
| aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior.Automatic, | ||
| deviceInset); | ||
|
|
||
| Assert.Equal(44, result.Left); | ||
| Assert.Equal(20, result.Top); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ComputeSafeArea_Automatic_LandscapeLeft_UsesDeviceInsetForHorizontal() | ||
| { | ||
| // In landscape-left with CIAB.Automatic: | ||
| // UIKit does NOT add left/right to ACI → SACI.Left = 0 | ||
| // but device SafeAreaInsets.Left = 44 (notch) | ||
| var aci = new UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0); // UIKit-reported | ||
| var deviceInset = new UIEdgeInsets(top: 20, left: 44, bottom: 0, right: 0); // actual notch | ||
|
|
||
| var result = MauiScrollView.ComputeSafeArea( | ||
| aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior.Automatic, | ||
| deviceInset); | ||
|
|
||
| // Left must come from deviceInset (44), not aci (0) — this is the bug fix | ||
| Assert.Equal(44, result.Left); | ||
| Assert.Equal(0, result.Right); | ||
| // Top/Bottom come from aci (UIKit-owned) | ||
| Assert.Equal(20, result.Top); | ||
| Assert.Equal(0, result.Bottom); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ComputeSafeArea_Automatic_LandscapeRight_UsesDeviceInsetForRight() | ||
| { | ||
| var aci = new UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0); | ||
| var deviceInset = new UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 44); | ||
|
|
||
| var result = MauiScrollView.ComputeSafeArea( | ||
| aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior.Automatic, | ||
| deviceInset); | ||
|
|
||
| Assert.Equal(0, result.Left); | ||
| Assert.Equal(44, result.Right); | ||
| Assert.Equal(20, result.Top); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ComputeSafeArea_Always_UsesAciForAllEdges() | ||
| { | ||
| // With CIAB.Always UIKit puts left/right into ACI, so SACI.Left == SafeAreaInsets.Left | ||
| var aci = new UIEdgeInsets(top: 20, left: 44, bottom: 0, right: 0); | ||
| var deviceInset = new UIEdgeInsets(top: 20, left: 44, bottom: 0, right: 0); | ||
|
|
||
| var result = MauiScrollView.ComputeSafeArea( | ||
| aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior.Always, | ||
| deviceInset); | ||
|
|
||
| Assert.Equal(44, result.Left); | ||
| Assert.Equal(20, result.Top); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ComputeSafeArea_Always_AciZero_UsesDeviceInset() | ||
| { | ||
| var aci = UIEdgeInsets.Zero; | ||
| var deviceInset = new UIEdgeInsets(top: 20, left: 44, bottom: 0, right: 0); | ||
|
|
||
| var result = MauiScrollView.ComputeSafeArea( | ||
| aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior.Always, | ||
| deviceInset); | ||
|
|
||
| Assert.Equal(44, result.Left); | ||
| Assert.Equal(20, result.Top); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ComputeSafeArea_Automatic_Portrait_NoNotchEdge() | ||
| { | ||
| var aci = new UIEdgeInsets(top: 44, left: 0, bottom: 34, right: 0); | ||
| var deviceInset = new UIEdgeInsets(top: 44, left: 0, bottom: 34, right: 0); | ||
|
|
||
| var result = MauiScrollView.ComputeSafeArea( | ||
| aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior.Automatic, | ||
| deviceInset); | ||
|
|
||
| Assert.Equal(0, result.Left); | ||
| Assert.Equal(0, result.Right); | ||
| Assert.Equal(44, result.Top); | ||
| Assert.Equal(34, result.Bottom); | ||
| } | ||
|
|
||
| [Fact] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [moderate] Test Coverage — The new tests validate |
||
| public void ComputeSafeArea_Automatic_HorizontalScroll_LandscapeLeft_UsesAciForHorizontal() | ||
| { | ||
| // For horizontal scroll views, UIKit DOES include L/R in ACI under Automatic mode. | ||
| // MAUI should therefore read L/R from ACI (UIKit-owned) and T/B from deviceInset (MAUI-owned). | ||
| // aci.Left = 44 because UIKit populated it; device.Left = 44 (notch) — equal, so result is the same value. | ||
| // The key is that T/B are device-sourced, not ACI-sourced (UIKit doesn't populate T/B for horizontal scroll). | ||
| var aci = new UIEdgeInsets(top: 0, left: 44, bottom: 0, right: 0); // UIKit adds L/R for horizontal | ||
| var deviceInset = new UIEdgeInsets(top: 20, left: 44, bottom: 0, right: 0); | ||
|
|
||
| var result = MauiScrollView.ComputeSafeArea( | ||
| aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior.Automatic, | ||
| deviceInset, | ||
| isHorizontalScroll: true); | ||
|
|
||
| // L/R come from ACI (UIKit-owned for horizontal scroll) | ||
| Assert.Equal(44, result.Left); | ||
| Assert.Equal(0, result.Right); | ||
| // T/B come from deviceInset (MAUI-owned; UIKit doesn't apply T/B to ACI for horizontal scroll) | ||
| Assert.Equal(20, result.Top); | ||
| Assert.Equal(0, result.Bottom); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void ComputeSafeArea_Automatic_VerticalScroll_AciTopNotDoubledWithDeviceTop() | ||
| { | ||
| // Regression guard: for vertical scroll in Automatic mode, T/B come from ACI (normAci), | ||
| // NOT from deviceInset. Previously a raw-(double) cast was used; confirm ToSafeAreaInsets() | ||
| // normalization is now applied consistently (values within tolerance become exactly 0). | ||
| const double noise = 3.5e-15; // representative UIKit floating-point noise | ||
| var aci = new UIEdgeInsets(top: (nfloat)(44 + noise), left: 0, bottom: 0, right: 0); | ||
| var deviceInset = new UIEdgeInsets(top: 44, left: 0, bottom: 0, right: 0); | ||
|
|
||
| var result = MauiScrollView.ComputeSafeArea( | ||
| aci, | ||
| UIScrollViewContentInsetAdjustmentBehavior.Automatic, | ||
| deviceInset); | ||
|
|
||
| // Top must be exactly 44, not 44 + 3.5e-15 — normalization suppresses the noise | ||
| Assert.Equal(44, result.Top); | ||
| Assert.Equal(0, result.Left); | ||
| } | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[major] Safe Area and Window Insets — This manually builds
SafeAreaPaddingfrom rawUIEdgeInsets, bypassingToSafeAreaInsets()normalization. UIKit can report negligible floating-point residue, and without the tolerance filter those values make_safeArea.IsEmptyfalse, enabling safe-area adjustments and constraint invalidation for effectively-zero insets. Normalize the composed values before returning, e.g. by applying the same tolerance path used by the other branches.