Repository navigation
[release/10.0.1xx-sr11] [iOS] ScrollView: Keep the content offset when the scroll view is resized by its parent layout - #38602
Merged
kubaflo merged 1 commit intoSep 16, 2026
Conversation
…ized by its parent layout (#38599) ### Description of Change On iOS, a `ScrollView` whose frame is resized by its parent layout in the same pass in which its content becomes taller than the viewport ends up **scrolled by half the resize delta**, so the page appears with the first item partially hidden. The common shape is a bound page: a `Grid` (`Auto,*,Auto`) with the `ScrollView` in the star row, whose content *and* an Auto-row footer arrive from the ViewModel after the page is already on screen — the footer grows, the star row shrinks, the content grows, and the ScrollView lands at e.g. offset 18 instead of 0 (content `Padding` 8 + item `Margin` 8 + stroke 2 scrolled away). **Cause.** `ViewHandlerExtensions.PlatformArrangeHandler` arranges views by setting `Center` and then `Bounds` (rather than `Frame`, so non-identity transforms keep working). On a `UIScrollView`, `Bounds.Origin` *is* the content offset, and UIKit re-derives it inside `setBounds:` when the size changes — it keeps the content under the view's center stable, i.e. it scrolls by half the delta. A KVO observer on `contentOffset` captured the move exactly there: ``` offset->17.0 bounds=(0.0,17.0,691.0) contentSize=725.0 at UIKit.UIView.set_Bounds(CGRect) at Microsoft.Maui.ViewHandlerExtensions.PlatformArrangeHandler(IViewHandler, Rect) ViewHandlerExtensions.iOS.cs:158 at Microsoft.Maui.Handlers.ViewHandler`2.PlatformArrange(Rect) at Microsoft.Maui.Controls.ScrollView.ArrangeOverride(Rect) at Microsoft.Maui.Layouts.GridLayoutManager.ArrangeChildren(Rect) at Microsoft.Maui.Platform.MauiView.LayoutSubviews() ``` The arrange defect itself is old: the fixture fails identically on the `10.0.90` (SR9), `10.0.100` and `10.0.101` sources, so it is not a regression of the recent ScrollView changes. What changed in SR10 is *reachability*: #32267 made `MauiScrollView.InvalidateMeasure(isPropagating: true)` return `true`, so a content change inside a ScrollView now propagates measure invalidation to its ancestors (needed so a content-sized ScrollView shrinks when children are removed). Before SR10 the ScrollView was a propagation barrier and a bound page's content arrival never re-laid-out the page; since SR10 it does — and a page host that applies insets or chrome on every layout pass (a custom scaffold, say) can then resize the ScrollView in the very pass its content grows. That is why an app can be clean on SR9 and hit this on SR10/SR11 with no code change of its own; the fixture forces the resize explicitly (a bound footer growing with the content) so it reproduces on every version. **Fix.** Arranging a scroll view must never scroll it. `ScrollViewHandler.PlatformArrange` (iOS) — the call through which a parent layout resizes the scroll view — now remembers the content offset before the base arrange and puts it back if UIKit moved it, clamped to the range the new size currently allows. The clamp is the same inset-aware range `ScrollToAsync` already uses (`ClampToScrollableRange`, shared with `GetTargetContentOffset`: rest is `-AdjustedContentInset`, the maximum extends past the arranged extent by the trailing inset). Kept in the ScrollView handler rather than the generic arrange helper or a `Bounds` override on `MauiScrollView`: it is scroll-view-specific, only the arrange path is affected, and UIKit implements `contentOffset` through `setBounds:` so an override there would sit on every scroll frame. A view the user had scrolled keeps its position across a resize, exactly as UIKit's own `setFrame:` semantics would. Side effect worth knowing: in the affected pass `Scrolled` is raised twice (UIKit's spurious move, then the restore) — the final state is correct, whereas before the fix the spurious one was raised and *kept*. No public API change beyond the `PlatformArrange` override itself. ### Workaround for affected versions The override is public, so an app (or a library hosting pages) can apply the same correction without waiting for the fix, by registering a handler subclass on iOS/MacCatalyst: ```csharp #if IOS || MACCATALYST using CoreGraphics; using Microsoft.Maui.Graphics; using Microsoft.Maui.Handlers; public class OffsetPreservingScrollViewHandler : ScrollViewHandler { public override void PlatformArrange(Rect rect) { var scrollView = PlatformView; var offset = scrollView.ContentOffset; base.PlatformArrange(rect); if (scrollView.ContentOffset == offset) return; // Restore the offset the arrange found, kept inside the range the new size allows var inset = scrollView.AdjustedContentInset; var bounds = scrollView.Bounds; var contentSize = scrollView.ContentSize; var minX = -(double)inset.Left; var minY = -(double)inset.Top; var maxX = Math.Max(minX, contentSize.Width + inset.Right - bounds.Width); var maxY = Math.Max(minY, contentSize.Height + inset.Bottom - bounds.Height); scrollView.ContentOffset = new CGPoint( Math.Clamp((double)offset.X, minX, maxX), Math.Clamp((double)offset.Y, minY, maxY)); } } #endif ``` ```csharp // MauiProgram.cs builder.ConfigureMauiHandlers(handlers => { #if IOS || MACCATALYST handlers.AddHandler<ScrollView, OffsetPreservingScrollViewHandler>(); #endif }); ``` Notes: it clamps against `ContentSize` rather than the internal arranged extent, which is equivalent for this purpose; it replaces the ScrollView handler globally, so combine it with any custom ScrollView handler the app already registers; and once on a fixed version it becomes a harmless no-op that can be removed. Merely deferring the content population to a later tick than other layout changes also avoids the coincidence in plain layouts, but is not reliable under hosts that re-apply insets on every layout pass. ### Note on #32267 — the enabler, and why it deserves a different approach #32267 is what made this reachable from SR10 on, and we think its approach should be revisited on `main`, independently of this fix. **What it set out to do is legitimate.** A ScrollView *sized by its content* — a `StackLayout` child, an `Auto` row, `VerticalOptions=Start` — must make its parent re-measure when its content shrinks, or it keeps blank space at the bottom (#32221, #20586, #34536). **How it did it is the problem.** `MauiScrollView` was deliberately a measure-invalidation *barrier* (`InvalidateMeasure(isPropagating: true)` returned `false`; the surrounding code still says "Invalidation stops at `UIScrollView`s for performance reasons"), and it already had the targeted escape hatch for the content-sized case: `LayoutSubviews` calls `InvalidateAncestorsMeasures()` when `ContentSize` changes. #32267's diff is a single line that flips the barrier off unconditionally (`return true`), so **every** invalidation raised anywhere inside a ScrollView now walks up and re-measures the entire page, regardless of whether the ScrollView's size can even change. **Performance cost.** A ScrollView in a star row, in `Fill`, or with an explicit size is fully constrained by its parent: its ancestors can learn nothing from its content changing, yet since SR10 each such change — a bound list populating, a `Label` text update, every keystroke in an `Entry` or `Editor` inside the scroll view — invalidates and re-measures the page above it. The scroll view is the single most common container for content-heavy pages, so this is paid constantly, on the main thread, for no layout result. The old barrier existed precisely to avoid that. **Correctness cost.** Turning arbitrary content changes into page re-layouts is what creates the coincidence this PR fixes: a page host that applies insets or chrome on each layout pass now resizes the ScrollView in the very pass its content grows. The arrange defect predates SR10, but SR10 is what makes ordinary bound pages hit it. **What the fix should have been.** The reported root cause was that, on the first pass after removing children, `CrossPlatformMeasure` returned the ScrollView's current size, so `ContentSize` did not change and the existing `InvalidateAncestorsMeasures()` never fired. The proportionate fix addresses *that*: propagate only when the ScrollView's own measured size can actually change — i.e. when it is not size-constrained by its parent — and/or when its measured size differs from what the parent last measured. That keeps the content-sized case correct while leaving the barrier in place for every constrained ScrollView, which is the overwhelmingly common one. We would suggest reverting the unconditional `return true` in favour of such a condition as a follow-up on `main`; it is out of scope for a servicing fix, and this PR is deliberately independent of it — it fixes the arrange defect regardless of what triggers the resize. Separately, the XML doc on `IPlatformMeasureInvalidationController.InvalidateMeasure` in `MauiScrollView` says the return value means "stop propagating", while the walk in `ViewExtensions.InvalidateAncestorsMeasures` treats `true` as "keep propagating". The doc is inverted and worth a one-line correction — it makes changes like #32267 easy to misjudge. ### Issues Fixed Reported against 10.0.101 (page hosted in a custom scaffold with bound content). Reproduces with plain `NavigationPage` + `Grid`. ### Tests `ScrollViewInitialOffsetInGridRow` (iOS + MacCatalyst): a pushed, edge-to-edge page with a `Grid Auto,*,Auto`, the `ScrollView` in the star row, content `VerticalStackLayout Padding=0,8` whose first child is a `Border Margin=16,8`, in five cases — static vs. async content (content and a footer banner arriving together after appearing) × with vs. without a header row (the latter puts the ScrollView under the status bar, so the top inset is non-zero), plus a guard where the content is tall and laid out from the start and only the footer grows later: an ordinary resize of a laid-out scroll view does not scroll it (verified without the fix), and the restore must keep it that way. The page instruments the native `UIScrollView` (offset, adjusted/content/safe insets, content size, bounds, content frame, `ScrollY`) and, via KVO, the managed stack of the first offset move, so a failure names its cause. Oracles: native offset `== -AdjustedContentInset.Top` and `ScrollY == 0` once the content is laid out; and, Appium-side, the first item's top exactly 16 below the ScrollView's visible top. Results on this commit (iOS simulator, iPhone 11 Pro): - with the fix: **5/5**; `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` suites **12/12** - with the fix reverted (pristine sr11): `AsyncContentWithHeader` **fails** at `offset=18` (36pt footer growth, halved); the same case fails identically against the `10.0.90`, `10.0.100` and `10.0.101` sources for `src/Core/src` + `src/Controls/src/Core` - with the fix reverted and `ScrollEventProxy` + `NotifyInsetsChanged` disabled: still fails with the same `set_Bounds` stack, `ScrollY` left at 0 while the native offset is 18 — those paths only report the offset - `StaticContentResizedLater` passes with and without the fix (the guard) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Jakub Florkowski <kubaflo123@gmail.com>
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
kubaflo
approved these changes
Sep 16, 2026
This was referenced Oct 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backport of #38599 to release/10.0.1xx-sr11
/cc @kubaflo @albyrock87