Skip to content

Commit e39fe7c

Browse files
[iOS] Fix for ScrollView SafeAreaEdges="Container" Double Safe-Area Reservation Causing Phantom Scroll Range (#37765)
> [!NOTE] > Are you waiting for the changes in this PR to be merged? > It would be very helpful if you could [test the resulting artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Issue Description On iOS, a ScrollView configured with SafeAreaEdges="Container" incorrectly reserves the safe area twice, resulting in an additional phantom scroll range. When the content is smaller than the viewport, the ScrollView becomes unnecessarily scrollable. The extra scrollable area corresponds exactly to the combined safe-area insets (for example, 82 pt on an iPhone 11: 48 pt for the status bar and 34 pt for the home indicator). When the content is taller than the viewport, the same issue causes an unnecessary dead over-scroll space at the end of the content. ### Root Cause The issue occurs because MauiScrollView.CrossPlatformArrange always pads the scroll content's measured size by the safe-area thickness when ContentInsetAdjustmentBehavior is not Automatic, without checking whether UIKit has already reserved the same space through AdjustedContentInset. As a result, when SafeAreaEdges="Container" is set and the native inset is active, the safe area is accounted for twice—once by UIKit and once by the manual padding. This inflates the content size beyond its actual bounds and produces a phantom scrollable range, even when the content should not be scrollable. ### Description of Change The fix narrows the manual-padding condition so that it applies only when the native inset is genuinely inactive—that is, when SystemAdjustedContentInset is zero or ContentInsetAdjustmentBehavior is Never. In all other cases, UIKit's own inset is trusted to reserve the safe area, and the content size is left unchanged. This removes the double reservation for SafeAreaEdges="Container" while preserving the existing None and Automaticbehaviors. Tested the behavior in the following platforms. - [x] iOS - [x] Android - [ ] Mac - [ ] Windows ### Issues Fixed Fixes #36800 ### Output |Before Fix|After Fix| |--|--| <video src="https://github.com/user-attachments/assets/19c064e4-4622-4d1d-992c-340a56fb948f"> |<video src="https://github.com/user-attachments/assets/57fa7cae-6e95-4573-885d-8f2979750f38">| --------- Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
1 parent daa4c3c commit e39fe7c

6 files changed

Lines changed: 126 additions & 2 deletions

File tree

global.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"tools": {
3-
"dotnet": "10.0.108"
3+
"dotnet": "10.0.111"
44
},
55
"sdk": {
66
"paths": [
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#if IOS
2+
using UIKit;
3+
#endif
4+
5+
namespace Maui.Controls.Sample.Issues;
6+
7+
[Issue(IssueTracker.Github, 36800, "ScrollView with SafeAreaEdges=\"Container\" reserves the safe area twice, causing phantom scroll range", PlatformAffected.iOS)]
8+
public class Issue36800 : ContentPage
9+
{
10+
ScrollView _scrollView;
11+
Label _diagLabel;
12+
13+
public Issue36800()
14+
{
15+
SafeAreaEdges = SafeAreaEdges.None;
16+
17+
_diagLabel = new Label
18+
{
19+
Text = "Phantom=Unknown",
20+
AutomationId = "DiagLabel"
21+
};
22+
23+
var dumpButton = new Button
24+
{
25+
Text = "Dump native state",
26+
AutomationId = "DumpButton"
27+
};
28+
dumpButton.Clicked += OnDumpClicked;
29+
30+
// Small content that comfortably fits within the viewport - the ScrollView
31+
// should NOT be scrollable at all when the bug is fixed.
32+
var content = new VerticalStackLayout
33+
{
34+
Padding = 16,
35+
Spacing = 12,
36+
Children =
37+
{
38+
new Label { Text = "Small content", FontSize = 22, AutomationId = "SmallContentLabel" },
39+
dumpButton,
40+
_diagLabel
41+
}
42+
};
43+
44+
_scrollView = new ScrollView
45+
{
46+
AutomationId = "TestScrollView",
47+
SafeAreaEdges = new SafeAreaEdges(SafeAreaRegions.Container),
48+
Content = content
49+
};
50+
51+
Content = _scrollView;
52+
}
53+
54+
void OnDumpClicked(object sender, EventArgs e)
55+
{
56+
#if IOS
57+
if (_scrollView.Handler?.PlatformView is UIScrollView native)
58+
{
59+
// Phantom scroll range: on a fixed (non-scrollable) content, the scrollable
60+
// range should be zero. When the safe area is reserved twice, this value
61+
// is positive and equals the safe-area thickness.
62+
double phantom = (native.ContentSize.Height + native.AdjustedContentInset.Top + native.AdjustedContentInset.Bottom) - native.Bounds.Height;
63+
_diagLabel.Text = $"Phantom={phantom}";
64+
}
65+
else
66+
{
67+
_diagLabel.Text = "Phantom=NoHandler";
68+
}
69+
#else
70+
_diagLabel.Text = "Phantom=0";
71+
#endif
72+
}
73+
}

src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28986.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ public void SafeAreaPerEdgeValidation()
177177

178178
// Open Soft Input test entry
179179
App.Tap("SoftInputTestEntry");
180+
Assert.That(App.WaitForKeyboardToShow(), Is.True, "Keyboard should be visible before validating the resized safe area");
180181

181182
// With AdjustNothing mode, the window doesn't resize or pan
182183
// The MainGrid gets bottom padding from SoftInput, so ContentGrid should shrink
@@ -187,6 +188,7 @@ public void SafeAreaPerEdgeValidation()
187188
});
188189

189190
App.DismissKeyboard();
191+
Assert.That(App.WaitForKeyboardToHide(), Is.True, "Keyboard should be hidden before validating the restored safe area");
190192

191193
App.RetryAssert(() =>
192194
{

src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue28986_ContentPage.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ public void SafeAreaNoWhiteSpaceAfterKeyboardDismissAndEdgeToggle()
141141
var baselinePosition = App.WaitForElement("ContentGrid").GetRect();
142142

143143
App.Tap("SoftInputTestEntry");
144+
Assert.That(App.WaitForKeyboardToShow(), Is.True, "Keyboard should be visible before validating the resized safe area");
144145
App.RetryAssert(() =>
145146
{
146147
var withKeyboard = App.WaitForElement("ContentGrid").GetRect();
@@ -150,6 +151,7 @@ public void SafeAreaNoWhiteSpaceAfterKeyboardDismissAndEdgeToggle()
150151

151152
App.Tap("GridSetContainerButton");
152153
App.DismissKeyboard();
154+
Assert.That(App.WaitForKeyboardToHide(), Is.True, "Keyboard should be hidden before restoring all safe-area edges");
153155
App.Tap("GridResetAllButton");
154156

155157
App.RetryAssert(() =>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#if IOS // Fix is specific to iOS's ContentInsetAdjustmentBehavior/AdjustedContentInset handling; not applicable on Android/Catalyst/Windows
2+
using System.Globalization;
3+
using NUnit.Framework;
4+
using UITest.Appium;
5+
using UITest.Core;
6+
7+
namespace Microsoft.Maui.TestCases.Tests.Issues;
8+
9+
public class Issue36800 : _IssuesUITest
10+
{
11+
public override string Issue => "ScrollView with SafeAreaEdges=\"Container\" reserves the safe area twice, causing phantom scroll range";
12+
13+
public Issue36800(TestDevice device) : base(device)
14+
{
15+
}
16+
17+
[Test]
18+
[Category(UITestCategories.SafeAreaEdges)]
19+
public void ScrollViewWithContainerSafeAreaDoesNotDoubleReserveSafeArea()
20+
{
21+
App.WaitForElement("TestScrollView");
22+
App.Tap("DumpButton");
23+
24+
var diagText = App.WaitForElement("DiagLabel").GetText() ?? string.Empty;
25+
26+
Assert.That(diagText, Does.StartWith("Phantom="), $"Unexpected diagnostic text: {diagText}");
27+
28+
var phantomValueText = diagText.Substring("Phantom=".Length);
29+
var phantom = double.Parse(phantomValueText, CultureInfo.InvariantCulture);
30+
31+
Assert.That(phantom, Is.LessThanOrEqualTo(1.0),
32+
$"ScrollView with SafeAreaEdges=Container should not have a phantom scroll range, but measured {phantom}pt");
33+
}
34+
}
35+
#endif

src/Core/src/Platform/iOS/MauiScrollView.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,11 +647,23 @@ Size CrossPlatformArrange(CGRect bounds)
647647
height = Bounds.Height + 1;
648648
}
649649
}
650-
else if (ContentInsetAdjustmentBehavior != UIScrollViewContentInsetAdjustmentBehavior.Automatic)
650+
else if (SystemAdjustedContentInset == UIEdgeInsets.Zero || ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Never)
651651
{
652+
// UIKit is not currently reserving any space for the safe area on
653+
// this scroll view - either because the adjustment behavior is set
654+
// to Never, or because it is set to Always but UIKit has not yet
655+
// assigned a system inset for the current layout pass. In either
656+
// case, the content size must be padded manually to keep the
657+
// content clear of the safe area. Once UIKit assigns a non-zero
658+
// inset, the safe area is reserved natively instead, as described
659+
// below.
652660
width += _safeArea.HorizontalThickness;
653661
height += _safeArea.VerticalThickness;
654662
}
663+
// UIKit is already reserving the safe area natively via a non-zero
664+
// AdjustedContentInset, so the content size is intentionally left
665+
// unmodified here. Padding it again would reserve the same safe area
666+
// twice, producing a scrollable range larger than the actual content.
655667

656668
contentSize = new Size(width, height);
657669

0 commit comments

Comments
 (0)