Skip to content

[iOS] ScrollView: Keep the content offset when the scroll view is resized by its parent layout - #38599

Merged
kubaflo merged 13 commits into
dotnet:mainfrom
albyrock87:fix-sr11-scrollview-arrange-offset
Sep 16, 2026
Merged

kubaflo merged 13 commits into
dotnet:mainfrom
albyrock87:fix-sr11-scrollview-arrange-offset

Conversation

@albyrock87

@albyrock87 albyrock87 commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

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:

#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
// 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 UIScrollViews 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

kubaflo and others added 9 commits September 12, 2026 18:30
<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

## Summary

- preserve `PatchVersion` at `110`
- change `PreReleaseVersionLabel` from `ci.main` to `servicing`
- remove the `inflight/current` `ci.inflight` label override
- enable `StabilizePackageVersion` by default while preserving explicit
overrides

## Validation

Evaluated the properties with MSBuild for the default environment,
`BUILD_SOURCEBRANCH=refs/heads/inflight/current`, and an explicit
`StabilizePackageVersion=false` override. Both branch environments
resolve to patch 110 and the `servicing` label; stabilization defaults
to `true`, while the explicit `false` override remains honored.

## Scope

This PR only prepares stable SR11 package versioning on
`release/10.0.1xx-sr11`. It does not change `main`, resolve outstanding
regression or backport decisions, update BAR state, or trigger release
pipelines. Final release-head validation is still required after any
later backports.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ger accepts OS-locale decimal separator (dotnet#38483)

Backport of dotnet#38277 to release/10.0.1xx-sr11

/cc @kubaflo @HarishwaranVijayakumar

Co-authored-by: HarishwaranVijayakumar <harishwaran.vijayakumar@syncfusion.com>
Co-authored-by: Jakub Florkowski <kubaflo123@gmail.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 169298fa-95aa-47a4-ad1c-512dc3f97871
…n removing items from grouped CollectionView (dotnet#38482)

Backport of dotnet#38341 to release/10.0.1xx-sr11

/cc @kubaflo @Vignesh-SF3580

Co-authored-by: Vignesh-SF3580 <102575140+Vignesh-SF3580@users.noreply.github.com>
…essException when picking 2+ items (dotnet#38481)

Backport of dotnet#37879 to release/10.0.1xx-sr11

/cc @kubaflo @pierre-galaup

Co-authored-by: Pierre Galaup <pierregalaup@outlook.fr>
… remaining non-interactive after IsEnabled toggle (dotnet#38480)

Backport of dotnet#37849 to release/10.0.1xx-sr11

/cc @kubaflo @SyedAbdulAzeemSF4852

Co-authored-by: SyedAbdulAzeemSF4852 <syedabdulazeem.a@syncfusion.com>
…coverage (dotnet#38489)

> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

Backport of dotnet#37708 to `release/10.0.1xx-sr11` using a focused manual
fallback after the automated backport failed:
https://github.com/dotnet/maui/actions/runs/34705427768

Source squash commit: `90c55197d1925f99480482d51be7f53aae0d194c` (issue
dotnet#37691).

SR11 already inherits the runtime fix through
`7c523245e64fca92f376a76298ed6cfd263cad3e`, including
`ClearKeyboardState()` and the stronger
`InvalidateSafeAreaWithDescendants()` behavior. This fallback
intentionally preserves that runtime and adds only the two missing
regression test files from the source squash commit.

## Validation

- Both added files are byte-identical to their blobs in source commit
`90c55197d1925f99480482d51be7f53aae0d194c`.
- `src/Core/src/Platform/iOS/MauiView.cs` is unchanged.
- `eng/Versions.props` remains at `PatchVersion` 110.
- The equivalent targeted test previously passed on iPhone 17 / iOS 26.5
with all three cycles restoring the bottom marker to 874.
- A fresh SR11-targeted run was attempted on iPhone 17 / iOS 26.5, but
executed 0 tests because this worktree lacked bootstrapped MAUI build
tasks. The documented bootstrap was attempted and bounded; it did not
complete in time, so no new SR11-local pass result is claimed.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…rashWhenPageUnloaded UITests failure (dotnet#38565)

Backport of dotnet#38564 to release/10.0.1xx-sr11

/cc @kubaflo @TamilarasanSF4853

Co-authored-by: TamilarasanSF4853 <tamilarasan.velu@syncfusion.com>
Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…nViewRemovalsUseCurrentAdapterPositions (dotnet#38588)

<!-- Please let the below note in for people that find this PR -->
> [!NOTE]
> Are you waiting for the changes in this PR to be merged?
> It would be very helpful if you could [test the resulting
artifacts](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from
this PR and let us know in a comment if this change resolves your issue.
Thank you!

### Issue Details
The GroupedCollectionViewRemovalsUseCurrentAdapterPositions test is
failing on iOS when running with CollectionView1 (CV1). The test was
checked locally, and the crash can be reproduced with CV1. The same test
scenario works properly with CollectionView2 (CV2). The PR changes are
related to Android, while this test failure occurs only with iOS CV1.

This test was added in dotnet#38482

### Root Cause
The test scenario causes an existing crash with CollectionView1 (CV1) on
iOS. During the investigation, it was also found that the
GroupFooterTemplate used in the sample contributes to the CV1 crash. The
same scenario works correctly with CV2. The same behavior was reproduced
with MAUI 10.0.0 without the PR changes, confirming that the failure is
not related to this PR.

### Description of Change
Updated the test to run only with CollectionView2 (CV2). This avoids the
existing CV1-specific failure on iOS while continuing to validate the
intended grouped CollectionView item removal scenario.
…ized by its parent layout

A ScrollView whose frame is resized by its parent layout in the same
pass in which its content becomes taller than the viewport ended up
scrolled by half the resize delta, so a bound page (Grid Auto,*,Auto
with the ScrollView in the star row, content and an Auto-row footer
arriving together after the page is on screen) appeared with its first
item partially hidden — offset 18 for a 36pt footer.

The generic arrange sets Center and then Bounds 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,
keeping 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, under GridLayoutManager.ArrangeChildren. Pre-existing
(reproduced on the 10.0.100 and 10.0.101 sources), not a regression of
the recent ScrollView changes.

Arranging must never scroll. 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 inset-aware range ScrollToAsync already uses, factored
into ClampToScrollableRange and shared with GetTargetContentOffset.
Kept in the scroll view's handler rather than the generic arrange
helper (scroll-view-specific) or a Bounds override on MauiScrollView
(UIKit implements contentOffset through setBounds:, so that would sit
on every scroll frame).

ScrollViewInitialOffsetInGridRow (iOS/MacCatalyst) covers static vs.
async content × with vs. without a header row (the latter under the
status bar, non-zero top inset), instrumenting the native scroll view
and capturing the managed stack of the first offset move so a failure
names its cause. The async-with-header case fails without the fix
(offset 18) and passes with it; all four pass with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@albyrock87
albyrock87 deployed to copilot-pat-pool September 16, 2026 16:05 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 38599

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 38599"

@dotnet-policy-service dotnet-policy-service Bot added the community ✨ Community Contribution label Sep 16, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Hey there @@albyrock87! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Hey there @albyrock87! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@albyrock87
albyrock87 deployed to copilot-pat-pool September 16, 2026 16:05 — with GitHub Actions Active
@albyrock87
albyrock87 deployed to copilot-pat-pool September 16, 2026 16:08 — with GitHub Actions Active
@albyrock87
albyrock87 deployed to copilot-pat-pool September 16, 2026 16:10 — with GitHub Actions Active
@albyrock87
albyrock87 deployed to copilot-pat-pool September 16, 2026 16:11 — with GitHub Actions Active
@albyrock87
albyrock87 deployed to copilot-pat-pool September 16, 2026 16:11 — with GitHub Actions Active
@kubaflo

This comment has been minimized.

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Expert Review — 1 findings

See inline comments for details.

static Microsoft.Maui.Handlers.EntryHandler.MapBackground(Microsoft.Maui.Handlers.IEntryHandler! handler, Microsoft.Maui.IEntry! entry) -> void
static Microsoft.Maui.Handlers.DatePickerHandler.MapFlowDirection(Microsoft.Maui.Handlers.IDatePickerHandler! handler, Microsoft.Maui.IDatePicker! datePicker) -> void
override Microsoft.Maui.Handlers.EditorHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void
override Microsoft.Maui.Handlers.ScrollViewHandler.PlatformArrange(Microsoft.Maui.Graphics.Rect rect) -> void

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔍 AI-Generated Review (multi-model)

⚠️ Warning — Public API Surface: ScrollViewHandler.PlatformArrange(Rect) is introduced by ScrollViewHandler.iOS.cs, which compiles for both iOS and MacCatalyst, but only net-ios/PublicAPI.Unshipped.txt was updated. Concrete failure scenario: MacCatalyst API baselining reports the override as an untracked public API because net-maccatalyst/PublicAPI.Unshipped.txt is missing the matching entry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 73b8059 — the same single line appended to net-maccatalyst/PublicAPI.Unshipped.txt. Core.csproj now builds clean for net10.0-maccatalyst26.0 Release with the PublicAPI analyzer.

MauiBot

This comment was marked as outdated.

@MauiBot MauiBot added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-fix-win AI found a better alternative fix than the PR s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) and removed s/agent-review-in-progress AI review is currently running for this PR labels Sep 16, 2026
@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Sep 16, 2026
The override is compiled for Mac Catalyst too, so RS0016 fires there without
this entry (macOS Release lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kubaflo

This comment has been minimized.

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

AI Review Summary

@albyrock87 — new AI review results are available based on commit 73b8059.

Gate Passed Confidence Low Platform iOS


🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix

Gate Result: ✅ PASSED

Platform: IOS · Base: release/10.0.1xx-sr11 · Merge base: f28df38e

Test Without Fix (expect FAIL) With Fix (expect PASS)
🖥️ ScrollViewInitialOffsetInGridRow ScrollViewInitialOffsetInGridRow ✅ FAIL — 124s ✅ PASS — 102s
🔴 Without fix — 🖥️ ScrollViewInitialOffsetInGridRow: FAIL ✅ · 124s

Error-relevant lines (filtered from the build log):

     at Microsoft.Maui.TestCases.Tests.Issues.ScrollViewInitialOffsetInGridRow.AssertRestsAtTop(String pushButton) in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/ScrollViewInitialOffsetInGridRow.cs:line 60
   at Microsoft.Maui.TestCases.Tests.Issues.ScrollViewInitialOffsetInGridRow.AsyncContentWithHeader() in /_/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/ScrollViewInitialOffsetInGridRow.cs:line 21
🟢 With fix — 🖥️ ScrollViewInitialOffsetInGridRow: PASS ✅ · 102s

(no coded error found; showing last 1200 chars)

tentResizedLater [2 s]
>>>>> 9/16/2026 12:03:49 PM StaticContentUnderTheBar Start
>>>>> 9/16/2026 12:03:52 PM StaticContentUnderTheBar Stop
[PushNoHeaderButton] Success: offset=(0.0,-44.0) adjusted=(44.0,0.0,0.0,0.0) contentInset=(0.0,0.0) safe=(44.0,0.0) contentSize=3558.0 bounds=591.0 contentFrameY=0.0 behavior=Automatic scrollY=0.0 svFrame=(0.0,591.0) gridFrame=(0.0,812.0) pageH=812.0
  Passed StaticContentUnderTheBar [2 s]
>>>>> 9/16/2026 12:03:52 PM StaticContentWithHeader Start
>>>>> 9/16/2026 12:03:55 PM StaticContentWithHeader Stop
[PushButton] Success: offset=(0.0,0.0) adjusted=(0.0,0.0,0.0,0.0) contentInset=(0.0,0.0) safe=(0.0,0.0) contentSize=3558.0 bounds=547.0 contentFrameY=0.0 behavior=Automatic scrollY=0.0 svFrame=(44.0,547.0) gridFrame=(0.0,812.0) pageH=812.0
  Passed StaticContentWithHeader [3 s]
NUnit Adapter 4.5.0.0: Test execution complete
Results File: /Users/cloudtest/vss/_work/1/s/CustomAgentLogsTmp/UITests/TestResults/ScrollViewInitialOffsetInGridRow.trx
Test Run Successful.
Total tests: 5
     Passed: 5
 Total time: 31.5217 Seconds
>>> TRX_RESULT_FILE: /Users/cloudtest/vss/_work/1/s/CustomAgentLogsTmp/UITests/TestResults/ScrollViewInitialOffsetInGridRow.trx
📁 Fix files reverted (3 files)
  • src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs
  • src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt
  • src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt

📋 Pre-Flight — Context & Validation

Issue: No linked issue - app report against 10.0.101
PR: #38599 - [iOS] ScrollView: Keep the content offset when the scroll view is resized by its parent layout
Platforms Affected: iOS and MacCatalyst; required validation platform is iOS
Files Changed: 3 implementation/API-baseline, 2 UI test

Key Findings

  • When a parent resizes a UIScrollView, the generic arrange path assigns Center and then Bounds. UIKit may rederive Bounds.Origin during the size change, moving ContentOffset by half the resize delta and leaving initially loaded content partially hidden.
  • The PR overrides ScrollViewHandler.PlatformArrange, captures the pre-arrange native offset, calls the base arrange, then restores a clamped inset-aware offset if UIKit moved it. The same clamp is shared with GetTargetContentOffset.
  • The focused ScrollViewInitialOffsetInGridRow UI test covers static and asynchronously populated content, with and without a top inset, plus an already-laid-out resize guard.
  • The current three-commit PR includes matching PlatformArrange(Rect) entries in both iOS and MacCatalyst PublicAPI.Unshipped.txt files, resolving the only inline review finding on the earlier revision.
  • The Gate is already complete: the primary test failed without the PR fix and passed with it. Gate artifacts were not modified.

Code Review Summary

Verdict: SKIPPED
Confidence: N/A
Errors: N/A | Warnings: N/A | Suggestions: N/A

Dedicated expert review is deferred to STEP 5b by the execution contract. No expert-review agent was launched in STEP 5a.

Fix Candidates

# Source Approach Test Result Files Changed Notes
PR PR #38599 Preserve and inset-clamp ContentOffset around ScrollViewHandler.PlatformArrange ✅ PASSED (Gate) 5 files Original PR; alternative candidates must use a different mechanism

Try-Fix Test Contract

  1. Primary: pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform ios -TestFilter "ScrollViewInitialOffsetInGridRow"
  2. Mandatory regression after a passing primary test: pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform ios -TestFilter "Issue34666"

Each candidate receives one implementation/test pass and at most one focused correction/retest. A failed regression test makes the candidate Fail; an unavailable simulator or unsafe baseline makes it Blocked.


🔬 Code Review — Deep Analysis

Expert PR Evaluation — PR #38599

Independent Assessment

What this changes: The iOS/MacCatalyst ScrollViewHandler now snapshots the native ContentOffset around base.PlatformArrange, then restores that offset when UIKit changes it as a side effect of resizing Bounds. The restored offset is clamped to the inset-aware scrollable range. The existing ScrollTo path is refactored to use the same clamp helper.

Inferred motivation: A parent-driven resize must not implicitly scroll a ScrollView. UIKit can move UIScrollView.Bounds.Origin while MAUI's generic arrange path changes the bounds size, leaving content partially scrolled after a simultaneous content/layout update.

Expert Findings

No actionable inline findings were identified. The required inline-findings.json contains a valid empty JSON array.

Soundness

  • The correction is scoped to the iOS/MacCatalyst scroll-view arrange path rather than changing generic view arrangement.
  • The pre-arrange offset is restored only when UIKit changed it.
  • Clamping handles a resize that makes the old offset invalid and reuses the same inset-aware limits as ScrollTo.
  • MauiScrollView.ScrollableContentSize remains the source of the effective extent when safe-area padding is baked into content.
  • The public override is recorded in both the iOS and MacCatalyst PublicAPI.Unshipped.txt files.

Blast Radius and Failure-Mode Probing

  • All instances: The override runs for every iOS/MacCatalyst ScrollView, but it writes an offset only when the base arrange changed the native value.
  • Startup: No startup or static initialization path is added.
  • Shared state: No new static or cross-instance state is introduced.
  • Disconnect/reconnect: A missing PlatformView falls through to the base implementation; no subscriptions or retained lifecycle state are added.
  • Resize after user scrolling: The prior offset is retained when valid and clamped when the new viewport/content extent makes it invalid.
  • Safe-area/content inset: The clamp uses AdjustedContentInset, so the legal native rest and maximum offsets remain inset-aware.
  • Events: Restoring an offset can produce a second Scrolled notification after UIKit's spurious resize-induced move. This is an observable limitation, but the final state is correct and the submitted description discloses it; the reviewer did not classify it as blocking.

Regression Coverage

The new iOS UI coverage exercises static and delayed content, header/no-header safe-area shapes, and an already-laid-out scroll view resized later. The trusted Gate result supplied to this review is: tests fail without the fix and pass with it.

External Output Contract

Not applicable.

Verdict: LGTM

Confidence: low. The platform-specific implementation and targeted Gate evidence support the fix, but the expert reviewer could not independently retrieve required-check rows in its environment. No code defect, blocking error, or discussion item was found.


🛠️ Try-Fix — Analysis & Comparison

Aggregate Try-Fix Results

Fix Candidates

# Source Approach Test Result Files Changed Notes
1 gpt-5.3-codex Prevent UIKit recentering by retaining UIScrollView.Bounds.Origin during the resize rather than restoring ContentOffset afterward ⚠️ Blocked None Baseline script rejected pre-existing dirty .github/eng harness files before implementation
2 gpt-5.6-sol Defer publication of the taller native ContentSize until after the parent-driven bounds transition ⚠️ Blocked None Same baseline safety boundary; no allow-list was created
PR PR #38599 Capture, inset-clamp, and restore ContentOffset around ScrollViewHandler.PlatformArrange ✅ PASSED (Gate) 5 files Original PR

Candidate 1 — Blocked (gpt-5.3-codex)

Approach: The planned mechanism would replace recovery-after-movement with prevention: preserve the native bounds origin while resizing so UIKit cannot introduce the half-delta offset. It was not implemented.

Difference from PR: The PR lets the base arrange move ContentOffset and then repairs it. Candidate 1 intended to prevent that movement during the geometry update.

Diff: Empty; no source changes were made.

Primary test: Not run — baseline establishment failed before editing.

Mandatory regression: Not run — the primary test did not pass.

Result: Blocked

Failure analysis: pwsh .github/scripts/EstablishBrokenBaseline.ps1 exited with DIRTY WORKING DIRECTORY because of pre-existing modified .github and eng harness files. It created no baseline state, so the skill's safety boundary prohibited implementation and testing. No correction allowance was consumed.

Inline self-review: 0 findings against the empty diff.

Restoration: pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore completed with No baseline state found / Restored False; the PR source files remained unchanged.

Artifacts: CustomAgentLogsTmp/PRState/38599/PRAgent/try-fix/attempt-1 and CustomAgentLogsTmp/PRState/38599/PRAgent/try-fix-1/content.md

Candidate 2 — Blocked (gpt-5.6-sol)

Approach: Defer publishing the newly taller native ContentSize until UIKit has completed the parent-driven bounds transition, avoiding the simultaneous resize-plus-new-scroll-range condition. It was not implemented.

Difference from prior approaches: Unlike the PR, this does not capture, clamp, or restore ContentOffset. Unlike candidate 1, it does not preserve Bounds.Origin, replace base arrangement, or customize arrange geometry. It changes when UIKit learns that the content became scrollable.

Diff: Empty; no source changes were made.

Primary test: Not run — baseline establishment failed before editing.

Mandatory regression: Not run — the primary test did not pass.

Result: Blocked

Failure analysis: pwsh .github/scripts/EstablishBrokenBaseline.ps1 again exited with DIRTY WORKING DIRECTORY for the same 55 pre-existing .github and eng harness modifications. No .github/.baseline-state.json or RevertedFiles allow-list was created, so implementation and testing were prohibited. No correction allowance was consumed.

Inline self-review: 0 findings against the empty diff.

Restoration: pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore completed with No baseline state found / Restored False; the PR source files remained unchanged.

Artifacts: CustomAgentLogsTmp/PRState/38599/PRAgent/try-fix/attempt-2 and CustomAgentLogsTmp/PRState/38599/PRAgent/try-fix-2/content.md

Selection

Bounded attempts complete: Yes — both prescribed models were invoked once, sequentially. Cross-pollination and follow-up review were intentionally skipped.

Selected Fix: PR #38599 — it is the only implementation with empirical evidence: the trusted Gate proves the focused regression fails without it and passes with it. Neither alternative reached implementation, the primary test, or the mandatory Issue34666 regression, so both remain speculative and cannot outrank the PR.

Regression-test status for alternatives: Not run because neither candidate passed baseline establishment or the primary test. No alternative is marked Pass.


🏁 Report — Final Recommendation

✅ Final Recommendation: APPROVE

Winner: pr — the raw submitted PR fix.

The submitted fix is the only distinct implementation with complete empirical evidence: the trusted Gate proves the focused reproduction fails without the fix and all five cases pass with it, and the required Issue34666 overlap regression also passes. The single expert review found no actionable inline issue or blocking discussion item. pr-plus-reviewer contains no reviewer delta and is therefore behaviorally identical; the raw PR wins that tie because selecting the no-op derivative would require no actual change. Both STEP 5a alternatives were blocked before implementation or testing and remain speculative.

Candidate Comparison

Rank Candidate Implementation Focused reproduction Required regression Expert/reviewer result Decision
1 pr Capture ContentOffset around iOS/MacCatalyst PlatformArrange, then restore it within the inset-aware scrollable range; share that clamp with ScrollTo ✅ PASS: demonstrated failure without fix; 5/5 pass with fix ✅ Issue34666 PASS (1/1) LGTM; inline-findings.json is [] Winner
2 pr-plus-reviewer No-op relative to the immutable reviewer baseline because the expert supplied no actionable feedback ✅ Same source and inherited Gate evidence as pr; no redundant rerun ✅ Same source and inherited regression evidence as pr No reviewer patch; reviewer.patch is empty Sound but offers no improvement over pr
3 try-fix-1 Planned to preserve UIScrollView.Bounds.Origin during resize, preventing UIKit's recentering ⚠️ Not run ⚠️ Not run Blocked before any source change by baseline safety Cannot outrank a validated implementation
4 try-fix-2 Planned to defer publication of the taller native ContentSize until after the parent bounds transition ⚠️ Not run ⚠️ Not run Blocked before any source change by baseline safety Cannot outrank a validated implementation

No candidate failed the required regression after implementation; rather, both try-fix candidates never reached implementation or validation. They are ranked below the two evidence-equivalent passing candidates, consistent with the requirement that validated regression safety outrank failed or absent regression evidence.

Why the Submitted Fix Wins

  • It addresses the observed mutation at the narrowest appropriate layer: ScrollViewHandler.PlatformArrange on iOS/MacCatalyst.
  • It preserves a valid user scroll position and clamps only when a resize makes the old native offset unreachable.
  • It reuses the existing AdjustedContentInset-aware range, including MauiScrollView.ScrollableContentSize, instead of introducing a second range model.
  • It adds matching public API baselines for both TFMs compiled from the .iOS.cs source.
  • Its UI fixture covers the timing-dependent failure and adjacent static/resize/safe-area shapes.
  • The expert found no error, warning, or discussion item that justified changing the submitted code.

Evidence and Uncertainty

  • Gate: ✅ PASS — the regression is proven and fixed.
  • Overlap regression: ✅ PASS — Issue34666.
  • Expert inline findings: none.
  • Required GitHub check rows were unavailable to the expert review environment, so that external CI state remains unverified here. This does not change the comparative result or the trusted Gate evidence.
  • The two alternative mechanisms have no diff and no test evidence; their relative safety cannot be assessed beyond their design sketches.

No PR source changes are recommended.


🔗 Regression Cross-Reference

🔍 Regression Cross-Reference

⚠ Overlaps with prior bug-fix PRs — same files modified, but no exact line revert detected.

File Fix PR Fixed issue(s)
src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs #36628 #36501

🧪 Regression Tests to Verify

These tests were added by the overlapping fix PRs. Running them to verify no side-effect regressions:

Fix PR Type Test Filter
#36628 UITest Issue34666 Issue34666

🧪 Regression Test Results

✅ PASSED — 1 passed, 0 failed, 0 skipped

Fix PR Test Type Result
#36628 Issue34666 UITest ✅ PASSED

📱 UI Tests — ScrollView,ViewBaseTests

Detected UI test categories: ScrollView,ViewBaseTests

✅ Deep UI tests — 290 passed, 0 failed, 1 skipped across 2 categories on platform-pool agent (replaces in-process counts above).

🧪 UI Test Execution Results (deep, platform pool)

Category Tests Snapshot diffs
ScrollView 178/179 (1 skipped) ✓ —
ViewBaseTests 112/112 ✓ —
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs)

🧭 Next Steps — review latest findings

No alternative fix was selected for this run. Review the session findings and CI results before merging.

@MauiBot MauiBot added s/agent-approved AI agent recommends approval - PR fix is correct and optimal s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates and removed s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-win AI found a better alternative fix than the PR s/agent-review-in-progress AI review is currently running for this PR labels Sep 16, 2026
@kubaflo

kubaflo commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).

@kubaflo
kubaflo changed the base branch from release/10.0.1xx-sr11 to main September 16, 2026 22:06
kubaflo
kubaflo previously approved these changes Sep 16, 2026
Restore eng/Versions.props to main so PR dotnet#38599 contains only the ScrollView fix and its tests/API declarations.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@kubaflo
kubaflo merged commit 7f88468 into dotnet:main Sep 16, 2026
3 of 13 checks passed
@kubaflo

kubaflo commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

/backport to release/10.0.1xx-sr11

@github-actions

Copy link
Copy Markdown
Contributor

Started backporting to release/10.0.1xx-sr11 (link to workflow run)

@github-actions github-actions Bot added this to the .NET 10 SR12 milestone Sep 16, 2026
kubaflo added a commit that referenced this pull request Sep 16, 2026
…n the scroll view is resized by its parent layout (#38602)

Backport of #38599 to release/10.0.1xx-sr11

/cc @kubaflo @albyrock87

Co-authored-by: Alberto Aldegheri <albyrock87@gmail.com>
Co-authored-by: Jakub Florkowski <kubaflo123@gmail.com>
kubaflo added a commit that referenced this pull request Sep 17, 2026
…38621)

This PR addresses UI test failures in the main branch caused in this PR
#38599. And it updates the
`AssertRestsAtTop` test method to improve platform-specific handling for
iOS devices. The main change is that the calculation and assertion logic
for verifying the top position of the first item in the `ScrollView` is
now only applied when running on iOS, ensuring more accurate and
relevant test behavior for that platform.

**Platform-specific test logic:**

* In `ScrollViewInitialOffsetInGridRow.cs`, the calculation and
assertion for the first item's top position relative to the `ScrollView`
are now executed only for iOS devices, with updated comments clarifying
the logic and rationale for this conditional handling.

**Cause:** On Mac Catalyst, Appium element rectangles use desktop screen
coordinates, which cannot be reliably combined with UIKit’s
AdjustedContentInset. This caused false failures in the visual geometry
assertion.
**Fix:** Restrict the geometry assertion to iOS. Mac Catalyst continues
to validate the behavior using the native ContentOffset and managed
ScrollY checks reported by the HostApp.

Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
@sheiksyedm sheiksyedm modified the milestones: .NET 10 SR12, .NET 10 SR11 Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community ✨ Community Contribution s/agent-approved AI agent recommends approval - PR fix is correct and optimal s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants