Skip to content

[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 into
release/10.0.1xx-sr11from
backport/pr-38599-to-release/10.0.1xx-sr11
Sep 16, 2026
Merged

kubaflo merged 1 commit into
release/10.0.1xx-sr11from
backport/pr-38599-to-release/10.0.1xx-sr11

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Backport of #38599 to release/10.0.1xx-sr11

/cc @kubaflo @albyrock87

…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

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
There may be pipelines that require an authorized user to comment /azp run to run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants