Skip to content
Merged
141 changes: 129 additions & 12 deletions src/Core/src/Platform/iOS/MauiScrollView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -202,6 +212,7 @@ SafeAreaRegions GetSafeAreaRegionForEdge(int edge)
}

SafeAreaEdges? _previousEdges;
ScrollOrientation? _previousScrollOrientation;

UIEdgeInsets GetInset()
{
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Collaborator

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 SafeAreaPadding from raw UIEdgeInsets, bypassing ToSafeAreaInsets() normalization. UIKit can report negligible floating-point residue, and without the tolerance filter those values make _safeArea.IsEmpty false, 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.

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.
Expand All @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Safe Area / Layout Arrange — In Automatic mode for ScrollOrientation.Horizontal or Both, ComputeSafeArea treats top/bottom as MAUI-owned (deviceInset), but this arrange call discards bounds.Y and always arranges at Y = 0. After _safeArea.InsetRect(bounds), bounds.Y contains the top safe-area offset that MAUI needs to apply when UIKit only owns the horizontal ACI. This leaves horizontal scroll content able to render under the top safe area/title bar. Preserve bounds.Y for edges MAUI owns (similar to the new arrangeX ownership split).

Copy link
Copy Markdown
Collaborator

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 - In Automatic horizontal scroll mode, ComputeSafeArea now treats Top/Bottom as MAUI-owned (normDevice.Top/Bottom), but this arrange call still passes Y = 0. That drops the top safe-area inset for horizontal scroll views on notched devices, so content can still render under the status/notch area. The vertical fix adds the owned horizontal offset via arrangeX; horizontal Automatic needs the same symmetry for bounds.Y/content height when MAUI owns Top/Bottom.


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;
Expand All @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[moderate] Test Coverage — The new tests validate ComputeSafeArea edge selection, but not the CrossPlatformArrange behavior changed in this PR. The horizontal-scroll test asserts top/bottom are MAUI-owned, yet no test verifies the resulting top offset is actually passed to CrossPlatformArrange; such a test would catch the arrange bug above.

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);
}
}
}

Loading