diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs index 5362b2bf6c8d..433925ffae7b 100644 --- a/src/Core/src/Platform/iOS/MauiScrollView.cs +++ b/src/Core/src/Platform/iOS/MauiScrollView.cs @@ -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(); + } } /// @@ -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 } } + /// + /// Chooses the correct safe-area source for each edge based on who owns it. + /// + /// SystemAdjustedContentInset (AdjustedContentInset minus developer ContentInset). + /// The scroll view's ContentInsetAdjustmentBehavior. + /// Raw SafeAreaInsets from GetInset() — the actual device notch/home-indicator insets. + /// + /// 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. + /// + /// The safe-area padding to apply to the MAUI layout. + 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); + } + /// /// 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; 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) { diff --git a/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.iOS.cs index a472a01082cd..6d476f169533 100644 --- a/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/ScrollView/ScrollViewHandlerTests.iOS.cs @@ -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] + 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); + } } } +