Repository navigation
[iOS] ScrollView: Keep the content offset when the scroll view is resized by its parent layout - #38599
Conversation
<!-- 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>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 38599Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 38599" |
|
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: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
|
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. |
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
There was a problem hiding this comment.
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.
This comment has been minimized.
This comment has been minimized.
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>
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@albyrock87 — new AI review results are available based on commit
73b8059.
🗂️ 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.cssrc/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txtsrc/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 assignsCenterand thenBounds. UIKit may rederiveBounds.Originduring the size change, movingContentOffsetby 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 withGetTargetContentOffset. - The focused
ScrollViewInitialOffsetInGridRowUI 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 MacCatalystPublicAPI.Unshipped.txtfiles, 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
- Primary:
pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform ios -TestFilter "ScrollViewInitialOffsetInGridRow" - 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.ScrollableContentSizeremains 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.txtfiles.
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
PlatformViewfalls 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
Scrollednotification 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 |
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 |
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 |
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 |
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.PlatformArrangeon 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, includingMauiScrollView.ScrollableContentSize, instead of introducing a second range model. - It adds matching public API baselines for both TFMs compiled from the
.iOS.cssource. - 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.
|
/azp run |
|
Azure Pipelines: Successfully started running 3 pipeline(s). |
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>
|
/backport to release/10.0.1xx-sr11 |
|
Started backporting to |
…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>
…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>
Description of Change
On iOS, a
ScrollViewwhose 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: aGrid(Auto,*,Auto) with theScrollViewin 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 (contentPadding8 + itemMargin8 + stroke 2 scrolled away).Cause.
ViewHandlerExtensions.PlatformArrangeHandlerarranges views by settingCenterand thenBounds(rather thanFrame, so non-identity transforms keep working). On aUIScrollView,Bounds.Originis the content offset, and UIKit re-derives it insidesetBounds: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 oncontentOffsetcaptured the move exactly there:The arrange defect itself is old: the fixture fails identically on the
10.0.90(SR9),10.0.100and10.0.101sources, so it is not a regression of the recent ScrollView changes. What changed in SR10 is reachability: #32267 madeMauiScrollView.InvalidateMeasure(isPropagating: true)returntrue, 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 rangeScrollToAsyncalready uses (ClampToScrollableRange, shared withGetTargetContentOffset: 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 aBoundsoverride onMauiScrollView: it is scroll-view-specific, only the arrange path is affected, and UIKit implementscontentOffsetthroughsetBounds: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 ownsetFrame:semantics would. Side effect worth knowing: in the affected passScrolledis 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
PlatformArrangeoverride 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:
Notes: it clamps against
ContentSizerather 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
StackLayoutchild, anAutorow,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.
MauiScrollViewwas deliberately a measure-invalidation barrier (InvalidateMeasure(isPropagating: true)returnedfalse; the surrounding code still says "Invalidation stops atUIScrollViews for performance reasons"), and it already had the targeted escape hatch for the content-sized case:LayoutSubviewscallsInvalidateAncestorsMeasures()whenContentSizechanges. #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, aLabeltext update, every keystroke in anEntryorEditorinside 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,
CrossPlatformMeasurereturned the ScrollView's current size, soContentSizedid not change and the existingInvalidateAncestorsMeasures()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 unconditionalreturn truein favour of such a condition as a follow-up onmain; 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.InvalidateMeasureinMauiScrollViewsays the return value means "stop propagating", while the walk inViewExtensions.InvalidateAncestorsMeasurestreatstrueas "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 aGrid Auto,*,Auto, theScrollViewin the star row, contentVerticalStackLayout Padding=0,8whose first child is aBorder 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 nativeUIScrollView(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.TopandScrollY == 0once 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):
Issue36801+Issue36801DeferredElement+ShellFlyoutHeaderScrollViewContentsuites 12/12AsyncContentWithHeaderfails atoffset=18(36pt footer growth, halved); the same case fails identically against the10.0.90,10.0.100and10.0.101sources forsrc/Core/src+src/Controls/src/CoreScrollEventProxy+NotifyInsetsChangeddisabled: still fails with the sameset_Boundsstack,ScrollYleft at 0 while the native offset is 18 — those paths only report the offsetStaticContentResizedLaterpasses with and without the fix (the guard)🤖 Generated with Claude Code