From e856c64ba8299ad16b4278ca0a9abdbd1be46cb6 Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Mon, 3 Aug 2026 20:25:21 +0200 Subject: [PATCH 01/10] [iOS] Clamp ScrollTo requests against AdjustedContentInset MapRequestScrollTo clamped programmatic scrolls to [0, ContentSize - Frame], ignoring the UIScrollView's AdjustedContentInset. On inset scroll views (the standard .NET 10 edge-to-edge configuration) scroll-to-end stopped short by the adjusted-inset sum and scroll-to-zero could not restore the natural rest position (-adjustedInset.Top). Translate cross-platform content coordinates into native offset space, clamp against the inset-aware range, and apply the reverse translation when reporting ScrollX/ScrollY so requests round-trip. With zero insets the math degenerates to the previous behavior. Fixes #36801 Co-Authored-By: Claude Fable 5 --- .../TestCases.HostApp/Issues/Issue36801.cs | 117 ++++++++++++++++++ .../Tests/Issues/Issue36801.cs | 35 ++++++ .../ScrollView/ScrollViewHandler.iOS.cs | 33 +++-- 3 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs new file mode 100644 index 000000000000..ddb06c4f5a8a --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs @@ -0,0 +1,117 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 36801, "iOS ScrollToAsync clamps without AdjustedContentInset", PlatformAffected.iOS)] +public class Issue36801 : ContentPage +{ + readonly ScrollView _scrollView; + readonly Label _endResultLabel; + readonly Label _topResultLabel; + + public Issue36801() + { + NavigationPage.SetHasNavigationBar(this, false); + SafeAreaEdges = SafeAreaEdges.None; + + _endResultLabel = new Label { Text = "EndPending", AutomationId = "EndResultLabel" }; + _topResultLabel = new Label { Text = "TopPending", AutomationId = "TopResultLabel" }; + + var scrollToEndButton = new Button { Text = "Scroll to end", AutomationId = "ScrollToEndButton" }; + scrollToEndButton.Clicked += async (sender, e) => + { + _endResultLabel.Text = "EndPending"; + await _scrollView.ScrollToAsync(0, _scrollView.ContentSize.Height, animated: false); + await Task.Delay(100); + _endResultLabel.Text = EvaluateOffset(expectEnd: true); + }; + + var scrollToTopButton = new Button { Text = "Scroll to top", AutomationId = "ScrollToTopButton" }; + scrollToTopButton.Clicked += async (sender, e) => + { + _topResultLabel.Text = "TopPending"; + await _scrollView.ScrollToAsync(0, 0, animated: false); + await Task.Delay(100); + _topResultLabel.Text = EvaluateOffset(expectEnd: false); + }; + + var content = new VerticalStackLayout { Padding = 16, Spacing = 6 }; + + // Spacer so the scrollable content starts below the floating header + content.Add(new BoxView { HeightRequest = 220, Color = Colors.Transparent }); + for (int i = 0; i < 60; i++) + { + content.Add(new Label { Text = $"Filler {i}", HeightRequest = 30 }); + } + + content.Add(new Label + { + Text = "BOTTOM PROBE", + AutomationId = "ProbeLabel", + FontAttributes = FontAttributes.Bold + }); + + _scrollView = new ScrollView { Content = content }; + + // Floating header keeps the buttons and result labels visible and tappable + // regardless of the scroll position. + var header = new VerticalStackLayout + { + Padding = new Thickness(16, 60, 16, 8), + Spacing = 6, + BackgroundColor = Colors.LightGray, + VerticalOptions = LayoutOptions.Start, + Children = { scrollToEndButton, scrollToTopButton, _endResultLabel, _topResultLabel } + }; + + Content = new Grid { Children = { _scrollView, header } }; + +#if IOS + // Give the native scroll view explicit content insets so it has a non-zero + // AdjustedContentInset regardless of how the host positions the page relative + // to the system chrome. This mirrors the issue's "custom chrome" scenario + // (AdditionalSafeAreaInsets) and deterministically exposes the clamping math: + // the valid native offset range becomes [-60, ContentSize + 40 - Bounds.Height]. + _scrollView.Loaded += (sender, e) => + { + if (_scrollView.Handler?.PlatformView is UIKit.UIScrollView nativeScrollView) + { + nativeScrollView.ContentInset = new UIKit.UIEdgeInsets(60, 0, 40, 0); + } + }; +#endif + } + + string EvaluateOffset(bool expectEnd) + { +#if IOS + if (_scrollView.Handler?.PlatformView is not UIKit.UIScrollView nativeScrollView) + { + return "Fail: native scroll view unavailable"; + } + + var adjustedInset = nativeScrollView.AdjustedContentInset; + if (adjustedInset.Top + adjustedInset.Bottom <= 0) + { + // Without insets the buggy and correct math coincide and the test proves nothing + var frameInWindow = nativeScrollView.ConvertRectToView(nativeScrollView.Bounds, null); + var screen = UIKit.UIScreen.MainScreen.Bounds; + var safeArea = nativeScrollView.SafeAreaInsets; + return $"Fail: scenario invalid, no adjusted content insets " + + $"(behavior={nativeScrollView.ContentInsetAdjustmentBehavior}, safeArea=({safeArea.Top:F0},{safeArea.Bottom:F0}), " + + $"frame={frameInWindow.Y:F0}x{frameInWindow.Height:F0}, screen={screen.Height:F0}, " + + $"contentInset=({nativeScrollView.ContentInset.Top:F0},{nativeScrollView.ContentInset.Bottom:F0}))"; + } + + double expected = expectEnd + ? nativeScrollView.ContentSize.Height + adjustedInset.Bottom - nativeScrollView.Bounds.Height + : -adjustedInset.Top; + double actual = nativeScrollView.ContentOffset.Y; + var kind = expectEnd ? "end" : "top"; + + return Math.Abs(actual - expected) <= 1.5 + ? $"Success ({kind}): offset={actual:F1}" + : $"Fail ({kind}): actual={actual:F1} expected={expected:F1}"; +#else + return "Success: not applicable on this platform"; +#endif + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs new file mode 100644 index 000000000000..0e691249fbc8 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs @@ -0,0 +1,35 @@ +#if IOS // Validates iOS-specific UIScrollView AdjustedContentInset math with iOS-only instrumentation in the HostApp page +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue36801 : _IssuesUITest +{ + public Issue36801(TestDevice device) : base(device) { } + + public override string Issue => "iOS ScrollToAsync clamps without AdjustedContentInset"; + + [Test] + [Category(UITestCategories.ScrollView)] + public void ScrollToAsyncReachesInsetAwareExtremes() + { + App.WaitForElement("ScrollToEndButton"); + App.Tap("ScrollToEndButton"); + + // The page compares the native content offset against the inset-aware maximum + // (ContentSize + AdjustedContentInset.Bottom - Bounds.Height) + var endSuccess = App.WaitForTextToBePresentInElement("EndResultLabel", "Success", timeout: TimeSpan.FromSeconds(5)); + var endText = App.FindElement("EndResultLabel").GetText(); + Assert.That(endSuccess, Is.True, $"Scroll to end did not reach the inset-aware maximum: {endText}"); + + App.Tap("ScrollToTopButton"); + + // Scrolling back to 0 must land on the natural rest position (-AdjustedContentInset.Top) + var topSuccess = App.WaitForTextToBePresentInElement("TopResultLabel", "Success", timeout: TimeSpan.FromSeconds(5)); + var topText = App.FindElement("TopResultLabel").GetText(); + Assert.That(topSuccess, Is.True, $"Scroll to top did not land on the rest position: {topText}"); + } +} +#endif diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs index 05daf0c17cf7..49e7437daa09 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs @@ -121,16 +121,27 @@ public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView sc return; } - var availableScrollHeight = Math.Max(uiScrollView.ContentSize.Height - uiScrollView.Frame.Height, 0); - var availableScrollWidth = Math.Max(uiScrollView.ContentSize.Width - uiScrollView.Frame.Width, 0); - var minScrollHorizontal = Math.Clamp(request.HorizontalOffset, 0, availableScrollWidth); - var minScrollVertical = Math.Clamp(request.VerticalOffset, 0, availableScrollHeight); - - bool alreadyAtTarget = uiScrollView.ContentOffset.Y == minScrollVertical && uiScrollView.ContentOffset.X == minScrollHorizontal; + // Cross-platform scroll coordinates treat (0,0) as the top of the content, but when the + // scroll view has content insets (e.g. it consumes the safe area) the native rest offset + // is (-adjustedInset.Left, -adjustedInset.Top) and the native maximum extends past + // ContentSize - Bounds by the trailing insets. Translate the request into native offset + // space and clamp against the inset-aware range (issue #36801). + var adjustedInset = uiScrollView.AdjustedContentInset; + var bounds = uiScrollView.Bounds; + + var minScrollHorizontal = -(double)adjustedInset.Left; + var minScrollVertical = -(double)adjustedInset.Top; + var maxScrollHorizontal = Math.Max(minScrollHorizontal, uiScrollView.ContentSize.Width + adjustedInset.Right - bounds.Width); + var maxScrollVertical = Math.Max(minScrollVertical, uiScrollView.ContentSize.Height + adjustedInset.Bottom - bounds.Height); + + var targetHorizontal = Math.Clamp(request.HorizontalOffset - (double)adjustedInset.Left, minScrollHorizontal, maxScrollHorizontal); + var targetVertical = Math.Clamp(request.VerticalOffset - (double)adjustedInset.Top, minScrollVertical, maxScrollVertical); + + bool alreadyAtTarget = uiScrollView.ContentOffset.Y == targetVertical && uiScrollView.ContentOffset.X == targetHorizontal; if (!alreadyAtTarget) { - uiScrollView.SetContentOffset(new CGPoint(minScrollHorizontal, minScrollVertical), !request.Instant); + uiScrollView.SetContentOffset(new CGPoint(targetHorizontal, targetVertical), !request.Instant); } if (request.Instant || alreadyAtTarget) @@ -257,8 +268,12 @@ void Scrolled(object? sender, EventArgs e) return; } - VirtualView.HorizontalOffset = platformView.ContentOffset.X; - VirtualView.VerticalOffset = platformView.ContentOffset.Y; + // Report offsets in cross-platform content coordinates: with content insets the native + // rest offset is (-adjustedInset.Left, -adjustedInset.Top), which maps to (0,0) + // cross-platform so ScrollToAsync(ScrollX, ScrollY, ...) round-trips (issue #36801). + var adjustedInset = platformView.AdjustedContentInset; + VirtualView.HorizontalOffset = platformView.ContentOffset.X + adjustedInset.Left; + VirtualView.VerticalOffset = platformView.ContentOffset.Y + adjustedInset.Top; } } } From 347ee65a62e98ce55b486b1df072ebd4bf703dda Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Wed, 5 Aug 2026 21:58:27 +0200 Subject: [PATCH 02/10] Address AI review: fix element-scroll viewport and minimize the fix surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #37060, keeping the change minimal relative to main: - ScrollView.GetScrollPositionForElement now computes End/Center/MakeVisible targets against the effective viewport (frame minus AdjustedContentInset on iOS/Catalyst) instead of the full frame. With the new coordinate mapping the old math landed the element adjustedInset.Top+Bottom past the visible region for downward scrolls (review error finding). - With ContentInsetAdjustmentBehavior.Always, MauiScrollView bakes the safe area into ContentSize while UIKit also applies it via AdjustedContentInset; MapRequestScrollTo now excludes the duplicated (system-contributed) amount from the reachable range, computed from public UIScrollView properties. - OnHandlerChangedCore replayed a pending scroll request through OnScrollToRequested, which resets the completion source and orphans the task the original caller is awaiting — a pre-layout ScrollToAsync never completed. The replay now invokes the handler directly. - Issue36801 HostApp page reworked per review: geometric oracles (content and probe platform-view frames, plus the ScrollX/ScrollY cross-platform contract) instead of mirroring the implementation formula, a converge loop instead of a fixed Task.Delay, MacCatalyst included in all guards, and the non-applicable fallback no longer matches "Success". New coverage: deferred pre-layout scroll, ScrollToAsync(element, End), and Appium-side probe visibility assertions. All Issue36801 UI tests verified passing on the iOS simulator. Co-Authored-By: Claude Fable 5 --- .../src/Core/ScrollView/ScrollView.cs | 40 ++++- .../TestCases.HostApp/Issues/Issue36801.cs | 152 ++++++++++++++---- .../Tests/Issues/Issue36801.cs | 44 ++++- .../ScrollView/ScrollViewHandler.iOS.cs | 22 ++- 4 files changed, 214 insertions(+), 44 deletions(-) diff --git a/src/Controls/src/Core/ScrollView/ScrollView.cs b/src/Controls/src/Core/ScrollView/ScrollView.cs index ac7d843dfe76..b7815f29f9cb 100644 --- a/src/Controls/src/Core/ScrollView/ScrollView.cs +++ b/src/Controls/src/Core/ScrollView/ScrollView.cs @@ -47,8 +47,13 @@ private protected override void OnHandlerChangedCore() if (Handler is not null && _pendingScrollToRequested is not null) { - OnScrollToRequested(_pendingScrollToRequested); + var pending = _pendingScrollToRequested; _pendingScrollToRequested = null; + + // Replay without going through OnScrollToRequested: that would reset the + // completion source and orphan the task the original caller is still awaiting + ScrollToRequested?.Invoke(this, pending); + Handler.Invoke(nameof(IScrollView.RequestScrollTo), ConvertRequestMode(pending).ToRequest()); } } @@ -62,9 +67,17 @@ public Point GetScrollPositionForElement(VisualElement item, ScrollToPosition po double y = GetCoordinate(item, "Y", 0); double x = GetCoordinate(item, "X", 0); + // The scrollable viewport can be smaller than this ScrollView's frame: on iOS, content + // insets (safe area, ContentInset) obscure part of the frame and (0,0) in scroll + // coordinates is the inset rest position. Compute element targets against the effective + // viewport so End/Center/MakeVisible land the element fully inside the visible region. + var viewportInsets = GetVisibleViewportInsets(); + double viewportWidth = Math.Max(0, Width - viewportInsets.HorizontalThickness); + double viewportHeight = Math.Max(0, Height - viewportInsets.VerticalThickness); + if (position == ScrollToPosition.MakeVisible) { - var scrollBounds = new Rect(ScrollX, ScrollY, Width, Height); + var scrollBounds = new Rect(ScrollX, ScrollY, viewportWidth, viewportHeight); var itemBounds = new Rect(x, y, item.Width, item.Height); if (scrollBounds.Contains(itemBounds)) return new Point(ScrollX, ScrollY); @@ -84,17 +97,32 @@ public Point GetScrollPositionForElement(VisualElement item, ScrollToPosition po switch (position) { case ScrollToPosition.Center: - y = y - Height / 2 + item.Height / 2; - x = x - Width / 2 + item.Width / 2; + y = y - viewportHeight / 2 + item.Height / 2; + x = x - viewportWidth / 2 + item.Width / 2; break; case ScrollToPosition.End: - y = y - Height + item.Height; - x = x - Width + item.Width; + y = y - viewportHeight + item.Height; + x = x - viewportWidth + item.Width; break; } return new Point(x, y); } + // On iOS the scrollable viewport is smaller than the frame when the native scroll view + // carries content insets (safe area, ContentInset): the insets obscure part of the frame + // and (0,0) in cross-platform scroll coordinates is the inset rest position. + Thickness GetVisibleViewportInsets() + { +#if IOS || MACCATALYST + if (Handler?.PlatformView is UIKit.UIScrollView platformView) + { + var inset = platformView.AdjustedContentInset; + return new Thickness(inset.Left, inset.Top, inset.Right, inset.Bottom); + } +#endif + return default; + } + /// /// Sends the scroll finished notification. /// diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs index ddb06c4f5a8a..2e3a29e38a50 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs @@ -4,8 +4,12 @@ namespace Maui.Controls.Sample.Issues; public class Issue36801 : ContentPage { readonly ScrollView _scrollView; + readonly VerticalStackLayout _content; + readonly Label _probeLabel; readonly Label _endResultLabel; readonly Label _topResultLabel; + readonly Label _elementResultLabel; + readonly Label _deferredResultLabel; public Issue36801() { @@ -14,14 +18,15 @@ public Issue36801() _endResultLabel = new Label { Text = "EndPending", AutomationId = "EndResultLabel" }; _topResultLabel = new Label { Text = "TopPending", AutomationId = "TopResultLabel" }; + _elementResultLabel = new Label { Text = "ElementPending", AutomationId = "ElementResultLabel" }; + _deferredResultLabel = new Label { Text = "DeferredPending", AutomationId = "DeferredResultLabel" }; var scrollToEndButton = new Button { Text = "Scroll to end", AutomationId = "ScrollToEndButton" }; scrollToEndButton.Clicked += async (sender, e) => { _endResultLabel.Text = "EndPending"; await _scrollView.ScrollToAsync(0, _scrollView.ContentSize.Height, animated: false); - await Task.Delay(100); - _endResultLabel.Text = EvaluateOffset(expectEnd: true); + await EvaluateUntilSuccess(_endResultLabel, () => EvaluateOffset(expectEnd: true, kind: "end")); }; var scrollToTopButton = new Button { Text = "Scroll to top", AutomationId = "ScrollToTopButton" }; @@ -29,27 +34,35 @@ public Issue36801() { _topResultLabel.Text = "TopPending"; await _scrollView.ScrollToAsync(0, 0, animated: false); - await Task.Delay(100); - _topResultLabel.Text = EvaluateOffset(expectEnd: false); + await EvaluateUntilSuccess(_topResultLabel, () => EvaluateOffset(expectEnd: false, kind: "top")); }; - var content = new VerticalStackLayout { Padding = 16, Spacing = 6 }; + var scrollToProbeButton = new Button { Text = "Scroll to probe (End)", AutomationId = "ScrollToProbeButton" }; + scrollToProbeButton.Clicked += async (sender, e) => + { + _elementResultLabel.Text = "ElementPending"; + await _scrollView.ScrollToAsync(_probeLabel, ScrollToPosition.End, animated: false); + await EvaluateUntilSuccess(_elementResultLabel, EvaluateElementEnd); + }; + + _content = new VerticalStackLayout { Padding = 16, Spacing = 6 }; // Spacer so the scrollable content starts below the floating header - content.Add(new BoxView { HeightRequest = 220, Color = Colors.Transparent }); + _content.Add(new BoxView { HeightRequest = 220, Color = Colors.Transparent }); for (int i = 0; i < 60; i++) { - content.Add(new Label { Text = $"Filler {i}", HeightRequest = 30 }); + _content.Add(new Label { Text = $"Filler {i}", HeightRequest = 30 }); } - content.Add(new Label + _probeLabel = new Label { Text = "BOTTOM PROBE", AutomationId = "ProbeLabel", FontAttributes = FontAttributes.Bold - }); + }; + _content.Add(_probeLabel); - _scrollView = new ScrollView { Content = content }; + _scrollView = new ScrollView { Content = _content }; // Floating header keeps the buttons and result labels visible and tappable // regardless of the scroll position. @@ -59,12 +72,12 @@ public Issue36801() Spacing = 6, BackgroundColor = Colors.LightGray, VerticalOptions = LayoutOptions.Start, - Children = { scrollToEndButton, scrollToTopButton, _endResultLabel, _topResultLabel } + Children = { scrollToEndButton, scrollToTopButton, scrollToProbeButton, _endResultLabel, _topResultLabel, _elementResultLabel, _deferredResultLabel } }; - Content = new Grid { Children = { _scrollView, header } }; + Content = new Grid { AutomationId = "PageRoot", Children = { _scrollView, header } }; -#if IOS +#if IOS || MACCATALYST // Give the native scroll view explicit content insets so it has a non-zero // AdjustedContentInset regardless of how the host positions the page relative // to the system chrome. This mirrors the issue's "custom chrome" scenario @@ -78,40 +91,117 @@ public Issue36801() } }; #endif + + // Issue a request before any handler or layout exists so it travels through the + // deferred PendingScrollToRequest drain in the first layout pass, where the adjusted + // insets may still be stale (the #35395 OnAppearing scenario). + RunDeferredScroll(); + } + + async void RunDeferredScroll() + { + await _scrollView.ScrollToAsync(0, 100000, animated: false); + await EvaluateUntilSuccess(_deferredResultLabel, () => EvaluateOffset(expectEnd: true, kind: "deferred")); + } + + // Re-evaluates until the offset settles on the expected value so the UI test's polling + // wait can converge instead of freezing a single too-early sample. + static async Task EvaluateUntilSuccess(Label label, Func evaluate) + { + for (int attempt = 0; attempt < 20; attempt++) + { + var result = evaluate(); + label.Text = result; + if (result.StartsWith("Success", StringComparison.Ordinal)) + { + return; + } + + await Task.Delay(250); + } } - string EvaluateOffset(bool expectEnd) + string EvaluateOffset(bool expectEnd, string kind) { -#if IOS +#if IOS || MACCATALYST if (_scrollView.Handler?.PlatformView is not UIKit.UIScrollView nativeScrollView) { - return "Fail: native scroll view unavailable"; + return $"Fail ({kind}): native scroll view unavailable"; + } + + if (_content.Handler?.PlatformView is not UIKit.UIView contentView) + { + return $"Fail ({kind}): content platform view unavailable"; } var adjustedInset = nativeScrollView.AdjustedContentInset; if (adjustedInset.Top + adjustedInset.Bottom <= 0) { // Without insets the buggy and correct math coincide and the test proves nothing - var frameInWindow = nativeScrollView.ConvertRectToView(nativeScrollView.Bounds, null); - var screen = UIKit.UIScreen.MainScreen.Bounds; - var safeArea = nativeScrollView.SafeAreaInsets; - return $"Fail: scenario invalid, no adjusted content insets " + - $"(behavior={nativeScrollView.ContentInsetAdjustmentBehavior}, safeArea=({safeArea.Top:F0},{safeArea.Bottom:F0}), " + - $"frame={frameInWindow.Y:F0}x{frameInWindow.Height:F0}, screen={screen.Height:F0}, " + + return $"Fail ({kind}): scenario invalid, no adjusted content insets " + + $"(behavior={nativeScrollView.ContentInsetAdjustmentBehavior}, " + $"contentInset=({nativeScrollView.ContentInset.Top:F0},{nativeScrollView.ContentInset.Bottom:F0}))"; } + // Independent oracle: measure where the content platform view actually sits inside the + // scroll view instead of re-deriving the implementation's ContentSize arithmetic. + // At the end, the content's last pixel must rest exactly at the bottom of the + // unobscured viewport; at the top, the offset must be the natural rest position. double expected = expectEnd - ? nativeScrollView.ContentSize.Height + adjustedInset.Bottom - nativeScrollView.Bounds.Height - : -adjustedInset.Top; - double actual = nativeScrollView.ContentOffset.Y; - var kind = expectEnd ? "end" : "top"; - - return Math.Abs(actual - expected) <= 1.5 - ? $"Success ({kind}): offset={actual:F1}" - : $"Fail ({kind}): actual={actual:F1} expected={expected:F1}"; + ? (double)(contentView.Frame.Bottom + adjustedInset.Bottom - nativeScrollView.Bounds.Height) + : -(double)adjustedInset.Top; + double actual = (double)nativeScrollView.ContentOffset.Y; + + if (Math.Abs(actual - expected) > 1.5) + { + return $"Fail ({kind}): actual={actual:F1} expected={expected:F1}"; + } + + // Also pin the public contract: ScrollY is published in cross-platform content + // coordinates, i.e. the native offset shifted by the adjusted inset (0 at rest) + double expectedScrollY = expected + (double)adjustedInset.Top; + if (Math.Abs(_scrollView.ScrollY - expectedScrollY) > 1.5) + { + return $"Fail ({kind}): ScrollY={_scrollView.ScrollY:F1} expected={expectedScrollY:F1}"; + } + + return $"Success ({kind}): offset={actual:F1} scrollY={_scrollView.ScrollY:F1}"; +#else + return $"Skipped ({kind}): not applicable on this platform"; +#endif + } + + string EvaluateElementEnd() + { +#if IOS || MACCATALYST + if (_scrollView.Handler?.PlatformView is not UIKit.UIScrollView nativeScrollView) + { + return "Fail (element): native scroll view unavailable"; + } + + if (_probeLabel.Handler?.PlatformView is not UIKit.UIView probeView) + { + return "Fail (element): probe platform view unavailable"; + } + + var adjustedInset = nativeScrollView.AdjustedContentInset; + if (adjustedInset.Top + adjustedInset.Bottom <= 0) + { + return "Fail (element): scenario invalid, no adjusted content insets"; + } + + // Independent geometric oracle: after ScrollToAsync(probe, End) the probe's bottom edge + // must sit exactly at the bottom of the unobscured viewport, in window coordinates. + var probeInWindow = probeView.ConvertRectToView(probeView.Bounds, null); + var scrollInWindow = nativeScrollView.ConvertRectToView(nativeScrollView.Bounds, null); + double visibleBottom = (double)(scrollInWindow.Bottom - adjustedInset.Bottom); + double actual = (double)probeInWindow.Bottom; + + return Math.Abs(actual - visibleBottom) <= 1.5 + ? $"Success (element): bottom={actual:F1}" + : $"Fail (element): actual={actual:F1} expected={visibleBottom:F1}"; #else - return "Success: not applicable on this platform"; + return "Skipped (element): not applicable on this platform"; #endif } } diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs index 0e691249fbc8..5b12d2ddb99f 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs @@ -1,4 +1,4 @@ -#if IOS // Validates iOS-specific UIScrollView AdjustedContentInset math with iOS-only instrumentation in the HostApp page +#if IOS || MACCATALYST // Validates UIScrollView AdjustedContentInset math with platform instrumentation in the HostApp page using NUnit.Framework; using UITest.Appium; using UITest.Core; @@ -16,20 +16,54 @@ public Issue36801(TestDevice device) : base(device) { } public void ScrollToAsyncReachesInsetAwareExtremes() { App.WaitForElement("ScrollToEndButton"); + + // A ScrollToAsync issued before the first layout must also land on the inset-aware + // maximum once the deferred request drains and the adjusted insets settle + var deferredSuccess = App.WaitForTextToBePresentInElement("DeferredResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var deferredText = App.FindElement("DeferredResultLabel").GetText(); + Assert.That(deferredSuccess, Is.True, $"Deferred (pre-layout) scroll to end did not settle on the inset-aware maximum: {deferredText}"); + App.Tap("ScrollToEndButton"); - // The page compares the native content offset against the inset-aware maximum - // (ContentSize + AdjustedContentInset.Bottom - Bounds.Height) - var endSuccess = App.WaitForTextToBePresentInElement("EndResultLabel", "Success", timeout: TimeSpan.FromSeconds(5)); + // The page measures where the content platform view actually rests: its last pixel + // must sit exactly at the bottom of the unobscured viewport + var endSuccess = App.WaitForTextToBePresentInElement("EndResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); var endText = App.FindElement("EndResultLabel").GetText(); Assert.That(endSuccess, Is.True, $"Scroll to end did not reach the inset-aware maximum: {endText}"); + // Independent user-visible oracle: the probe label at the very end of the content + // must now be fully inside the page, above the bottom edge + var probeRect = App.WaitForElement("ProbeLabel").GetRect(); + var pageRect = App.WaitForElement("PageRoot").GetRect(); + Assert.That(probeRect.Y, Is.GreaterThanOrEqualTo(pageRect.Y), "Probe label should be inside the page after scrolling to end"); + Assert.That(probeRect.Y + probeRect.Height, Is.LessThanOrEqualTo(pageRect.Y + pageRect.Height + 1), + "Probe label should be fully visible above the bottom edge after scrolling to end"); + App.Tap("ScrollToTopButton"); // Scrolling back to 0 must land on the natural rest position (-AdjustedContentInset.Top) - var topSuccess = App.WaitForTextToBePresentInElement("TopResultLabel", "Success", timeout: TimeSpan.FromSeconds(5)); + var topSuccess = App.WaitForTextToBePresentInElement("TopResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); var topText = App.FindElement("TopResultLabel").GetText(); Assert.That(topSuccess, Is.True, $"Scroll to top did not land on the rest position: {topText}"); } + + [Test] + [Category(UITestCategories.ScrollView)] + public void ScrollToElementEndLandsInsideVisibleViewport() + { + App.WaitForElement("ScrollToProbeButton"); + App.Tap("ScrollToProbeButton"); + + // The page asserts, in window coordinates, that the probe's bottom edge rests exactly + // at the bottom of the unobscured viewport (frame bottom minus AdjustedContentInset.Bottom) + var elementSuccess = App.WaitForTextToBePresentInElement("ElementResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var elementText = App.FindElement("ElementResultLabel").GetText(); + Assert.That(elementSuccess, Is.True, $"ScrollToAsync(element, End) did not align the element with the visible viewport bottom: {elementText}"); + + var probeRect = App.WaitForElement("ProbeLabel").GetRect(); + var pageRect = App.WaitForElement("PageRoot").GetRect(); + Assert.That(probeRect.Y + probeRect.Height, Is.LessThanOrEqualTo(pageRect.Y + pageRect.Height + 1), + "Probe label should be fully visible after ScrollToAsync(element, End)"); + } } #endif diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs index 49e7437daa09..47a8d98a77e0 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs @@ -129,10 +129,28 @@ public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView sc var adjustedInset = uiScrollView.AdjustedContentInset; var bounds = uiScrollView.Bounds; + // With ContentInsetAdjustmentBehavior.Always, MauiScrollView bakes the safe area into + // ContentSize (CrossPlatformArrange) while UIKit also applies the same safe area through + // AdjustedContentInset; exclude the duplicated amount (adjusted minus the explicit + // ContentInset, i.e. the system-contributed part) so the maximum stops at the content + // instead of inside the padding. + var duplicatedPadding = UIEdgeInsets.Zero; + if (uiScrollView.ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Always) + { + var contentInset = uiScrollView.ContentInset; + duplicatedPadding = new UIEdgeInsets( + adjustedInset.Top - contentInset.Top, + adjustedInset.Left - contentInset.Left, + adjustedInset.Bottom - contentInset.Bottom, + adjustedInset.Right - contentInset.Right); + } + var minScrollHorizontal = -(double)adjustedInset.Left; var minScrollVertical = -(double)adjustedInset.Top; - var maxScrollHorizontal = Math.Max(minScrollHorizontal, uiScrollView.ContentSize.Width + adjustedInset.Right - bounds.Width); - var maxScrollVertical = Math.Max(minScrollVertical, uiScrollView.ContentSize.Height + adjustedInset.Bottom - bounds.Height); + var maxScrollHorizontal = Math.Max(minScrollHorizontal, + uiScrollView.ContentSize.Width - duplicatedPadding.Left - duplicatedPadding.Right + adjustedInset.Right - bounds.Width); + var maxScrollVertical = Math.Max(minScrollVertical, + uiScrollView.ContentSize.Height - duplicatedPadding.Top - duplicatedPadding.Bottom + adjustedInset.Bottom - bounds.Height); var targetHorizontal = Math.Clamp(request.HorizontalOffset - (double)adjustedInset.Left, minScrollHorizontal, maxScrollHorizontal); var targetVertical = Math.Clamp(request.VerticalOffset - (double)adjustedInset.Top, minScrollVertical, maxScrollVertical); From 0ddb79cfd2cf5ea39c8f53cb0461f3767cefcdcb Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Wed, 5 Aug 2026 22:22:44 +0200 Subject: [PATCH 03/10] Extract scroll-target math into GetTargetContentOffset Refactor only, no behavior change: MapRequestScrollTo shrinks back to defer/compute/apply, and the coordinate translation and inset-aware clamping move into a focused static helper. The Always-mode compensation now adjusts an effective content extent once instead of threading a duplicated-padding UIEdgeInsets through the max formulas, and the already-at-target check is a single CGPoint comparison. UI tests re-verified passing on the iOS simulator after the refactor. Co-Authored-By: Claude Fable 5 --- .../ScrollView/ScrollViewHandler.iOS.cs | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs index 47a8d98a77e0..72033aeb6df4 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs @@ -121,45 +121,12 @@ public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView sc return; } - // Cross-platform scroll coordinates treat (0,0) as the top of the content, but when the - // scroll view has content insets (e.g. it consumes the safe area) the native rest offset - // is (-adjustedInset.Left, -adjustedInset.Top) and the native maximum extends past - // ContentSize - Bounds by the trailing insets. Translate the request into native offset - // space and clamp against the inset-aware range (issue #36801). - var adjustedInset = uiScrollView.AdjustedContentInset; - var bounds = uiScrollView.Bounds; - - // With ContentInsetAdjustmentBehavior.Always, MauiScrollView bakes the safe area into - // ContentSize (CrossPlatformArrange) while UIKit also applies the same safe area through - // AdjustedContentInset; exclude the duplicated amount (adjusted minus the explicit - // ContentInset, i.e. the system-contributed part) so the maximum stops at the content - // instead of inside the padding. - var duplicatedPadding = UIEdgeInsets.Zero; - if (uiScrollView.ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Always) - { - var contentInset = uiScrollView.ContentInset; - duplicatedPadding = new UIEdgeInsets( - adjustedInset.Top - contentInset.Top, - adjustedInset.Left - contentInset.Left, - adjustedInset.Bottom - contentInset.Bottom, - adjustedInset.Right - contentInset.Right); - } - - var minScrollHorizontal = -(double)adjustedInset.Left; - var minScrollVertical = -(double)adjustedInset.Top; - var maxScrollHorizontal = Math.Max(minScrollHorizontal, - uiScrollView.ContentSize.Width - duplicatedPadding.Left - duplicatedPadding.Right + adjustedInset.Right - bounds.Width); - var maxScrollVertical = Math.Max(minScrollVertical, - uiScrollView.ContentSize.Height - duplicatedPadding.Top - duplicatedPadding.Bottom + adjustedInset.Bottom - bounds.Height); - - var targetHorizontal = Math.Clamp(request.HorizontalOffset - (double)adjustedInset.Left, minScrollHorizontal, maxScrollHorizontal); - var targetVertical = Math.Clamp(request.VerticalOffset - (double)adjustedInset.Top, minScrollVertical, maxScrollVertical); - - bool alreadyAtTarget = uiScrollView.ContentOffset.Y == targetVertical && uiScrollView.ContentOffset.X == targetHorizontal; + var target = GetTargetContentOffset(uiScrollView, request); + bool alreadyAtTarget = uiScrollView.ContentOffset == target; if (!alreadyAtTarget) { - uiScrollView.SetContentOffset(new CGPoint(targetHorizontal, targetVertical), !request.Instant); + uiScrollView.SetContentOffset(target, !request.Instant); } if (request.Instant || alreadyAtTarget) @@ -169,6 +136,39 @@ public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView sc } } + // Cross-platform scroll coordinates treat (0,0) as the top of the content, but with content + // insets (e.g. a scroll view consuming the safe area) the native rest offset is + // (-adjustedInset.Left, -adjustedInset.Top) and the native maximum extends past + // ContentSize - Bounds by the trailing insets. Translate the request into native offset + // space and clamp against the inset-aware range (issue #36801). + static CGPoint GetTargetContentOffset(UIScrollView uiScrollView, ScrollToRequest request) + { + var adjustedInset = uiScrollView.AdjustedContentInset; + var bounds = uiScrollView.Bounds; + var contentWidth = (double)uiScrollView.ContentSize.Width; + var contentHeight = (double)uiScrollView.ContentSize.Height; + + // With ContentInsetAdjustmentBehavior.Always, MauiScrollView bakes the safe area into + // ContentSize while UIKit also applies it through AdjustedContentInset; exclude the + // duplicated (system-contributed) amount so the maximum stops at the content instead + // of inside the padding. + if (uiScrollView.ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Always) + { + var contentInset = uiScrollView.ContentInset; + contentWidth -= adjustedInset.Left + adjustedInset.Right - contentInset.Left - contentInset.Right; + contentHeight -= adjustedInset.Top + adjustedInset.Bottom - contentInset.Top - contentInset.Bottom; + } + + var minScrollHorizontal = -(double)adjustedInset.Left; + var minScrollVertical = -(double)adjustedInset.Top; + var maxScrollHorizontal = Math.Max(minScrollHorizontal, contentWidth + adjustedInset.Right - bounds.Width); + var maxScrollVertical = Math.Max(minScrollVertical, contentHeight + adjustedInset.Bottom - bounds.Height); + + return new CGPoint( + Math.Clamp(request.HorizontalOffset - (double)adjustedInset.Left, minScrollHorizontal, maxScrollHorizontal), + Math.Clamp(request.VerticalOffset - (double)adjustedInset.Top, minScrollVertical, maxScrollVertical)); + } + static void UpdateContentView(IScrollView scrollView, IScrollViewHandler handler) { bool changed = false; From 7f427d1a378033470156d7c1194eaabd4c7532ee Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Thu, 6 Aug 2026 10:33:29 +0200 Subject: [PATCH 04/10] Convert ScrollY back to a native offset for the Shell flyout header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShellFlyoutLayoutManager's header math works in native scroll offsets, which rest at -headerHeight because SetHeaderContentInset carries the header in ContentInset. Its ScrollView branch fed ScrollView.ScrollY in directly; now that offsets are reported in content coordinates, that value is 0 at rest and the header was pushed a full header height off the top for FlyoutHeaderBehavior.Scroll and collapsed immediately for CollapseOnScroll. The branch now subtracts the scroll view's top inset, mirroring what the CollectionView branch beside it already does (CollectionView reports inset-adjusted offsets via ItemsViewDelegator, so the same conversion was already needed there). The manager already holds the UIScrollView and forces ContentInsetAdjustmentBehavior.Never on it, so the conversion is local and exact. Coverage: ShellFlyoutHeaderScrollViewContent exercises a Shell whose FlyoutContent is a ScrollView — the existing ShellFlyoutHeaderBehavior test uses the default flyout, which goes through ShellTableViewController and raw native offsets, so it never touched this path. The page scrolls the flyout content and returns to the top, then reports the header container's frame offset. Verified on the iOS simulator: fails without the fix (headerY=-106, header pushed above the flyout top) and passes with it. Co-Authored-By: Claude Fable 5 --- .../Shell/iOS/ShellFlyoutLayoutManager.cs | 6 +- .../ShellFlyoutHeaderScrollViewContent.cs | 107 ++++++++++++++++++ .../ShellFlyoutHeaderScrollViewContent.cs | 31 +++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/ShellFlyoutHeaderScrollViewContent.cs diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs index 201a6018f6b2..d9edff633b94 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs @@ -78,7 +78,11 @@ public void SetCustomContent(View content) sv.Scrolled += ScrollViewScrolled; removeScrolledEvent = () => sv.Scrolled -= ScrollViewScrolled; void ScrollViewScrolled(object sender, ScrolledEventArgs e) => - OnScrolled((nfloat)sv.ScrollY); + // ScrollY is reported in content coordinates (native ContentOffset plus the + // adjusted inset), but OnScrolled works in native offsets, which rest at + // -headerHeight because SetHeaderContentInset puts the header in ContentInset. + // Convert back, the same way the CollectionView branch below does. + OnScrolled((nfloat)sv.ScrollY - (ScrollView?.AdjustedContentInset.Top ?? 0)); } #pragma warning disable CS0618 // Type or member is obsolete else if (Content is CollectionView cv) diff --git a/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs b/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs new file mode 100644 index 000000000000..944cc52d3a3e --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs @@ -0,0 +1,107 @@ +namespace Maui.Controls.Sample.Issues; + +// A Shell whose FlyoutContent is a ScrollView exercises ShellFlyoutLayoutManager's +// ScrollView branch, which feeds ScrollView.ScrollY into the header layout math. That math +// works in native offsets (which rest at -headerHeight because the header is carried in +// ContentInset), so it has to convert back from the content coordinates ScrollY reports. +// The default flyout uses a UITableView and a different code path, so it does not cover this. +[Issue(IssueTracker.None, 0, "Shell flyout header with ScrollView flyout content", PlatformAffected.iOS)] +public class ShellFlyoutHeaderScrollViewContent : TestShell +{ + const double HeaderHeight = 150; + + readonly Label _resultLabel = new() { Text = "Pending", AutomationId = "ResultLabel" }; + ScrollView _flyoutScroll; + Grid _headerGrid; + + protected override void Init() + { + Shell.SetFlyoutBehavior(this, FlyoutBehavior.Locked); + FlyoutHeaderBehavior = FlyoutHeaderBehavior.Scroll; + + _headerGrid = new Grid + { + HeightRequest = HeaderHeight, + BackgroundColor = Colors.MediumPurple, + AutomationId = "FlyoutHeaderId", + Children = + { + new Label + { + Text = "FLYOUT HEADER", + TextColor = Colors.White, + HorizontalTextAlignment = TextAlignment.Center, + VerticalTextAlignment = TextAlignment.Center + } + } + }; + + FlyoutHeader = _headerGrid; + + var rows = new VerticalStackLayout(); + for (int i = 0; i < 40; i++) + { + rows.Add(new Label { Text = $"Flyout row {i}", HeightRequest = 40 }); + } + + _flyoutScroll = new ScrollView { Content = rows, AutomationId = "FlyoutScroll" }; + FlyoutContent = _flyoutScroll; + + var runButton = new Button { Text = "Scroll flyout and return", AutomationId = "RunButton" }; + runButton.Clicked += async (sender, e) => await RunAsync(); + + AddFlyoutItem(new ContentPage + { + Content = new VerticalStackLayout + { + Padding = 24, + Spacing = 12, + Children = { runButton, _resultLabel } + } + }, "Item"); + } + + async Task RunAsync() + { + _resultLabel.Text = "Pending"; + + // Scroll the flyout content and come back to the top. The round trip guarantees the + // header layout is driven by a Scrolled event (the ScrollView branch) rather than the + // direct native call that also runs during initial layout. + await _flyoutScroll.ScrollToAsync(0, 200, animated: false); + await Task.Delay(150); + await _flyoutScroll.ScrollToAsync(0, 0, animated: false); + await Task.Delay(150); + + _resultLabel.Text = EvaluateHeaderPosition(); + } + + string EvaluateHeaderPosition() + { +#if IOS || MACCATALYST + if (_headerGrid.Handler?.PlatformView is not UIKit.UIView headerView) + { + return "Fail: header platform view unavailable"; + } + + // The Shell renderer positions the container that wraps the header content, not the + // header content itself, so read the container's frame + if (headerView.Superview is not UIKit.UIView headerContainer) + { + return "Fail: header container unavailable"; + } + + // FlyoutHeaderBehavior.Scroll positions the header by its frame Y (the scroll offset, + // plus the safe area when honoured), so back at the top of the content the header must + // be fully visible again — never pushed above the flyout's top edge. Computing the + // header offset from the wrong coordinate space pushes it up by a full header height. + double headerY = headerContainer.Frame.Y; + + return headerY >= -1 + ? $"Success: headerY={headerY:F1} height={headerContainer.Frame.Height:F1}" + : $"Fail: headerY={headerY:F1}, expected the header to stay at or below the top"; +#else + return "Skipped: not applicable on this platform"; +#endif + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/ShellFlyoutHeaderScrollViewContent.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/ShellFlyoutHeaderScrollViewContent.cs new file mode 100644 index 000000000000..e764b3b670b7 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/ShellFlyoutHeaderScrollViewContent.cs @@ -0,0 +1,31 @@ +#if IOS || MACCATALYST // The header offset is read from the iOS platform view in the HostApp page +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class ShellFlyoutHeaderScrollViewContent : _IssuesUITest +{ + public ShellFlyoutHeaderScrollViewContent(TestDevice testDevice) : base(testDevice) + { + } + + public override string Issue => "Shell flyout header with ScrollView flyout content"; + + [Test] + [Category(UITestCategories.Shell)] + public void FlyoutHeaderReturnsToTopWhenScrollViewContentScrollsBack() + { + App.WaitForElement("RunButton"); + App.Tap("RunButton"); + + // The page scrolls the flyout ScrollView down and back to the top, then reports the + // header's platform frame offset — which must be 0 again once the content is back + // at the top under FlyoutHeaderBehavior.Scroll. + var success = App.WaitForTextToBePresentInElement("ResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var resultText = App.FindElement("ResultLabel").GetText(); + Assert.That(success, Is.True, $"Flyout header did not return to the top: {resultText}"); + } +} +#endif From 947997e4d9d592979e0dadcfb4c02bb413b9a82b Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Thu, 6 Aug 2026 11:04:46 +0200 Subject: [PATCH 05/10] Own the scroll coordinate convention in Core and cover all inset modes Addresses the remaining AI review findings. Layering: Controls no longer reaches into UIKit to re-derive inset knowledge the handler already owns. A new internal Core contract, IScrollViewportProvider, reports the effective viewport insets and is implemented only by the iOS handler, so GetScrollPositionForElement consumes a platform-agnostic value and other handlers keep the full-frame math without a silent fallback. Cohesion: the "ContentSize already includes the system inset" rule moves onto MauiScrollView.ScrollableContentSize, next to the CrossPlatformArrange logic that creates the double counting, instead of being restated by the handler across an assembly boundary. Element-mode requests are no longer converted at handler-attach, where Width/Height are still -1 and every content coordinate is 0. They stay in element mode until the ScrollView has geometry and a non-empty ContentSize, and resolve on the next dispatcher tick so the layout pass that produced those sizes has finished arranging children. Reported offsets are refreshed when the adjusted insets change without the offset moving (keyboard, rotation, an auto-hiding bar). UIKit raises no scroll notification for that, so ScrollX/ScrollY would go stale by the inset delta and ScrollToAsync(ScrollX, ScrollY) would stop round-tripping. MauiScrollView reports it through the same internal contract. Tests: - SafeAreaEdges is set on the ScrollView itself. It does not propagate from the page, so the fixture had been resolving to Automatic while reading as Never. - The oracle asserts the resolved ContentInsetAdjustmentBehavior, so a drifting mode fails loudly instead of testing a different branch. - All three modes are covered. SafeAreaEdges.All maps to Never, so the Always case is built from SafeAreaRegions.Container. - The ContentInset is applied from HandlerChanged rather than Loaded, so it is in place before the layout pass that drains the deferred request. - New coverage for the deferred element-mode path. Verified on the iOS simulator: 7/7 UI tests pass, including the three inset modes and the Shell flyout header. No new public API. Co-Authored-By: Claude Fable 5 --- .../src/Core/ScrollView/ScrollView.cs | 85 ++++++++++---- .../TestCases.HostApp/Issues/Issue36801.cs | 68 ++++++++++- .../Issues/Issue36801DeferredElement.cs | 107 ++++++++++++++++++ .../Tests/Issues/Issue36801.cs | 26 +++++ .../Tests/Issues/Issue36801DeferredElement.cs | 27 +++++ .../ScrollView/IScrollViewportProvider.cs | 28 +++++ .../ScrollView/ScrollViewHandler.iOS.cs | 71 +++++++----- src/Core/src/Platform/iOS/MauiScrollView.cs | 39 +++++++ 8 files changed, 400 insertions(+), 51 deletions(-) create mode 100644 src/Controls/tests/TestCases.HostApp/Issues/Issue36801DeferredElement.cs create mode 100644 src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801DeferredElement.cs create mode 100644 src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs diff --git a/src/Controls/src/Core/ScrollView/ScrollView.cs b/src/Controls/src/Core/ScrollView/ScrollView.cs index b7815f29f9cb..e727a873416f 100644 --- a/src/Controls/src/Core/ScrollView/ScrollView.cs +++ b/src/Controls/src/Core/ScrollView/ScrollView.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Threading.Tasks; using Microsoft.Maui.Graphics; +using Microsoft.Maui.Handlers; using Microsoft.Maui.Layouts; namespace Microsoft.Maui.Controls @@ -40,23 +41,66 @@ public Rect LayoutAreaOverride public event EventHandler ScrollToRequested; ScrollToRequestedEventArgs _pendingScrollToRequested; + bool _elementScrollDispatchQueued; private protected override void OnHandlerChangedCore() { base.OnHandlerChangedCore(); + DispatchPendingScrollToRequest(); + } + + void DispatchPendingScrollToRequest() + { + if (Handler is null || _pendingScrollToRequested is not { } pending) + { + return; + } - if (Handler is not null && _pendingScrollToRequested is not null) + if (pending.Mode == ScrollToMode.Element) { - var pending = _pendingScrollToRequested; - _pendingScrollToRequested = null; + // An element target is resolved against this ScrollView's geometry and the + // element's position inside the arranged content. Before the first layout pass + // Width/Height are still -1 and every content coordinate is 0, so the request + // has to wait; OnSizeAllocated and ContentSizeChanged retry it. + if (Width < 0 || Height < 0 || ContentSize.IsZero) + { + return; + } + + // Those callbacks run while the pass that produced the sizes is still arranging + // children, so resolve on the next tick, once positions are final. + if (!_elementScrollDispatchQueued) + { + _elementScrollDispatchQueued = true; + Dispatcher.Dispatch(() => + { + _elementScrollDispatchQueued = false; + SendPendingScrollToRequest(); + }); + } + + return; + } + + SendPendingScrollToRequest(); + } - // Replay without going through OnScrollToRequested: that would reset the - // completion source and orphan the task the original caller is still awaiting - ScrollToRequested?.Invoke(this, pending); - Handler.Invoke(nameof(IScrollView.RequestScrollTo), ConvertRequestMode(pending).ToRequest()); + void SendPendingScrollToRequest() + { + if (Handler is null || _pendingScrollToRequested is not { } pending) + { + return; } + + _pendingScrollToRequested = null; + + // Replay without going through OnScrollToRequested: that would reset the + // completion source and orphan the task the original caller is still awaiting + ScrollToRequested?.Invoke(this, pending); + Handler.Invoke(nameof(IScrollView.RequestScrollTo), ConvertRequestMode(pending).ToRequest()); } + /// /// Gets the scroll position for the specified element. /// @@ -108,20 +152,11 @@ public Point GetScrollPositionForElement(VisualElement item, ScrollToPosition po return new Point(x, y); } - // On iOS the scrollable viewport is smaller than the frame when the native scroll view - // carries content insets (safe area, ContentInset): the insets obscure part of the frame - // and (0,0) in cross-platform scroll coordinates is the inset rest position. - Thickness GetVisibleViewportInsets() - { -#if IOS || MACCATALYST - if (Handler?.PlatformView is UIKit.UIScrollView platformView) - { - var inset = platformView.AdjustedContentInset; - return new Thickness(inset.Left, inset.Top, inset.Right, inset.Bottom); - } -#endif - return default; - } + // The scrollable viewport can be smaller than the frame: on iOS the adjusted content + // insets obscure part of it. The handler owns that coordinate convention and reports + // it here; handlers whose viewport always equals the frame don't implement the contract. + Thickness GetVisibleViewportInsets() => + (Handler as IScrollViewportProvider)?.ViewportInsets ?? default; /// /// Sends the scroll finished notification. @@ -259,6 +294,10 @@ void ContentSizeChanged(object sender, EventArgs e) // The ContentSize includes the margins for the content ContentSize = new Size(frameSize.Width + margin.HorizontalThickness, frameSize.Height + margin.VerticalThickness); + + // The content has been arranged, so an element target can now be resolved: its + // position is read from the content tree, which is only meaningful once laid out + DispatchPendingScrollToRequest(); } /// @@ -561,6 +600,10 @@ protected override Size ArrangeOverride(Rect bounds) protected override void OnSizeAllocated(double width, double height) { base.OnSizeAllocated(width, height); + + // Geometry is now known, so an element-mode request held back at handler-attach + // can be resolved + DispatchPendingScrollToRequest(); } Size ICrossPlatformLayout.CrossPlatformArrange(Rect bounds) diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs index 2e3a29e38a50..8d1a96b52335 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs @@ -62,7 +62,20 @@ public Issue36801() }; _content.Add(_probeLabel); - _scrollView = new ScrollView { Content = _content }; + // Set the mode on the ScrollView itself: SafeAreaEdges does not propagate from the + // page, and the ScrollView's own value is what selects ContentInsetAdjustmentBehavior. + _scrollView = new ScrollView { Content = _content, SafeAreaEdges = SafeAreaEdges.Default }; + + var modeDefaultButton = new Button { Text = "Mode Default", AutomationId = "ModeDefaultButton" }; + modeDefaultButton.Clicked += (sender, e) => SetMode(SafeAreaEdges.Default); + + var modeNoneButton = new Button { Text = "Mode None", AutomationId = "ModeNoneButton" }; + modeNoneButton.Clicked += (sender, e) => SetMode(SafeAreaEdges.None); + + var modeContainerButton = new Button { Text = "Mode Container", AutomationId = "ModeContainerButton" }; + // SafeAreaEdges.Container is internal, so build the same value from the public enum. + // SafeAreaEdges.All would not do: it is mapped to Never, not Always. + modeContainerButton.Clicked += (sender, e) => SetMode(new SafeAreaEdges(SafeAreaRegions.Container)); // Floating header keeps the buttons and result labels visible and tappable // regardless of the scroll position. @@ -72,7 +85,12 @@ public Issue36801() Spacing = 6, BackgroundColor = Colors.LightGray, VerticalOptions = LayoutOptions.Start, - Children = { scrollToEndButton, scrollToTopButton, scrollToProbeButton, _endResultLabel, _topResultLabel, _elementResultLabel, _deferredResultLabel } + Children = + { + modeDefaultButton, modeNoneButton, modeContainerButton, + scrollToEndButton, scrollToTopButton, scrollToProbeButton, + _endResultLabel, _topResultLabel, _elementResultLabel, _deferredResultLabel + } }; Content = new Grid { AutomationId = "PageRoot", Children = { _scrollView, header } }; @@ -83,7 +101,10 @@ public Issue36801() // to the system chrome. This mirrors the issue's "custom chrome" scenario // (AdditionalSafeAreaInsets) and deterministically exposes the clamping math: // the valid native offset range becomes [-60, ContentSize + 40 - Bounds.Height]. - _scrollView.Loaded += (sender, e) => + // Applied from HandlerChanged rather than Loaded so the inset is in place before the + // first layout pass, which is what drains the deferred scroll request — otherwise the + // scroll could be clamped against a different inset than the one asserted against. + _scrollView.HandlerChanged += (sender, e) => { if (_scrollView.Handler?.PlatformView is UIKit.UIScrollView nativeScrollView) { @@ -98,6 +119,17 @@ public Issue36801() RunDeferredScroll(); } + void SetMode(SafeAreaEdges edges) + { + _scrollView.SafeAreaEdges = edges; + + // Reset the results so a stale Success from the previous mode can't be read as this + // mode's outcome + _endResultLabel.Text = "EndPending"; + _topResultLabel.Text = "TopPending"; + _elementResultLabel.Text = "ElementPending"; + } + async void RunDeferredScroll() { await _scrollView.ScrollToAsync(0, 100000, animated: false); @@ -121,6 +153,24 @@ static async Task EvaluateUntilSuccess(Label label, Func evaluate) } } +#if IOS || MACCATALYST + // The whole clamp has a mode-specific branch, so a fixture that silently resolved to a + // different ContentInsetAdjustmentBehavior than intended would test the wrong one. + string CheckResolvedMode(UIKit.UIScrollView nativeScrollView, string kind) + { + var edges = _scrollView.SafeAreaEdges; + var expected = + edges.Equals(new SafeAreaEdges(SafeAreaRegions.Container)) ? UIKit.UIScrollViewContentInsetAdjustmentBehavior.Always : + edges.Equals(SafeAreaEdges.None) ? UIKit.UIScrollViewContentInsetAdjustmentBehavior.Never : + UIKit.UIScrollViewContentInsetAdjustmentBehavior.Automatic; + + return nativeScrollView.ContentInsetAdjustmentBehavior == expected + ? null + : $"Fail ({kind}): SafeAreaEdges={edges} resolved to " + + $"{nativeScrollView.ContentInsetAdjustmentBehavior}, expected {expected}"; + } +#endif + string EvaluateOffset(bool expectEnd, string kind) { #if IOS || MACCATALYST @@ -134,6 +184,11 @@ string EvaluateOffset(bool expectEnd, string kind) return $"Fail ({kind}): content platform view unavailable"; } + if (CheckResolvedMode(nativeScrollView, kind) is string modeFailure) + { + return modeFailure; + } + var adjustedInset = nativeScrollView.AdjustedContentInset; if (adjustedInset.Top + adjustedInset.Bottom <= 0) { @@ -165,7 +220,7 @@ string EvaluateOffset(bool expectEnd, string kind) return $"Fail ({kind}): ScrollY={_scrollView.ScrollY:F1} expected={expectedScrollY:F1}"; } - return $"Success ({kind}): offset={actual:F1} scrollY={_scrollView.ScrollY:F1}"; + return $"Success ({kind}): mode={nativeScrollView.ContentInsetAdjustmentBehavior} offset={actual:F1} scrollY={_scrollView.ScrollY:F1}"; #else return $"Skipped ({kind}): not applicable on this platform"; #endif @@ -184,6 +239,11 @@ string EvaluateElementEnd() return "Fail (element): probe platform view unavailable"; } + if (CheckResolvedMode(nativeScrollView, "element") is string modeFailure) + { + return modeFailure; + } + var adjustedInset = nativeScrollView.AdjustedContentInset; if (adjustedInset.Top + adjustedInset.Bottom <= 0) { diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801DeferredElement.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801DeferredElement.cs new file mode 100644 index 000000000000..7557fa8a9a92 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801DeferredElement.cs @@ -0,0 +1,107 @@ +namespace Maui.Controls.Sample.Issues; + +// Covers the deferred *element-mode* request: ScrollToAsync(element, End) issued before the +// handler exists. The target is resolved against the ScrollView's own geometry, so converting +// it at handler-attach — before the first layout, when Width/Height are still -1 — produces a +// target computed from invalid geometry. It has to wait for layout. +[Issue(IssueTracker.None, 0, "Deferred element-mode ScrollToAsync", PlatformAffected.iOS)] +public class Issue36801DeferredElement : ContentPage +{ + readonly ScrollView _scrollView; + readonly Label _probeLabel; + readonly Label _resultLabel; + + public Issue36801DeferredElement() + { + NavigationPage.SetHasNavigationBar(this, false); + + _resultLabel = new Label { Text = "Pending", AutomationId = "ResultLabel" }; + + var content = new VerticalStackLayout { Padding = 16, Spacing = 6 }; + content.Add(new BoxView { HeightRequest = 220, Color = Colors.Transparent }); + for (int i = 0; i < 60; i++) + { + content.Add(new Label { Text = $"Filler {i}", HeightRequest = 30 }); + } + + _probeLabel = new Label + { + Text = "BOTTOM PROBE", + AutomationId = "ProbeLabel", + FontAttributes = FontAttributes.Bold + }; + content.Add(_probeLabel); + + _scrollView = new ScrollView { Content = content, SafeAreaEdges = SafeAreaEdges.Default }; + + var header = new VerticalStackLayout + { + Padding = new Thickness(16, 60, 16, 8), + BackgroundColor = Colors.LightGray, + VerticalOptions = LayoutOptions.Start, + Children = { _resultLabel } + }; + + Content = new Grid { Children = { _scrollView, header } }; + +#if IOS || MACCATALYST + _scrollView.HandlerChanged += (sender, e) => + { + if (_scrollView.Handler?.PlatformView is UIKit.UIScrollView nativeScrollView) + { + nativeScrollView.ContentInset = new UIKit.UIEdgeInsets(60, 0, 40, 0); + } + }; +#endif + + // Issued before any handler or layout exists + RunDeferredElementScroll(); + } + + async void RunDeferredElementScroll() + { + await _scrollView.ScrollToAsync(_probeLabel, ScrollToPosition.End, animated: false); + + for (int attempt = 0; attempt < 20; attempt++) + { + var result = Evaluate(); + _resultLabel.Text = result; + if (result.StartsWith("Success", StringComparison.Ordinal)) + { + return; + } + + await Task.Delay(250); + } + } + + string Evaluate() + { +#if IOS || MACCATALYST + if (_scrollView.Handler?.PlatformView is not UIKit.UIScrollView nativeScrollView || + _probeLabel.Handler?.PlatformView is not UIKit.UIView probeView) + { + return "Fail: platform views unavailable"; + } + + var adjustedInset = nativeScrollView.AdjustedContentInset; + if (adjustedInset.Top + adjustedInset.Bottom <= 0) + { + return "Fail: scenario invalid, no adjusted content insets"; + } + + // Same geometric oracle as the non-deferred element test: the probe's bottom edge must + // rest exactly at the bottom of the unobscured viewport + var probeInWindow = probeView.ConvertRectToView(probeView.Bounds, null); + var scrollInWindow = nativeScrollView.ConvertRectToView(nativeScrollView.Bounds, null); + double visibleBottom = (double)(scrollInWindow.Bottom - adjustedInset.Bottom); + double actual = (double)probeInWindow.Bottom; + + return Math.Abs(actual - visibleBottom) <= 1.5 + ? $"Success: bottom={actual:F1}" + : $"Fail: actual={actual:F1} expected={visibleBottom:F1}"; +#else + return "Skipped: not applicable on this platform"; +#endif + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs index 5b12d2ddb99f..ee05d3383a49 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs @@ -65,5 +65,31 @@ public void ScrollToElementEndLandsInsideVisibleViewport() Assert.That(probeRect.Y + probeRect.Height, Is.LessThanOrEqualTo(pageRect.Y + pageRect.Height + 1), "Probe label should be fully visible after ScrollToAsync(element, End)"); } + + // The clamp has a mode-specific branch and the three ContentInsetAdjustmentBehavior modes + // bake the safe area into MauiScrollView.ContentSize differently, so each is exercised. + // The page asserts the resolved native behavior, so a mode that silently drifted fails + // instead of quietly testing a different branch. + [Test] + [Category(UITestCategories.ScrollView)] + [TestCase("ModeDefaultButton", "Automatic")] + [TestCase("ModeNoneButton", "Never")] + [TestCase("ModeContainerButton", "Always")] + public void ScrollToExtremesInEachInsetMode(string modeButton, string expectedMode) + { + App.WaitForElement(modeButton); + App.Tap(modeButton); + + App.Tap("ScrollToEndButton"); + var endSuccess = App.WaitForTextToBePresentInElement("EndResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var endText = App.FindElement("EndResultLabel").GetText(); + Assert.That(endSuccess, Is.True, $"[{expectedMode}] scroll to end did not reach the inset-aware maximum: {endText}"); + Assert.That(endText, Does.Contain($"mode={expectedMode}"), $"[{expectedMode}] resolved to a different inset mode: {endText}"); + + App.Tap("ScrollToTopButton"); + var topSuccess = App.WaitForTextToBePresentInElement("TopResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var topText = App.FindElement("TopResultLabel").GetText(); + Assert.That(topSuccess, Is.True, $"[{expectedMode}] scroll to top did not land on the rest position: {topText}"); + } } #endif diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801DeferredElement.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801DeferredElement.cs new file mode 100644 index 000000000000..fb21afd70eb7 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801DeferredElement.cs @@ -0,0 +1,27 @@ +#if IOS || MACCATALYST // Validates UIScrollView AdjustedContentInset math with platform instrumentation in the HostApp page +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue36801DeferredElement : _IssuesUITest +{ + public Issue36801DeferredElement(TestDevice device) : base(device) { } + + public override string Issue => "Deferred element-mode ScrollToAsync"; + + [Test] + [Category(UITestCategories.ScrollView)] + public void DeferredElementScrollLandsInsideVisibleViewport() + { + App.WaitForElement("ResultLabel"); + + // The page issues ScrollToAsync(probe, End) before the handler exists; the target can + // only be resolved once layout has given the ScrollView its geometry. + var success = App.WaitForTextToBePresentInElement("ResultLabel", "Success", timeout: TimeSpan.FromSeconds(15)); + var resultText = App.FindElement("ResultLabel").GetText(); + Assert.That(success, Is.True, $"Deferred element scroll did not align with the viewport bottom: {resultText}"); + } +} +#endif diff --git a/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs b/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs new file mode 100644 index 000000000000..4bcd19c31c96 --- /dev/null +++ b/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs @@ -0,0 +1,28 @@ +namespace Microsoft.Maui.Handlers +{ + /// + /// Implemented by scroll view handlers whose platform reduces the scrollable viewport + /// below the view's frame — on iOS/MacCatalyst the adjusted content insets obscure part + /// of the frame, so the visible viewport is smaller and (0,0) in cross-platform scroll + /// coordinates is the inset rest position. + /// + /// + /// The handler owns that coordinate convention; this is how the cross-platform layer and + /// the platform view consume it instead of re-deriving inset knowledge of their own. + /// Handlers on platforms where the viewport always equals the frame do not implement it. + /// + internal interface IScrollViewportProvider + { + /// + /// The insets obscuring the scrollable viewport, in cross-platform units. + /// + Thickness ViewportInsets { get; } + + /// + /// Notifies the handler that the platform view's insets changed. The reported scroll + /// offsets are derived from them, and insets can change without the offset moving — + /// which produces no scroll notification of its own. + /// + void NotifyInsetsChanged(); + } +} diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs index 72033aeb6df4..06825c278db6 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs @@ -7,12 +7,26 @@ namespace Microsoft.Maui.Handlers { - public partial class ScrollViewHandler : ViewHandler, ICrossPlatformLayout + public partial class ScrollViewHandler : ViewHandler, ICrossPlatformLayout, IScrollViewportProvider { readonly ScrollEventProxy _eventProxy = new(); internal ScrollToRequest? PendingScrollToRequest { get; private set; } + Thickness IScrollViewportProvider.ViewportInsets + { + get + { + if (PlatformView is not { } platformView) + { + return default; + } + + var inset = platformView.AdjustedContentInset; + return new Thickness(inset.Left, inset.Top, inset.Right, inset.Bottom); + } + } + protected override UIScrollView CreatePlatformView() { return new MauiScrollView(); @@ -145,19 +159,13 @@ static CGPoint GetTargetContentOffset(UIScrollView uiScrollView, ScrollToRequest { var adjustedInset = uiScrollView.AdjustedContentInset; var bounds = uiScrollView.Bounds; - var contentWidth = (double)uiScrollView.ContentSize.Width; - var contentHeight = (double)uiScrollView.ContentSize.Height; - - // With ContentInsetAdjustmentBehavior.Always, MauiScrollView bakes the safe area into - // ContentSize while UIKit also applies it through AdjustedContentInset; exclude the - // duplicated (system-contributed) amount so the maximum stops at the content instead - // of inside the padding. - if (uiScrollView.ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Always) - { - var contentInset = uiScrollView.ContentInset; - contentWidth -= adjustedInset.Left + adjustedInset.Right - contentInset.Left - contentInset.Right; - contentHeight -= adjustedInset.Top + adjustedInset.Bottom - contentInset.Top - contentInset.Bottom; - } + + // MauiScrollView reports the extent to clamp against, since only it knows when its + // arrange baked safe-area padding into ContentSize that UIKit is also applying + // through AdjustedContentInset + var contentSize = (uiScrollView as MauiScrollView)?.ScrollableContentSize ?? uiScrollView.ContentSize; + var contentWidth = (double)contentSize.Width; + var contentHeight = (double)contentSize.Height; var minScrollHorizontal = -(double)adjustedInset.Left; var minScrollVertical = -(double)adjustedInset.Top; @@ -276,23 +284,34 @@ void ScrollAnimationEnded(object? sender, EventArgs e) void Scrolled(object? sender, EventArgs e) { - if (VirtualView == null) + if (sender is UIScrollView platformView) { - return; + PublishScrollOffsets(VirtualView, platformView); } + } + } - if (sender is not UIScrollView platformView) - { - return; - } + void IScrollViewportProvider.NotifyInsetsChanged() + { + if (PlatformView is { } platformView) + { + PublishScrollOffsets(VirtualView, platformView); + } + } - // Report offsets in cross-platform content coordinates: with content insets the native - // rest offset is (-adjustedInset.Left, -adjustedInset.Top), which maps to (0,0) - // cross-platform so ScrollToAsync(ScrollX, ScrollY, ...) round-trips (issue #36801). - var adjustedInset = platformView.AdjustedContentInset; - VirtualView.HorizontalOffset = platformView.ContentOffset.X + adjustedInset.Left; - VirtualView.VerticalOffset = platformView.ContentOffset.Y + adjustedInset.Top; + // Report offsets in cross-platform content coordinates: with content insets the native + // rest offset is (-adjustedInset.Left, -adjustedInset.Top), which maps to (0,0) + // cross-platform so ScrollToAsync(ScrollX, ScrollY, ...) round-trips (issue #36801). + static void PublishScrollOffsets(IScrollView? virtualView, UIScrollView platformView) + { + if (virtualView is null) + { + return; } + + var adjustedInset = platformView.AdjustedContentInset; + virtualView.HorizontalOffset = platformView.ContentOffset.X + adjustedInset.Left; + virtualView.VerticalOffset = platformView.ContentOffset.Y + adjustedInset.Top; } } } diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs index 03742e67af04..eeb8cf03c578 100644 --- a/src/Core/src/Platform/iOS/MauiScrollView.cs +++ b/src/Core/src/Platform/iOS/MauiScrollView.cs @@ -155,6 +155,11 @@ public override void AdjustedContentInsetDidChange() ((IPlatformMeasureInvalidationController)this).InvalidateMeasure(); this.InvalidateAncestorsMeasures(); } + + // UIKit does not raise Scrolled when the inset changes while the offset stays put + // (keyboard, rotation, an auto-hiding bar), and the reported scroll offsets are + // derived from the inset, so they would go stale by the delta + (CrossPlatformLayout as IScrollViewportProvider)?.NotifyInsetsChanged(); } /// @@ -414,6 +419,40 @@ bool ValidateSafeArea() (oldSafeArea.EqualsAtPixelLevel(_safeArea) || !_appliesSafeAreaAdjustments); } + /// + /// The content extent to clamp scrolling against: + /// minus any safe-area padding that baked into it while + /// UIKit is *also* compensating for the same safe area through + /// . + /// + /// + /// That double counting only happens with . + /// With the padding stands in + /// for an inset UIKit does not apply, and with + /// no padding is added at + /// all — so in both of those the content size is already the scrollable extent. Kept here so + /// the rule lives with the arrange logic that produces it rather than being restated by the + /// handler (issue #36801). + /// + internal CGSize ScrollableContentSize + { + get + { + var contentSize = ContentSize; + + if (ContentInsetAdjustmentBehavior != UIScrollViewContentInsetAdjustmentBehavior.Always) + { + return contentSize; + } + + var duplicated = SystemAdjustedContentInset; + + return new CGSize( + contentSize.Width - duplicated.Left - duplicated.Right, + contentSize.Height - duplicated.Top - duplicated.Bottom); + } + } + UIEdgeInsets SystemAdjustedContentInset { get From 6894b3128da4da76c4f6c6cafb1d2e323331adc3 Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Thu, 6 Aug 2026 19:55:20 +0200 Subject: [PATCH 06/10] Fix deferred-request lifecycle and drop the Shell offset conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three errors from the latest review round. A newer request no longer loses to a queued one. OnScrollToRequested set _pendingScrollToRequested only when there was no handler, so a request dispatched directly left an already-scheduled element request alive to run afterwards and restore the older target. The direct path now clears it, so latest-request-wins holds. A deferred request can no longer wedge or hang. The "already queued" flag it used to guard the dispatcher post is gone: if that callback were ever dropped the flag stayed set and blocked every later retry from OnSizeAllocated and ContentSizeChanged. Posting on each retry is safe because SendPendingScrollToRequest is a no-op once the request has been sent or superseded. OnHandlerChangedCore also releases the caller when the handler goes away with a request still queued — nothing would ever dispatch it, and Core already does the same for its own pending request on disconnect. The Shell flyout conversion is removed rather than special-cased. Its header math works in native offsets, and the value ScrollView.ScrollY reports now depends on the renderer: the default handler publishes content coordinates while the compatibility renderer still publishes raw ContentOffset, so converting from it was right for one and wrong for the other. The manager already holds the UIScrollView, so it reads ContentOffset.Y directly and no longer depends on either convention. Coverage: DeferredElementScrollCompletesWhenTheHandlerGoesAway, which times out without the teardown fix. Verified on the iOS simulator: 7/7 UI tests pass, and the Shell flyout test still fails (headerY=-106) when the conversion is put back, so it keeps discriminating after the mechanism change. Co-Authored-By: Claude Fable 5 --- .../Shell/iOS/ShellFlyoutLayoutManager.cs | 13 ++++--- .../src/Core/ScrollView/ScrollView.cs | 37 +++++++++++++------ .../Core.UnitTests/ScrollViewUnitTests.cs | 22 +++++++++++ 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs index d9edff633b94..8d05e29a48d1 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs @@ -78,11 +78,14 @@ public void SetCustomContent(View content) sv.Scrolled += ScrollViewScrolled; removeScrolledEvent = () => sv.Scrolled -= ScrollViewScrolled; void ScrollViewScrolled(object sender, ScrolledEventArgs e) => - // ScrollY is reported in content coordinates (native ContentOffset plus the - // adjusted inset), but OnScrolled works in native offsets, which rest at - // -headerHeight because SetHeaderContentInset puts the header in ContentInset. - // Convert back, the same way the CollectionView branch below does. - OnScrolled((nfloat)sv.ScrollY - (ScrollView?.AdjustedContentInset.Top ?? 0)); + // Use the event only for timing and read the offset from the native view. + // OnScrolled works in native offsets (which rest at -headerHeight, since + // SetHeaderContentInset carries the header in ContentInset), while the + // value ScrollY reports depends on which renderer is in play — the default + // handler publishes content coordinates, the compatibility renderer raw + // ContentOffset — so converting from it would be right for one and wrong + // for the other. + OnScrolled(ScrollView?.ContentOffset.Y ?? 0); } #pragma warning disable CS0618 // Type or member is obsolete else if (Content is CollectionView cv) diff --git a/src/Controls/src/Core/ScrollView/ScrollView.cs b/src/Controls/src/Core/ScrollView/ScrollView.cs index e727a873416f..6b2d22b1830e 100644 --- a/src/Controls/src/Core/ScrollView/ScrollView.cs +++ b/src/Controls/src/Core/ScrollView/ScrollView.cs @@ -41,11 +41,25 @@ public Rect LayoutAreaOverride public event EventHandler ScrollToRequested; ScrollToRequestedEventArgs _pendingScrollToRequested; - bool _elementScrollDispatchQueued; private protected override void OnHandlerChangedCore() { base.OnHandlerChangedCore(); + + if (Handler is null) + { + // The handler went away with a request still queued, so nothing will ever + // dispatch it. Release the caller rather than leaving its task pending + // forever; Core does the same for its own pending request on disconnect. + if (_pendingScrollToRequested is not null) + { + _pendingScrollToRequested = null; + SendScrollFinished(); + } + + return; + } + DispatchPendingScrollToRequest(); } @@ -68,17 +82,11 @@ void DispatchPendingScrollToRequest() } // Those callbacks run while the pass that produced the sizes is still arranging - // children, so resolve on the next tick, once positions are final. - if (!_elementScrollDispatchQueued) - { - _elementScrollDispatchQueued = true; - Dispatcher.Dispatch(() => - { - _elementScrollDispatchQueued = false; - SendPendingScrollToRequest(); - }); - } - + // children, so resolve on the next tick, once positions are final. Posting on + // every retry is deliberate: SendPendingScrollToRequest is a no-op once the + // request has been sent or superseded, so a dropped callback cannot wedge the + // request the way an "already queued" flag would. + Dispatcher.Dispatch(SendPendingScrollToRequest); return; } @@ -493,6 +501,11 @@ void OnScrollToRequested(ScrollToRequestedEventArgs e) } else { + // This request supersedes anything still queued: a deferred element request + // whose dispatch is already scheduled must not run afterwards and restore the + // older target (latest request wins). + _pendingScrollToRequested = null; + Handler.Invoke(nameof(IScrollView.RequestScrollTo), ConvertRequestMode(e).ToRequest()); } } diff --git a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs index 5d0b58727ca8..7cab10d8029f 100644 --- a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs @@ -314,6 +314,28 @@ public void TestBackToBackBiDirectionalScroll() Assert.Equal(2, y100Count); } + [Fact] + public async Task DeferredElementScrollCompletesWhenTheHandlerGoesAway() + { + var item = new View(); + var scrollView = new ScrollView { Content = new StackLayout { Children = { item } } }; + + // No handler yet, so the request is held. An element target also cannot be resolved + // until layout has run, so it stays held even once a handler attaches. + var task = scrollView.ScrollToAsync(item, ScrollToPosition.Center, false); + Assert.False(task.IsCompleted); + + scrollView.Handler = Substitute.For(); + Assert.False(task.IsCompleted); + + // The handler goes away before layout ever happens, so nothing will dispatch the + // request; the awaiting caller must still be released rather than hanging forever. + scrollView.Handler = null; + + await task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(task.IsCompleted); + } + void AssertInvalidated(IViewHandler handler) { handler.Received().Invoke(Arg.Is(nameof(IView.InvalidateMeasure)), Arg.Any()); From ab129ab1db0b98ef8beecafe451d31a78afb25dc Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Thu, 6 Aug 2026 20:02:05 +0200 Subject: [PATCH 07/10] Close the no-content hang and make two fixtures discriminate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deferred element request no longer waits on ContentSize when there is no Content: nothing subscribes ContentSizeChanged in that case, so the guard would never clear and ScrollToAsync(element, ...) would hang its caller forever. ContentSize is only required when there is content to arrange. Two test-fidelity gaps from the review round: The Never inset mode was exercised with SafeAreaEdges.None, which is the one Never configuration where MauiScrollView bakes zero safe-area padding into ContentSize — so ScrollableContentSize returning ContentSize unchanged was trivially correct and the branch went untested. Added a SafeAreaEdges.All case, which also resolves to Never but with non-zero padding, which is what that helper actually reasons about. The Shell flyout fixture only asserted the returned-to-top state, which is also what a header that never moved reports (_headerOffset starts at 0), so it would have passed even if the Scrolled subscription went dead. It now also asserts, mid-run, that the header moved up while scrolled. Verified on the iOS simulator: 8/8 UI tests pass. Co-Authored-By: Claude Fable 5 --- .../src/Core/ScrollView/ScrollView.cs | 5 ++- .../TestCases.HostApp/Issues/Issue36801.cs | 9 ++++-- .../ShellFlyoutHeaderScrollViewContent.cs | 32 +++++++++++++++++++ .../Tests/Issues/Issue36801.cs | 3 ++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/Controls/src/Core/ScrollView/ScrollView.cs b/src/Controls/src/Core/ScrollView/ScrollView.cs index 6b2d22b1830e..0d18a06711b0 100644 --- a/src/Controls/src/Core/ScrollView/ScrollView.cs +++ b/src/Controls/src/Core/ScrollView/ScrollView.cs @@ -76,7 +76,10 @@ void DispatchPendingScrollToRequest() // element's position inside the arranged content. Before the first layout pass // Width/Height are still -1 and every content coordinate is 0, so the request // has to wait; OnSizeAllocated and ContentSizeChanged retry it. - if (Width < 0 || Height < 0 || ContentSize.IsZero) + // ContentSize only matters when there is content to arrange — with no Content + // nothing ever raises ContentSizeChanged, so waiting on it would hang the + // caller's task forever. + if (Width < 0 || Height < 0 || (Content is not null && ContentSize.IsZero)) { return; } diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs index 8d1a96b52335..ebfc199728d9 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs @@ -72,6 +72,11 @@ public Issue36801() var modeNoneButton = new Button { Text = "Mode None", AutomationId = "ModeNoneButton" }; modeNoneButton.Clicked += (sender, e) => SetMode(SafeAreaEdges.None); + // SafeAreaEdges.All also resolves to Never, but unlike None it makes MauiScrollView + // bake the safe area into ContentSize — the case ScrollableContentSize reasons about + var modeAllButton = new Button { Text = "Mode All", AutomationId = "ModeAllButton" }; + modeAllButton.Clicked += (sender, e) => SetMode(SafeAreaEdges.All); + var modeContainerButton = new Button { Text = "Mode Container", AutomationId = "ModeContainerButton" }; // SafeAreaEdges.Container is internal, so build the same value from the public enum. // SafeAreaEdges.All would not do: it is mapped to Never, not Always. @@ -87,7 +92,7 @@ public Issue36801() VerticalOptions = LayoutOptions.Start, Children = { - modeDefaultButton, modeNoneButton, modeContainerButton, + modeDefaultButton, modeNoneButton, modeAllButton, modeContainerButton, scrollToEndButton, scrollToTopButton, scrollToProbeButton, _endResultLabel, _topResultLabel, _elementResultLabel, _deferredResultLabel } @@ -161,7 +166,7 @@ string CheckResolvedMode(UIKit.UIScrollView nativeScrollView, string kind) var edges = _scrollView.SafeAreaEdges; var expected = edges.Equals(new SafeAreaEdges(SafeAreaRegions.Container)) ? UIKit.UIScrollViewContentInsetAdjustmentBehavior.Always : - edges.Equals(SafeAreaEdges.None) ? UIKit.UIScrollViewContentInsetAdjustmentBehavior.Never : + edges.Equals(SafeAreaEdges.None) || edges.Equals(SafeAreaEdges.All) ? UIKit.UIScrollViewContentInsetAdjustmentBehavior.Never : UIKit.UIScrollViewContentInsetAdjustmentBehavior.Automatic; return nativeScrollView.ContentInsetAdjustmentBehavior == expected diff --git a/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs b/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs index 944cc52d3a3e..23ffff4e1549 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/ShellFlyoutHeaderScrollViewContent.cs @@ -70,12 +70,44 @@ async Task RunAsync() // direct native call that also runs during initial layout. await _flyoutScroll.ScrollToAsync(0, 200, animated: false); await Task.Delay(150); + + // Assert the scrolled-away state too. The returned-to-top state alone is also what a + // header that never moved would report (_headerOffset starts at 0), so without this + // the test would pass even if the Scrolled wiring went dead entirely. + var scrolledAway = EvaluateHeaderScrolledAway(); + if (!scrolledAway.StartsWith("Success", StringComparison.Ordinal)) + { + _resultLabel.Text = scrolledAway; + return; + } + await _flyoutScroll.ScrollToAsync(0, 0, animated: false); await Task.Delay(150); _resultLabel.Text = EvaluateHeaderPosition(); } + // With FlyoutHeaderBehavior.Scroll the header must move up as the content scrolls down, + // which only happens if the ScrollView -> OnScrolled path actually ran + string EvaluateHeaderScrolledAway() + { +#if IOS || MACCATALYST + if (_headerGrid.Handler?.PlatformView is not UIKit.UIView headerView || + headerView.Superview is not UIKit.UIView headerContainer) + { + return "Fail: header views unavailable"; + } + + double headerY = headerContainer.Frame.Y; + + return headerY < 0 + ? $"Success: scrolled headerY={headerY:F1}" + : $"Fail: header did not move up while scrolled (headerY={headerY:F1})"; +#else + return "Skipped: not applicable on this platform"; +#endif + } + string EvaluateHeaderPosition() { #if IOS || MACCATALYST diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs index ee05d3383a49..09eaf4188ec9 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs @@ -74,6 +74,9 @@ public void ScrollToElementEndLandsInsideVisibleViewport() [Category(UITestCategories.ScrollView)] [TestCase("ModeDefaultButton", "Automatic")] [TestCase("ModeNoneButton", "Never")] + // Also resolves to Never, but bakes the safe area into ContentSize, which None does not — + // so it is the case that actually exercises ScrollableContentSize's Never reasoning + [TestCase("ModeAllButton", "Never")] [TestCase("ModeContainerButton", "Always")] public void ScrollToExtremesInEachInsetMode(string modeButton, string expectedMode) { From b618395d3eda3f294f76fef815b0d1cb59680f8d Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Fri, 7 Aug 2026 09:41:11 +0200 Subject: [PATCH 08/10] Address expert review: baked-inset accounting, gate sentinel, tolerances The deferred-element gate now waits on "content not yet arranged" (the -1 frame sentinel) instead of "arranged to nothing": a 0x0-arranged content is a settled state with no further callbacks, so gating on the size could hang the caller's task, while dispatching just clamps to the origin. Element targets now account for the safe area MauiScrollView bakes into the content (SafeAreaEdges.All and friends map to Never, where AdjustedContentInset never reports the obscured region): IScrollViewportProvider grows a ContentCoordinateInsets member, ViewportInsets reports the total obscured insets, and GetScrollPositionForElement shifts targets back by the baked part that element coordinates already include. The Shell flyout scroll callback skips instead of feeding 0 when the native scroll view is gone - in that convention 0 means "scrolled past the header", not neutral. The already-at-target check compares at device-pixel resolution so a sub-pixel residual against the fractional inset-derived target cannot issue an animated no-op that never raises ScrollAnimationEnded. Unit tests now cover the dispatch success path (the original awaited task completes on replay), the supersede rule, the zero-arranged dispatch, and the viewport/origin-shift math; the per-mode UI test matrix gains element-mode End assertions, with the SafeAreaEdges.All oracle folding in the view-level safe area the platform baked into the content. Co-Authored-By: Claude Fable 5 --- .../Shell/iOS/ShellFlyoutLayoutManager.cs | 11 +- .../src/Core/ScrollView/ScrollView.cs | 32 ++-- .../Core.UnitTests/ScrollViewUnitTests.cs | 147 ++++++++++++++++++ .../TestCases.HostApp/Issues/Issue36801.cs | 11 +- .../Tests/Issues/Issue36801.cs | 25 +++ .../ScrollView/IScrollViewportProvider.cs | 13 +- .../ScrollView/ScrollViewHandler.iOS.cs | 35 ++++- .../Platform/iOS/CoreGraphicsExtensions.cs | 5 + src/Core/src/Platform/iOS/MauiScrollView.cs | 21 +++ 9 files changed, 281 insertions(+), 19 deletions(-) diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs index 8d05e29a48d1..9353766ea612 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellFlyoutLayoutManager.cs @@ -77,15 +77,20 @@ public void SetCustomContent(View content) { sv.Scrolled += ScrollViewScrolled; removeScrolledEvent = () => sv.Scrolled -= ScrollViewScrolled; - void ScrollViewScrolled(object sender, ScrolledEventArgs e) => + void ScrollViewScrolled(object sender, ScrolledEventArgs e) + { // Use the event only for timing and read the offset from the native view. // OnScrolled works in native offsets (which rest at -headerHeight, since // SetHeaderContentInset carries the header in ContentInset), while the // value ScrollY reports depends on which renderer is in play — the default // handler publishes content coordinates, the compatibility renderer raw // ContentOffset — so converting from it would be right for one and wrong - // for the other. - OnScrolled(ScrollView?.ContentOffset.Y ?? 0); + // for the other. With no native view there is no usable offset: in this + // convention 0 is not neutral but "scrolled past the header", so skip + // rather than collapse the header. + if (ScrollView is { } nativeScrollView) + OnScrolled(nativeScrollView.ContentOffset.Y); + } } #pragma warning disable CS0618 // Type or member is obsolete else if (Content is CollectionView cv) diff --git a/src/Controls/src/Core/ScrollView/ScrollView.cs b/src/Controls/src/Core/ScrollView/ScrollView.cs index 0d18a06711b0..024a25fe4699 100644 --- a/src/Controls/src/Core/ScrollView/ScrollView.cs +++ b/src/Controls/src/Core/ScrollView/ScrollView.cs @@ -74,12 +74,13 @@ void DispatchPendingScrollToRequest() { // An element target is resolved against this ScrollView's geometry and the // element's position inside the arranged content. Before the first layout pass - // Width/Height are still -1 and every content coordinate is 0, so the request - // has to wait; OnSizeAllocated and ContentSizeChanged retry it. - // ContentSize only matters when there is content to arrange — with no Content - // nothing ever raises ContentSizeChanged, so waiting on it would hang the - // caller's task forever. - if (Width < 0 || Height < 0 || (Content is not null && ContentSize.IsZero)) + // Width/Height are still -1 (the never-arranged sentinel, for the content too), + // so the request has to wait; OnSizeAllocated and ContentSizeChanged retry it. + // The content check must be "not yet arranged" rather than "arranged to nothing": + // content can legitimately arrange to a zero size (a collapsed container), and + // that raises no further callbacks — gating on the size would hang the caller's + // task forever, while dispatching just clamps the target to the origin. + if (Width < 0 || Height < 0 || Content is { Width: < 0 } or { Height: < 0 }) { return; } @@ -126,26 +127,32 @@ public Point GetScrollPositionForElement(VisualElement item, ScrollToPosition po // insets (safe area, ContentInset) obscure part of the frame and (0,0) in scroll // coordinates is the inset rest position. Compute element targets against the effective // viewport so End/Center/MakeVisible land the element fully inside the visible region. + // Part of those insets can be baked into the content itself (the platform arranged the + // content inside safe-area-inset bounds): element coordinates already include that + // padding, so targets shift back by it — the platform-inset part is instead + // compensated when the request is translated to a native offset. var viewportInsets = GetVisibleViewportInsets(); + var contentInsets = GetContentCoordinateInsets(); double viewportWidth = Math.Max(0, Width - viewportInsets.HorizontalThickness); double viewportHeight = Math.Max(0, Height - viewportInsets.VerticalThickness); if (position == ScrollToPosition.MakeVisible) { - var scrollBounds = new Rect(ScrollX, ScrollY, viewportWidth, viewportHeight); + // In content coordinates the visible window starts past the baked padding + var scrollBounds = new Rect(ScrollX + contentInsets.Left, ScrollY + contentInsets.Top, viewportWidth, viewportHeight); var itemBounds = new Rect(x, y, item.Width, item.Height); if (scrollBounds.Contains(itemBounds)) return new Point(ScrollX, ScrollY); switch (Orientation) { case ScrollOrientation.Vertical: - position = y > ScrollY ? ScrollToPosition.End : ScrollToPosition.Start; + position = y > scrollBounds.Y ? ScrollToPosition.End : ScrollToPosition.Start; break; case ScrollOrientation.Horizontal: - position = x > ScrollX ? ScrollToPosition.End : ScrollToPosition.Start; + position = x > scrollBounds.X ? ScrollToPosition.End : ScrollToPosition.Start; break; case ScrollOrientation.Both: - position = x > ScrollX || y > ScrollY ? ScrollToPosition.End : ScrollToPosition.Start; + position = x > scrollBounds.X || y > scrollBounds.Y ? ScrollToPosition.End : ScrollToPosition.Start; break; } } @@ -160,7 +167,7 @@ public Point GetScrollPositionForElement(VisualElement item, ScrollToPosition po x = x - viewportWidth + item.Width; break; } - return new Point(x, y); + return new Point(x - contentInsets.Left, y - contentInsets.Top); } // The scrollable viewport can be smaller than the frame: on iOS the adjusted content @@ -169,6 +176,9 @@ public Point GetScrollPositionForElement(VisualElement item, ScrollToPosition po Thickness GetVisibleViewportInsets() => (Handler as IScrollViewportProvider)?.ViewportInsets ?? default; + Thickness GetContentCoordinateInsets() => + (Handler as IScrollViewportProvider)?.ContentCoordinateInsets ?? default; + /// /// Sends the scroll finished notification. /// diff --git a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs index 7cab10d8029f..6339dd8c51ac 100644 --- a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs @@ -336,6 +336,153 @@ public async Task DeferredElementScrollCompletesWhenTheHandlerGoesAway() Assert.True(task.IsCompleted); } + [Fact] + public void DeferredElementScrollDispatchesOnceContentIsArranged() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + var task = scrollView.ScrollToAsync(item, ScrollToPosition.Start, false); + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + // No geometry yet, so the request must still be held + Assert.Empty(handler.ScrollToRequests); + + item.Layout(new Graphics.Rect(0, 450, 100, 50)); + layout.Layout(new Graphics.Rect(0, 0, 100, 1000)); + scrollView.Layout(new Graphics.Rect(0, 0, 100, 100)); + + // The element target resolves against the arranged position, not the zeros it had + // when the request was queued + var request = Assert.Single(handler.ScrollToRequests); + Assert.Equal(450, request.VerticalOffset); + + // The replay must complete the task the original caller is still awaiting, not a + // fresh completion source + Assert.False(task.IsCompleted); + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + + [Fact] + public void DirectRequestSupersedesDeferredElementRequest() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + _ = scrollView.ScrollToAsync(item, ScrollToPosition.Start, false); + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + // A direct request while the element request is still held must win + var task = scrollView.ScrollToAsync(0, 100, false); + var request = Assert.Single(handler.ScrollToRequests); + Assert.Equal(100, request.VerticalOffset); + + // Layout completing later must not dispatch the stale element target on top of it + item.Layout(new Graphics.Rect(0, 450, 100, 50)); + layout.Layout(new Graphics.Rect(0, 0, 100, 1000)); + scrollView.Layout(new Graphics.Rect(0, 0, 100, 100)); + + Assert.Single(handler.ScrollToRequests); + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + + [Fact] + public void DeferredElementScrollCompletesWhenContentArrangesToZero() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + var task = scrollView.ScrollToAsync(item, ScrollToPosition.Start, false); + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + // Everything arranges to zero (a collapsed container). "Arranged to nothing" is a + // settled state: the request must dispatch and clamp instead of waiting for a + // content size that will never become non-zero. + item.Layout(new Graphics.Rect(0, 0, 0, 0)); + layout.Layout(new Graphics.Rect(0, 0, 0, 0)); + scrollView.Layout(new Graphics.Rect(0, 0, 0, 0)); + + Assert.Single(handler.ScrollToRequests); + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + + [Fact] + public void ElementTargetsAccountForViewportAndContentCoordinateInsets() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + // Total obscured insets shrink the viewport; the content-coordinate part is padding + // the platform baked into the content, which element positions already include + var handler = new ViewportProviderHandlerStub + { + ViewportInsets = new Thickness(0, 70, 0, 50), + ContentCoordinateInsets = new Thickness(0, 10, 0, 10), + }; + scrollView.Handler = handler; + + item.Layout(new Graphics.Rect(0, 450, 100, 50)); + layout.Layout(new Graphics.Rect(0, 0, 100, 1000)); + scrollView.Layout(new Graphics.Rect(0, 0, 100, 300)); + + // Start aligns the element with the visible viewport top: the 10 of baked padding in + // its coordinates must be shifted back out + Assert.Equal(440, scrollView.GetScrollPositionForElement(item, ScrollToPosition.Start).Y); + + // End: 450 - (300 - 70 - 50) + 50 = 320, shifted by the baked 10 + Assert.Equal(310, scrollView.GetScrollPositionForElement(item, ScrollToPosition.End).Y); + + // Center: 450 - 180 / 2 + 25 = 385, shifted by the baked 10 + Assert.Equal(375, scrollView.GetScrollPositionForElement(item, ScrollToPosition.Center).Y); + + // The element is below the visible window, so MakeVisible resolves to End + Assert.Equal(310, scrollView.GetScrollPositionForElement(item, ScrollToPosition.MakeVisible).Y); + } + + // IScrollViewportProvider is internal to Core, which NSubstitute cannot proxy, so the + // viewport contract is stubbed by hand here. + class ViewportProviderHandlerStub : IViewHandler, Microsoft.Maui.Handlers.IScrollViewportProvider + { + public System.Collections.Generic.List ScrollToRequests { get; } = new(); + + public Thickness ViewportInsets { get; set; } + public Thickness ContentCoordinateInsets { get; set; } + public void NotifyInsetsChanged() { } + + public bool HasContainer { get; set; } + public object ContainerView => null; + public IView VirtualView { get; private set; } + IElement IElementHandler.VirtualView => VirtualView; + public object PlatformView => null; + public IMauiContext MauiContext => null; + + public Graphics.Size GetDesiredSize(double widthConstraint, double heightConstraint) => Graphics.Size.Zero; + public void PlatformArrange(Graphics.Rect frame) { } + public void SetMauiContext(IMauiContext mauiContext) { } + public void SetVirtualView(IElement view) => VirtualView = (IView)view; + public void UpdateValue(string property) { } + public void DisconnectHandler() { } + + public void Invoke(string command, object args = null) + { + if (command == nameof(IScrollView.RequestScrollTo) && args is ScrollToRequest request) + ScrollToRequests.Add(request); + } + } + void AssertInvalidated(IViewHandler handler) { handler.Received().Invoke(Arg.Is(nameof(IView.InvalidateMeasure)), Arg.Any()); diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs index ebfc199728d9..632010939786 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs @@ -257,13 +257,20 @@ string EvaluateElementEnd() // Independent geometric oracle: after ScrollToAsync(probe, End) the probe's bottom edge // must sit exactly at the bottom of the unobscured viewport, in window coordinates. + // In the mode where MauiScrollView applies the safe area itself (SafeAreaEdges.All maps + // to Never with the safe area baked into the content) the obscured bottom never appears + // in AdjustedContentInset; the view-level SafeAreaInsets is what the platform view baked + // in, so it obscures the viewport all the same. + double bakedBottom = _scrollView.SafeAreaEdges.Equals(SafeAreaEdges.All) + ? (double)nativeScrollView.SafeAreaInsets.Bottom + : 0; var probeInWindow = probeView.ConvertRectToView(probeView.Bounds, null); var scrollInWindow = nativeScrollView.ConvertRectToView(nativeScrollView.Bounds, null); - double visibleBottom = (double)(scrollInWindow.Bottom - adjustedInset.Bottom); + double visibleBottom = (double)(scrollInWindow.Bottom - adjustedInset.Bottom) - bakedBottom; double actual = (double)probeInWindow.Bottom; return Math.Abs(actual - visibleBottom) <= 1.5 - ? $"Success (element): bottom={actual:F1}" + ? $"Success (element): mode={nativeScrollView.ContentInsetAdjustmentBehavior} bottom={actual:F1}" : $"Fail (element): actual={actual:F1} expected={visibleBottom:F1}"; #else return "Skipped (element): not applicable on this platform"; diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs index 09eaf4188ec9..72a0151d8407 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs @@ -94,5 +94,30 @@ public void ScrollToExtremesInEachInsetMode(string modeButton, string expectedMo var topText = App.FindElement("TopResultLabel").GetText(); Assert.That(topSuccess, Is.True, $"[{expectedMode}] scroll to top did not land on the rest position: {topText}"); } + + // Element targets resolve against the effective viewport, and each inset mode obscures it + // differently: Automatic/Always through AdjustedContentInset, SafeAreaEdges.All by baking + // the safe area into the content where AdjustedContentInset never reports it. The page's + // oracle measures the probe's bottom edge against the unobscured viewport bottom in window + // coordinates, so the mode where MAUI itself obscures the viewport is proven too. + [Test] + [Category(UITestCategories.ScrollView)] + [TestCase("ModeDefaultButton", "Automatic")] + [TestCase("ModeNoneButton", "Never")] + // Also resolves to Never, but bakes the safe area into the content — the case where the + // viewport shrink comes from MAUI's own arrange instead of a UIKit inset + [TestCase("ModeAllButton", "Never")] + [TestCase("ModeContainerButton", "Always")] + public void ScrollToElementEndInEachInsetMode(string modeButton, string expectedMode) + { + App.WaitForElement(modeButton); + App.Tap(modeButton); + + App.Tap("ScrollToProbeButton"); + var elementSuccess = App.WaitForTextToBePresentInElement("ElementResultLabel", "Success", timeout: TimeSpan.FromSeconds(10)); + var elementText = App.FindElement("ElementResultLabel").GetText(); + Assert.That(elementSuccess, Is.True, $"[{expectedMode}] ScrollToAsync(element, End) did not align the element with the visible viewport bottom: {elementText}"); + Assert.That(elementText, Does.Contain($"mode={expectedMode}"), $"[{expectedMode}] resolved to a different inset mode: {elementText}"); + } } #endif diff --git a/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs b/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs index 4bcd19c31c96..616c211709d5 100644 --- a/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs +++ b/src/Core/src/Handlers/ScrollView/IScrollViewportProvider.cs @@ -14,10 +14,21 @@ namespace Microsoft.Maui.Handlers internal interface IScrollViewportProvider { /// - /// The insets obscuring the scrollable viewport, in cross-platform units. + /// The total insets obscuring the scrollable viewport, in cross-platform units: + /// the platform-applied content insets plus any safe area the platform view baked + /// into the content itself (see ). /// Thickness ViewportInsets { get; } + /// + /// The portion of the platform view baked into the + /// content as padding instead of applying it as a platform inset. Element positions + /// read from the content tree already include it, so element-relative targets must + /// shift by it; platform insets instead sit outside the content coordinate space and + /// are compensated when the request is translated to a native offset. + /// + Thickness ContentCoordinateInsets { get; } + /// /// Notifies the handler that the platform view's insets changed. The reported scroll /// offsets are derived from them, and insets can change without the offset moving — diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs index 06825c278db6..dd6de8fb6daa 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs @@ -22,11 +22,35 @@ Thickness IScrollViewportProvider.ViewportInsets return default; } + // The safe area MauiScrollView bakes into the content obscures the viewport just + // like a UIKit inset but never appears in AdjustedContentInset (issue #36801) var inset = platformView.AdjustedContentInset; - return new Thickness(inset.Left, inset.Top, inset.Right, inset.Bottom); + var baked = GetSafeAreaBakedIntoContent(platformView); + return new Thickness( + inset.Left + baked.Left, + inset.Top + baked.Top, + inset.Right + baked.Right, + inset.Bottom + baked.Bottom); } } + Thickness IScrollViewportProvider.ContentCoordinateInsets + { + get + { + if (PlatformView is not { } platformView) + { + return default; + } + + var baked = GetSafeAreaBakedIntoContent(platformView); + return new Thickness(baked.Left, baked.Top, baked.Right, baked.Bottom); + } + } + + static SafeAreaPadding GetSafeAreaBakedIntoContent(UIScrollView platformView) => + (platformView as MauiScrollView)?.SafeAreaBakedIntoContent ?? SafeAreaPadding.Empty; + protected override UIScrollView CreatePlatformView() { return new MauiScrollView(); @@ -136,7 +160,14 @@ public static void MapRequestScrollTo(IScrollViewHandler handler, IScrollView sc } var target = GetTargetContentOffset(uiScrollView, request); - bool alreadyAtTarget = uiScrollView.ContentOffset == target; + + // Compare at device-pixel resolution: UIKit rounds resting offsets to physical + // pixels while the inset-derived target is fractional, so an exact comparison can + // miss by a sub-pixel amount — and an animated SetContentOffset for a sub-pixel + // delta may never raise ScrollAnimationEnded, leaving the caller's task pending + // forever (there is no timeout anywhere in that chain). + var pixelTolerance = 1 / (uiScrollView.Window?.Screen ?? UIScreen.MainScreen).Scale; + bool alreadyAtTarget = uiScrollView.ContentOffset.IsCloseTo(target, pixelTolerance); if (!alreadyAtTarget) { diff --git a/src/Core/src/Platform/iOS/CoreGraphicsExtensions.cs b/src/Core/src/Platform/iOS/CoreGraphicsExtensions.cs index dcd48d60b43d..c6026d5f3fe2 100644 --- a/src/Core/src/Platform/iOS/CoreGraphicsExtensions.cs +++ b/src/Core/src/Platform/iOS/CoreGraphicsExtensions.cs @@ -38,5 +38,10 @@ public static bool IsCloseTo(this CGSize size0, CGSize size1, nfloat tolerance) var diff = size0 - size1; return Math.Abs(diff.Width) < tolerance && Math.Abs(diff.Height) < tolerance; } + + internal static bool IsCloseTo(this CGPoint point0, CGPoint point1, nfloat tolerance) + { + return Math.Abs(point0.X - point1.X) < tolerance && Math.Abs(point0.Y - point1.Y) < tolerance; + } } } diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs index eeb8cf03c578..bae4e219783f 100644 --- a/src/Core/src/Platform/iOS/MauiScrollView.cs +++ b/src/Core/src/Platform/iOS/MauiScrollView.cs @@ -453,6 +453,27 @@ internal CGSize ScrollableContentSize } } + /// + /// The safe area baked into the content's coordinate + /// space: when it applies the safe area while UIKit is not compensating through + /// , the content is arranged inside + /// safe-area-inset bounds, so element positions carry the padding and the trailing + /// padding still obscures the viewport without ever appearing in the adjusted inset. + /// + /// + /// Mirrors the arrange-side branch exactly: bounds are inset only while + /// _appliesSafeAreaAdjustments, and the inset origin is kept only when UIKit + /// contributes nothing (, + /// or a zero system inset). In the remaining case () + /// the content is re-based at the origin and UIKit's inset owns the compensation, which + /// already reasons about (issue #36801). + /// + internal SafeAreaPadding SafeAreaBakedIntoContent => + _appliesSafeAreaAdjustments && + (SystemAdjustedContentInset == UIEdgeInsets.Zero || ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Never) + ? _safeArea + : SafeAreaPadding.Empty; + UIEdgeInsets SystemAdjustedContentInset { get From 2a26469d8fdfd94d22d280afae97f0edb8a405aa Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Sat, 8 Aug 2026 00:54:26 +0200 Subject: [PATCH 09/10] Measure the scrollable extent from the arranged rect; keep Scrolled quiet on inset-only changes Two changes from the latest review round: ScrollableContentSize no longer reconstructs the extent from ContentSize with ContentInsetAdjustmentBehavior branching. CrossPlatformArrange now records the rect it actually placed the content into (origin + arranged size, before the ContentSize padding/inflation adjustments), and the extent is measured from that rect's trailing edge plus the trailing baked safe-area padding. This is identical in the stable Never/Always cases and correct in the Automatic corners the reconstruction missed: a non-zero safe-area origin with a zero system inset, and the artificial Bounds+1 inflation that keeps UIKit in scrollable mode. The recorded rect (not the content subview's Frame) is used so ScrollView padding and content trailing margins stay part of the extent. The orientation clamp from LayoutSubviews is mirrored so a non-scrolling axis does not become reachable. Inset-only changes now refresh ScrollX/ScrollY through the internal IScrollOffsetReceiver path instead of the HorizontalOffset/ VerticalOffset setters: the values and their bindings stay current, but no Scrolled event is manufactured for a change no scroll produced, so scroll-driven consumers (SwipeView.OnParentScrolled, hide-on-scroll logic) cannot react to a bar hiding or a rotation as if the user scrolled. Real scrolls keep the existing notification path. Verified: ScrollViewUnitTests 21/21, and the Issue36801 + Issue36801DeferredElement + ShellFlyoutHeaderScrollViewContent iOS UI suites 12/12 on the simulator. Co-Authored-By: Claude Fable 5 --- .../src/Core/ScrollView/ScrollView.cs | 10 ++- .../Core.UnitTests/ScrollViewUnitTests.cs | 22 ++++++ src/Core/src/Core/IScrollOffsetReceiver.cs | 21 +++++ .../ScrollView/ScrollViewHandler.iOS.cs | 26 ++++++- src/Core/src/Platform/iOS/MauiScrollView.cs | 76 ++++++++++++++----- 5 files changed, 129 insertions(+), 26 deletions(-) create mode 100644 src/Core/src/Core/IScrollOffsetReceiver.cs diff --git a/src/Controls/src/Core/ScrollView/ScrollView.cs b/src/Controls/src/Core/ScrollView/ScrollView.cs index 024a25fe4699..29d794415acb 100644 --- a/src/Controls/src/Core/ScrollView/ScrollView.cs +++ b/src/Controls/src/Core/ScrollView/ScrollView.cs @@ -16,7 +16,7 @@ namespace Microsoft.Maui.Controls [ContentProperty(nameof(Content))] [DebuggerDisplay("{GetDebuggerDisplay(), nq}")] #pragma warning disable CS0618 // Type or member is obsolete - public partial class ScrollView : Compatibility.Layout, ILayout, ILayoutController, IPaddingElement, IView, IVisualTreeElement, IInputTransparentContainerElement, IScrollViewController, IElementConfiguration, IFlowDirectionController, IScrollView, IContentView, ISafeAreaElement, ISafeAreaView2 + public partial class ScrollView : Compatibility.Layout, ILayout, ILayoutController, IPaddingElement, IView, IVisualTreeElement, IInputTransparentContainerElement, IScrollViewController, IElementConfiguration, IFlowDirectionController, IScrollView, IScrollOffsetReceiver, IContentView, ISafeAreaElement, ISafeAreaView2 #pragma warning restore CS0618 // Type or member is obsolete { #region IScrollViewController @@ -563,6 +563,14 @@ double IScrollView.VerticalOffset } } + void IScrollOffsetReceiver.UpdateScrollOffsets(double horizontalOffset, double verticalOffset) + { + // The reported offsets moved because the platform insets did, not because anything + // scrolled: keep ScrollX/ScrollY (and their bindings) current without raising Scrolled + ScrollX = horizontalOffset; + ScrollY = verticalOffset; + } + void IScrollView.RequestScrollTo(double horizontalOffset, double verticalOffset, bool instant) { var request = new ScrollToRequest(horizontalOffset, verticalOffset, instant); diff --git a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs index 6339dd8c51ac..e38625482bd3 100644 --- a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs @@ -452,6 +452,28 @@ public void ElementTargetsAccountForViewportAndContentCoordinateInsets() Assert.Equal(310, scrollView.GetScrollPositionForElement(item, ScrollToPosition.MakeVisible).Y); } + [Fact] + public void InsetRefreshUpdatesOffsetsWithoutRaisingScrolled() + { + var scrollView = new ScrollView(); + var scrolledCount = 0; + scrollView.Scrolled += (_, _) => scrolledCount++; + + // An inset-only change moves the derived offsets without any scroll: the values + // (and their bindings) must refresh, but no Scrolled event may be manufactured + ((IScrollOffsetReceiver)scrollView).UpdateScrollOffsets(5, 40); + + Assert.Equal(5, scrollView.ScrollX); + Assert.Equal(40, scrollView.ScrollY); + Assert.Equal(0, scrolledCount); + + // An actual scroll notification still raises Scrolled + ((IScrollView)scrollView).VerticalOffset = 60; + + Assert.Equal(60, scrollView.ScrollY); + Assert.Equal(1, scrolledCount); + } + // IScrollViewportProvider is internal to Core, which NSubstitute cannot proxy, so the // viewport contract is stubbed by hand here. class ViewportProviderHandlerStub : IViewHandler, Microsoft.Maui.Handlers.IScrollViewportProvider diff --git a/src/Core/src/Core/IScrollOffsetReceiver.cs b/src/Core/src/Core/IScrollOffsetReceiver.cs new file mode 100644 index 000000000000..5738596c8170 --- /dev/null +++ b/src/Core/src/Core/IScrollOffsetReceiver.cs @@ -0,0 +1,21 @@ +namespace Microsoft.Maui +{ + /// + /// Implemented by scroll views whose reported offsets are derived from platform insets: + /// when the insets change without any scrolling, the derived offsets move too, and the + /// handler refreshes them through this path so the update is not mistaken for a scroll. + /// + /// + /// The / + /// setters signal "the view scrolled" and views typically raise their scrolled notification + /// from them; an inset-only change must keep the offset values current without + /// manufacturing that notification (issue #36801). + /// + internal interface IScrollOffsetReceiver + { + /// + /// Updates the reported scroll offsets without treating the change as a scroll. + /// + void UpdateScrollOffsets(double horizontalOffset, double verticalOffset); + } +} diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs index dd6de8fb6daa..95324db441f9 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs @@ -324,9 +324,23 @@ void Scrolled(object? sender, EventArgs e) void IScrollViewportProvider.NotifyInsetsChanged() { - if (PlatformView is { } platformView) + if (PlatformView is not { } platformView || VirtualView is not { } virtualView) { - PublishScrollOffsets(VirtualView, platformView); + return; + } + + var (horizontalOffset, verticalOffset) = GetContentCoordinateOffsets(platformView); + + // Nothing scrolled — the derived offsets moved because the inset did — so refresh + // the values without letting the view raise a scrolled notification for it + if (virtualView is IScrollOffsetReceiver receiver) + { + receiver.UpdateScrollOffsets(horizontalOffset, verticalOffset); + } + else + { + virtualView.HorizontalOffset = horizontalOffset; + virtualView.VerticalOffset = verticalOffset; } } @@ -340,9 +354,13 @@ static void PublishScrollOffsets(IScrollView? virtualView, UIScrollView platform return; } + (virtualView.HorizontalOffset, virtualView.VerticalOffset) = GetContentCoordinateOffsets(platformView); + } + + static (double HorizontalOffset, double VerticalOffset) GetContentCoordinateOffsets(UIScrollView platformView) + { var adjustedInset = platformView.AdjustedContentInset; - virtualView.HorizontalOffset = platformView.ContentOffset.X + adjustedInset.Left; - virtualView.VerticalOffset = platformView.ContentOffset.Y + adjustedInset.Top; + return (platformView.ContentOffset.X + adjustedInset.Left, platformView.ContentOffset.Y + adjustedInset.Top); } } } diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs index bae4e219783f..eb73fb73194d 100644 --- a/src/Core/src/Platform/iOS/MauiScrollView.cs +++ b/src/Core/src/Platform/iOS/MauiScrollView.cs @@ -420,39 +420,67 @@ bool ValidateSafeArea() } /// - /// The content extent to clamp scrolling against: - /// minus any safe-area padding that baked into it while - /// UIKit is *also* compensating for the same safe area through - /// . + /// The content extent to clamp scrolling against: the trailing edge of the rect + /// actually arranged the content into, plus any + /// trailing safe-area padding it baked into the content coordinate space + /// (see ). /// /// - /// That double counting only happens with . - /// With the padding stands in - /// for an inset UIKit does not apply, and with - /// no padding is added at - /// all — so in both of those the content size is already the scrollable extent. Kept here so - /// the rule lives with the arrange logic that produces it rather than being restated by the - /// handler (issue #36801). + /// Measured from the recorded arrange rather than reconstructed from + /// , because that value is not authoritative: + /// depending on the inset mode the arrange pass pads it with a safe area UIKit is also + /// compensating through , inflates it to + /// keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was + /// arranged at. The arranged rect is correct in every mode (issue #36801). /// internal CGSize ScrollableContentSize { get { - var contentSize = ContentSize; + var arranged = _arrangedContentRect; - if (ContentInsetAdjustmentBehavior != UIScrollViewContentInsetAdjustmentBehavior.Always) + // Content has not been arranged through CrossPlatformArrange (e.g. ContentSize + // was mapped directly), so the content size is the only extent available + if (arranged == CGRect.Empty) { - return contentSize; + return ContentSize; } - var duplicated = SystemAdjustedContentInset; + var baked = SafeAreaBakedIntoContent; + var width = (double)arranged.Right + baked.Right; + var height = (double)arranged.Bottom + baked.Bottom; - return new CGSize( - contentSize.Width - duplicated.Left - duplicated.Right, - contentSize.Height - duplicated.Top - duplicated.Bottom); + // Mirror the orientation clamp LayoutSubviews applies to ContentSize: an axis the + // ScrollView doesn't scroll must not become reachable just because the arranged + // content overflows it + if (View is IScrollView scrollView) + { + var frameSize = Bounds.Size; + var orientation = scrollView.Orientation; + + if (orientation is ScrollOrientation.Vertical && width > frameSize.Width) + { + width = frameSize.Width; + } + + if (orientation is ScrollOrientation.Horizontal && height > frameSize.Height) + { + height = frameSize.Height; + } + } + + return new CGSize(width, height); } } + /// + /// The rect the last placed the content into: + /// origin is the (possibly safe-area-inset) position the content was arranged at and + /// size is the arranged content size before any + /// padding or scrollable-mode inflation is applied. + /// + CGRect _arrangedContentRect; + /// /// The safe area baked into the content's coordinate /// space: when it applies the safe area while UIKit is not compensating through @@ -465,8 +493,8 @@ internal CGSize ScrollableContentSize /// _appliesSafeAreaAdjustments, and the inset origin is kept only when UIKit /// contributes nothing (, /// or a zero system inset). In the remaining case () - /// the content is re-based at the origin and UIKit's inset owns the compensation, which - /// already reasons about (issue #36801). + /// the content is re-based at the origin and UIKit's inset owns the compensation, so the + /// arranged rect measures naturally excludes it (issue #36801). /// internal SafeAreaPadding SafeAreaBakedIntoContent => _appliesSafeAreaAdjustments && @@ -507,12 +535,13 @@ Size CrossPlatformArrange(CGRect bounds) Size contentSize; - + CGPoint contentOrigin; double width; double height; if (SystemAdjustedContentInset == UIEdgeInsets.Zero || ContentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentBehavior.Never) { contentSize = CrossPlatformLayout?.CrossPlatformArrange(bounds.ToRectangle()) ?? Size.Zero; + contentOrigin = bounds.Location; width = contentSize.Width; height = contentSize.Height; @@ -520,11 +549,16 @@ Size CrossPlatformArrange(CGRect bounds) else { contentSize = CrossPlatformLayout?.CrossPlatformArrange(new Rect(new Point(), bounds.Size.ToSize())) ?? Size.Zero; + contentOrigin = CGPoint.Empty; width = contentSize.Width; height = contentSize.Height; } + // Record where the content was actually arranged, before the ContentSize adjustments + // below: ScrollableContentSize measures the scrollable extent from this rect + _arrangedContentRect = new CGRect(contentOrigin, contentSize.ToCGSize()); + // When using ContentInsetAdjustmentBehavior.Automatic, UIKit dynamically decides whether to apply // safe area insets to the scroll view (via AdjustedContentInset) or to push them into the child view's SafeAreaInsets. From e3f386a81145ac1ee6eb23be7b0980269c860172 Mon Sep 17 00:00:00 2001 From: Alberto Aldegheri Date: Sun, 9 Aug 2026 22:53:28 +0200 Subject: [PATCH 10/10] Gate element requests on arranged geometry; single event raise per request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes from the latest review round, plus fixture updates for the merged landscape-notch rework: Element-mode requests now park behind the arranged-geometry gate even when the handler is already attached (ScrollToAsync(element, ...) from OnAppearing used to resolve against the -1 never-arranged sentinels and scroll nowhere); OnSizeAllocated/ContentSizeChanged retry them and the geometry-ready path stays synchronous. The ScrollToRequested replay raise is now limited to requests parked before the handler attached: compatibility renderers subscribe at attach and perform the scroll from the event, so that replay is load-bearing — but a request parked with the handler present (waiting for geometry) already notified its subscribers, and re-raising would double-notify. MauiScrollView._arrangedContentRect is nullable so "never arranged" is distinct from "arranged to nothing": an empty ScrollView now clamps to the rest position instead of the padded/inflated ContentSize. The Issue36801 fixture expected SafeAreaEdges.Default to resolve to Automatic; since the landscape-notch fix (#35533) Default on a vertical scroll view resolves to Never (Automatic remains only for horizontal scroll views), so the resolved-mode oracle and test parameters follow. Verified on the merged base: ScrollViewUnitTests 23/23, Issue36801 + Issue36801DeferredElement + ShellFlyoutHeaderScrollViewContent iOS UI suites 12/12 on the simulator. Co-Authored-By: Claude Fable 5 --- .../src/Core/ScrollView/ScrollView.cs | 46 +++++++++++---- .../Core.UnitTests/ScrollViewUnitTests.cs | 58 +++++++++++++++++++ .../TestCases.HostApp/Issues/Issue36801.cs | 5 +- .../Tests/Issues/Issue36801.cs | 19 +++--- src/Core/src/Platform/iOS/MauiScrollView.cs | 12 ++-- 5 files changed, 113 insertions(+), 27 deletions(-) diff --git a/src/Controls/src/Core/ScrollView/ScrollView.cs b/src/Controls/src/Core/ScrollView/ScrollView.cs index 29d794415acb..2dd1ed0159ee 100644 --- a/src/Controls/src/Core/ScrollView/ScrollView.cs +++ b/src/Controls/src/Core/ScrollView/ScrollView.cs @@ -41,6 +41,7 @@ public Rect LayoutAreaOverride public event EventHandler ScrollToRequested; ScrollToRequestedEventArgs _pendingScrollToRequested; + bool _replayPendingScrollToRequestedEvent; private protected override void OnHandlerChangedCore() { @@ -72,16 +73,9 @@ void DispatchPendingScrollToRequest() if (pending.Mode == ScrollToMode.Element) { - // An element target is resolved against this ScrollView's geometry and the - // element's position inside the arranged content. Before the first layout pass - // Width/Height are still -1 (the never-arranged sentinel, for the content too), - // so the request has to wait; OnSizeAllocated and ContentSizeChanged retry it. - // The content check must be "not yet arranged" rather than "arranged to nothing": - // content can legitimately arrange to a zero size (a collapsed container), and - // that raises no further callbacks — gating on the size would hang the caller's - // task forever, while dispatching just clamps the target to the origin. - if (Width < 0 || Height < 0 || Content is { Width: < 0 } or { Height: < 0 }) + if (!IsElementTargetGeometryReady()) { + // The request has to wait; OnSizeAllocated and ContentSizeChanged retry it. return; } @@ -97,6 +91,16 @@ void DispatchPendingScrollToRequest() SendPendingScrollToRequest(); } + // An element target is resolved against this ScrollView's geometry and the element's + // position inside the arranged content. Before the first layout pass Width/Height are + // still -1 (the never-arranged sentinel, for the content too), so a target computed + // then is garbage. The content check must be "not yet arranged" rather than "arranged + // to nothing": content can legitimately arrange to a zero size (a collapsed container), + // and that raises no further callbacks — gating on the size would hang the caller's + // task forever, while dispatching just clamps the target to the origin. + bool IsElementTargetGeometryReady() => + Width >= 0 && Height >= 0 && Content is not ({ Width: < 0 } or { Height: < 0 }); + void SendPendingScrollToRequest() { if (Handler is null || _pendingScrollToRequested is not { } pending) @@ -107,8 +111,18 @@ void SendPendingScrollToRequest() _pendingScrollToRequested = null; // Replay without going through OnScrollToRequested: that would reset the - // completion source and orphan the task the original caller is still awaiting - ScrollToRequested?.Invoke(this, pending); + // completion source and orphan the task the original caller is still awaiting. + // The event is re-raised only for requests parked before the handler attached: + // compatibility renderers subscribe to ScrollToRequested at attach and perform the + // scroll from it, so they would otherwise never see the request. A request parked + // with the handler present (waiting for element geometry) already notified its + // subscribers at request time, and re-raising would double-notify them. + if (_replayPendingScrollToRequestedEvent) + { + _replayPendingScrollToRequestedEvent = false; + ScrollToRequested?.Invoke(this, pending); + } + Handler.Invoke(nameof(IScrollView.RequestScrollTo), ConvertRequestMode(pending).ToRequest()); } @@ -511,6 +525,16 @@ void OnScrollToRequested(ScrollToRequestedEventArgs e) if (Handler is null) { _pendingScrollToRequested = e; + _replayPendingScrollToRequestedEvent = true; + } + else if (e.Mode == ScrollToMode.Element && !IsElementTargetGeometryReady()) + { + // The handler exists but layout has not run yet (e.g. ScrollToAsync from + // OnAppearing): resolving the element target now would compute against the -1 + // never-arranged sentinels. Park it for the layout callbacks instead — the + // subscribers were already notified above, so the replay must not re-raise. + _pendingScrollToRequested = e; + _replayPendingScrollToRequestedEvent = false; } else { diff --git a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs index e38625482bd3..9c1e8e23f72f 100644 --- a/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs +++ b/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs @@ -452,6 +452,64 @@ public void ElementTargetsAccountForViewportAndContentCoordinateInsets() Assert.Equal(310, scrollView.GetScrollPositionForElement(item, ScrollToPosition.MakeVisible).Y); } + [Fact] + public void ElementRequestWithHandlerAttachedWaitsForArrange() + { + var item = new View(); + var layout = new StackLayout { Children = { item } }; + var scrollView = new ScrollView { Content = layout }; + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + var eventCount = 0; + ((IScrollViewController)scrollView).ScrollToRequested += (_, _) => eventCount++; + + // The handler exists but nothing is arranged yet (Width/Height are the -1 + // never-arranged sentinels): resolving the element target now would compute + // against garbage geometry, so the request must wait for layout + var task = scrollView.ScrollToAsync(item, ScrollToPosition.Center, false); + Assert.Empty(handler.ScrollToRequests); + Assert.Equal(1, eventCount); + + item.Layout(new Graphics.Rect(0, 450, 100, 50)); + layout.Layout(new Graphics.Rect(0, 0, 100, 1000)); + scrollView.Layout(new Graphics.Rect(0, 0, 100, 100)); + + // Dispatched once with a target computed from real geometry: 450 - 100/2 + 50/2. + // The public event must not be raised a second time on the replay — subscribers + // were already notified when the request was made. + var request = Assert.Single(handler.ScrollToRequests); + Assert.Equal(425, request.VerticalOffset); + Assert.Equal(1, eventCount); + + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + + [Fact] + public void DeferredRequestReplaysEventForSubscribersAttachedWithTheHandler() + { + var scrollView = new ScrollView(); + var task = scrollView.ScrollToAsync(10, 20, false); + + // Compatibility renderers subscribe to ScrollToRequested when they attach — + // after the request above was parked — and perform the scroll from the event, + // so the replay must re-raise it for them + var eventCount = 0; + ((IScrollViewController)scrollView).ScrollToRequested += (_, _) => eventCount++; + + var handler = new ViewportProviderHandlerStub(); + scrollView.Handler = handler; + + Assert.Equal(1, eventCount); + var request = Assert.Single(handler.ScrollToRequests); + Assert.Equal(20, request.VerticalOffset); + + scrollView.SendScrollFinished(); + Assert.True(task.IsCompleted); + } + [Fact] public void InsetRefreshUpdatesOffsetsWithoutRaisingScrolled() { diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs index 632010939786..0a3ee58aa362 100644 --- a/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue36801.cs @@ -167,7 +167,10 @@ string CheckResolvedMode(UIKit.UIScrollView nativeScrollView, string kind) var expected = edges.Equals(new SafeAreaEdges(SafeAreaRegions.Container)) ? UIKit.UIScrollViewContentInsetAdjustmentBehavior.Always : edges.Equals(SafeAreaEdges.None) || edges.Equals(SafeAreaEdges.All) ? UIKit.UIScrollViewContentInsetAdjustmentBehavior.Never : - UIKit.UIScrollViewContentInsetAdjustmentBehavior.Automatic; + // Default on a vertical scroll view resolves to Never since the landscape-notch + // fix (#35533): MAUI owns all edges there, and Automatic remains in use only for + // horizontal scroll views + UIKit.UIScrollViewContentInsetAdjustmentBehavior.Never; return nativeScrollView.ContentInsetAdjustmentBehavior == expected ? null diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs index 72a0151d8407..0c072373ceca 100644 --- a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue36801.cs @@ -66,16 +66,17 @@ public void ScrollToElementEndLandsInsideVisibleViewport() "Probe label should be fully visible after ScrollToAsync(element, End)"); } - // The clamp has a mode-specific branch and the three ContentInsetAdjustmentBehavior modes - // bake the safe area into MauiScrollView.ContentSize differently, so each is exercised. - // The page asserts the resolved native behavior, so a mode that silently drifted fails - // instead of quietly testing a different branch. + // The reachable ContentInsetAdjustmentBehavior modes bake the safe area into the content + // differently, so each is exercised. The page asserts the resolved native behavior, so a + // mode that silently drifted fails instead of quietly testing a different branch. + // Default on a vertical scroll view resolves to Never since the landscape-notch fix + // (#35533); Automatic remains in use only for horizontal scroll views. [Test] [Category(UITestCategories.ScrollView)] - [TestCase("ModeDefaultButton", "Automatic")] + [TestCase("ModeDefaultButton", "Never")] [TestCase("ModeNoneButton", "Never")] - // Also resolves to Never, but bakes the safe area into ContentSize, which None does not — - // so it is the case that actually exercises ScrollableContentSize's Never reasoning + // Also resolves to Never, but bakes the safe area into the arranged content, which None + // does not — so it is the case that actually exercises the measured extent's baked padding [TestCase("ModeAllButton", "Never")] [TestCase("ModeContainerButton", "Always")] public void ScrollToExtremesInEachInsetMode(string modeButton, string expectedMode) @@ -96,13 +97,13 @@ public void ScrollToExtremesInEachInsetMode(string modeButton, string expectedMo } // Element targets resolve against the effective viewport, and each inset mode obscures it - // differently: Automatic/Always through AdjustedContentInset, SafeAreaEdges.All by baking + // differently: Always through AdjustedContentInset, Default/SafeAreaEdges.All by baking // the safe area into the content where AdjustedContentInset never reports it. The page's // oracle measures the probe's bottom edge against the unobscured viewport bottom in window // coordinates, so the mode where MAUI itself obscures the viewport is proven too. [Test] [Category(UITestCategories.ScrollView)] - [TestCase("ModeDefaultButton", "Automatic")] + [TestCase("ModeDefaultButton", "Never")] [TestCase("ModeNoneButton", "Never")] // Also resolves to Never, but bakes the safe area into the content — the case where the // viewport shrink comes from MAUI's own arrange instead of a UIKit inset diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs index 3940393559d3..a1559e3df002 100644 --- a/src/Core/src/Platform/iOS/MauiScrollView.cs +++ b/src/Core/src/Platform/iOS/MauiScrollView.cs @@ -467,11 +467,11 @@ internal CGSize ScrollableContentSize { get { - var arranged = _arrangedContentRect; - // Content has not been arranged through CrossPlatformArrange (e.g. ContentSize - // was mapped directly), so the content size is the only extent available - if (arranged == CGRect.Empty) + // was mapped directly), so the content size is the only extent available. The + // nullable keeps this distinct from content legitimately arranged to a zero + // size at the origin, whose extent really is empty. + if (_arrangedContentRect is not { } arranged) { return ContentSize; } @@ -507,9 +507,9 @@ internal CGSize ScrollableContentSize /// The rect the last placed the content into: /// origin is the (possibly safe-area-inset) position the content was arranged at and /// size is the arranged content size before any - /// padding or scrollable-mode inflation is applied. + /// padding or scrollable-mode inflation is applied. Null until the first arrange runs. /// - CGRect _arrangedContentRect; + CGRect? _arrangedContentRect; /// /// The safe area baked into the content's coordinate