[iOS] Fix CollectionView horizontal scroll when empty inside RefreshView#34382
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34382Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34382" |
|
/azp run maui-pr-uitests |
|
Azure Pipelines successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
Fixes an iOS (and likely MacCatalyst) CollectionView2 behavior where a vertical CollectionView inside a RefreshView can incorrectly report a too-large content width when empty, enabling unintended horizontal scrolling.
Changes:
- Clamp
UICollectionViewCompositionalLayout.CollectionViewContentSize.Widthto the currentCollectionView.Bounds.Widthfor vertical layouts. - Add a HostApp reproduction page for issue #34165.
- Add an Appium UI test asserting an empty CollectionView in a RefreshView does not scroll horizontally.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/Controls/src/Core/Handlers/Items2/iOS/LayoutFactory2.cs | Overrides CollectionViewContentSize to clamp oversized reported widths for vertical layouts, preventing horizontal scroll. |
| src/Controls/tests/TestCases.HostApp/Issues/Issue34165.cs | Adds the repro page (RefreshView + empty CollectionView) for issue #34165. |
| src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34165.cs | Adds an Appium test to validate no horizontal scrolling when empty inside RefreshView. |
src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34165.cs
Outdated
Show resolved
Hide resolved
🤖 AI Summary📊 Expand Full Review —
|
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #34382 | Override CollectionViewContentSize in CustomUICollectionViewCompositionalLayout to clamp width for vertical layouts |
⏳ PENDING (Gate) | LayoutFactory2.cs |
iOS/CV2 only; minimal, localized change |
🚦 Gate — Test Verification
Gate Result: ✅ PASSED
Platform: iOS
Mode: Full Verification
- Tests FAIL without fix: ✅
- Tests PASS with fix: ✅
The test EmptyCollectionViewInsideRefreshViewShouldNotScrollHorizontally in Issue34165.cs correctly catches the bug and validates the PR's fix in LayoutFactory2.cs.
🔧 Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| 1 | try-fix (claude-sonnet-4.6) | Override ShouldInvalidateLayoutForBoundsChange: return true when newBounds.Width != CollectionView.Bounds.Width on vertical layout |
✅ PASS | LayoutFactory2.cs |
Proactive: forces re-prep on any width change; could trigger more invalidations than needed |
| 2 | try-fix (claude-opus-4.6) | Override ShouldInvalidateLayoutForBoundsChange: return true when base.CollectionViewContentSize.Width > newBounds.Width on vertical layout |
✅ PASS | LayoutFactory2.cs |
Targeted: reads content size during bounds-change check; potential reentrancy risk |
| PR | PR #34382 | Override CollectionViewContentSize to clamp width to bounds on vertical layout |
✅ PASSED (Gate) | LayoutFactory2.cs |
Output clamping: minimal, no side effects, safest |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| claude-sonnet-4.6 | 2 | Proposed (rejected) | TargetContentOffset clamp — pure symptom suppression, disallowed by try-fix guidelines |
| claude-opus-4.6 | 2 | NO NEW IDEAS | Covered all viable strategies |
Exhausted: Yes — both models queried; no viable new ideas remain.
Selected Fix: PR's fix — All 3 candidates pass tests. PR fix is the simplest (1 property override, no side effects, no extra UIKit calls during layout callbacks), most localized, and most robustly safe. Alternatives introduce unnecessary invalidation cascades (Attempt 1) or potential reentrancy by querying content size during bounds-change evaluation (Attempt 2).
📋 Report — Final Recommendation
✅ Final Recommendation: APPROVE
Phase Status
| Phase | Status | Notes |
|---|---|---|
| Pre-Flight | ✅ COMPLETE | Issue #34165, 1 fix file + 2 test files, Copilot comments already addressed |
| Gate | ✅ PASSED | iOS — tests FAIL without fix, PASS with fix |
| Try-Fix | ✅ COMPLETE | 2 attempts, 2 passing; PR fix selected as best |
| Report | ✅ COMPLETE |
Summary
PR #34382 fixes a bug where an empty CollectionView inside a RefreshView enables unwanted horizontal scrolling on iOS. The fix is minimal, well-targeted, and validated by both Gate and independent Try-Fix exploration. Two alternative approaches were found (both passing), but the PR's fix is the simplest and safest of the three.
Root Cause
When RefreshView attaches UIRefreshControl to the CollectionView's scroll layer on iOS, it triggers a transient layout pass during which iOS temporarily reports double the actual container width. UICollectionViewCompositionalLayout (used by the CV2 handler) caches this incorrect width. After layout settles, the stale cache causes UIScrollView to believe content is wider than the screen, enabling horizontal scrolling.
Fix Quality
The PR's fix overrides CollectionViewContentSize in CustomUICollectionViewCompositionalLayout to clamp the reported width to CollectionView.Bounds.Width when:
- Scroll direction is Vertical
CollectionViewis not null- Reported width exceeds actual bounds width
- Bounds width is positive (guards against layout-not-ready state)
Strengths:
- ✅ Minimal change — single property override in one file
- ✅ No side effects — pure output clamping, no layout invalidation cascades
- ✅ Defensive guard — safely handles edge cases (null CollectionView, zero-width bounds)
- ✅ Well-commented — explains the root cause inline
- ✅ Tests included — HostApp + NUnit UI test with proper platform guards (
#if ANDROID || IOS) and tolerance-based assertion (.Within(1)) - ✅ Copilot review comments already addressed (platform guard, assertion tolerance, PlatformAffected.macOS)
Alternative approaches explored:
- Attempt 1 (
ShouldInvalidateLayoutForBoundsChangeon any width change): ✅ PASS but triggers more invalidations than needed - Attempt 2 (
ShouldInvalidateLayoutForBoundsChangewhen content > incoming bounds): ✅ PASS but reads content size during bounds-change evaluation (potential reentrancy risk)
Minor observations (non-blocking):
- The test wraps in
#if ANDROID || IOS— MacCatalyst is excluded becauseScrollRightwithScrollStrategy.Gestureis unsupported there. The HostApp page correctly marksPlatformAffected.iOS | PlatformAffected.macOS, so MacCatalyst users can still navigate to the page manually. - The fix is in
Items2/iOS(CV2 handler). Issue triage noted CV2 didn't reproduce with versions up to 10.0.41, but the PR author confirmed and demonstrated reproduction with video evidence. The defensive clamp is safe regardless — a vertical layout should never legitimately report wider content than the viewport.
Selected Fix: PR's fix
Reason: Simplest, most localized, no cascading side effects. All three candidates pass tests; the PR fix wins on safety and minimalism.
📋 Expand PR Finalization Review
PR #34382 Finalization Review
PR: #34382 — [iOS] Fix CollectionView horizontal scroll when empty inside RefreshView
Author: @praveenkumarkarunanithi
Branch: fix-34165 → main
Files Changed: 3 (1 fix, 2 tests)
Fixes: #34165
Phase 1: Title & Description
⚠️ Title: Needs Minor Fix (Typo)
Current: [iOS] Fix CollectionView horizontal scroll when empty inside RefreshView
Issue: Double space between "when" and "empty"
Recommended: [iOS] Fix CollectionView horizontal scroll when empty inside RefreshView
The title is otherwise accurate and well-structured.
✅ Description: Good — Only Addition Needed
Quality Assessment:
| Indicator | Status | Notes |
|---|---|---|
| Root cause | ✅ | Clear explanation of UICollectionViewCompositionalLayout caching incorrect width during RefreshView init |
| Description of change | ✅ | Explains the CollectionViewContentSize override and the clamping logic |
| Accuracy | ✅ | Matches actual diff |
| Issues Fixed | ✅ | Fixes #34165 present |
| Platforms tested | ✅ | All 4 platforms checked |
Only Addition Needed:
The required NOTE block is missing from the top of the description. Prepend:
<!-- 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!Action: Fix title typo (double space) + prepend NOTE block. Preserve existing description body — it is well written and accurate.
Phase 2: Code Review
✅ Core Fix — LayoutFactory2.cs
public override CGSize CollectionViewContentSize
{
get
{
var size = base.CollectionViewContentSize;
if (Configuration.ScrollDirection == UICollectionViewScrollDirection.Vertical
&& CollectionView is not null
&& size.Width > CollectionView.Bounds.Width
&& CollectionView.Bounds.Width > 0)
{
return new CGSize(CollectionView.Bounds.Width, size.Height);
}
return size;
}
}Observations:
- ✅ Minimal, localized change in the right place (
CustomUICollectionViewCompositionalLayout) - ✅ All necessary guards are present: null check, direction check, bounds > 0 (prevents clamping to zero during initialization)
- ✅ Only affects vertical layouts — horizontal CollectionViews are unaffected
- ✅ Comment references the issue number (CollectionView is scrolling left/right when the collection is empty and inside a RefreshView #34165) for traceability
- ✅ Correctly applies only to the CV2 handler path (
Items2/iOS/)
🟡 Suggestions
1. Test uses hardcoded string literals instead of host app constants
File: src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34165.cs
The host app defines constants for AutomationIds:
// TestCases.HostApp/Issues/Issue34165.cs
public const string CollectionViewId = "CollectionView";
public const string EmptyViewLabelId = "EmptyViewLabel";
public const string RefreshViewId = "RefreshView";But the test uses string literals:
App.WaitForElement("CollectionView");
var rectBefore = App.WaitForElement("EmptyViewLabel").GetRect();While the strings currently match, using the constants would make refactoring safer. This is a minor style issue — not blocking.
2. Test scope includes Android despite the fix being iOS-only
File: src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34165.cs
#if ANDROID || IOS // ScrollRight with ScrollStrategy.Gesture is not supported on Windows and MacCatalystThe bug is iOS-specific (the fix lives in Items2/iOS/LayoutFactory2.cs). Including Android in the test isn't harmful — Android uses a different handler and doesn't exhibit this bug — but it's mildly misleading given the host app is tagged PlatformAffected.iOS | PlatformAffected.macOS. The comment explains the rationale (gesture scroll not supported on Windows/MacCatalyst), so this is acceptable as-is.
3. Potential CV1 vs CV2 scope gap
The original issue (#34165) carries the label collectionview-cv1, but the fix is applied to Items2/iOS/LayoutFactory2.cs (CV2 handler). If CV1 (Items/iOS/) also uses UICollectionViewCompositionalLayout, the same bug may exist there. This should be verified — if CV1 has the same issue, a parallel fix may be needed there.
✅ Looks Good
- ✅ Fix is minimal and surgical — 19 lines in a single file
- ✅ Well-guarded against edge cases (null
CollectionView, zero bounds width, horizontal layouts) - ✅ Test verifies the exact regression: X position of
EmptyViewLabelmust not change after a horizontal swipe gesture - ✅ Test uses
Within(1)tolerance for pixel-level comparison — good practice - ✅ Host app correctly sets up the reproduction scenario (empty CollectionView inside RefreshView with EmptyView)
- ✅ Host app handles the
Refreshingevent (stops spinner) so the RefreshView doesn't get stuck
Summary
| Check | Status |
|---|---|
| Title accurate | |
| NOTE block present | ❌ Missing — must be added |
| Description accurate | ✅ |
| Root cause documented | ✅ |
| Fix approach documented | ✅ |
| Issues Fixed linked | ✅ |
| Fix code quality | ✅ |
| Tests added | ✅ |
| Critical issues | None |
Verdict: Nearly merge-ready. Two mechanical changes needed before merge:
- Fix the double space in the title (
when empty→when empty) - Prepend the required NOTE block to the PR description
The code change itself is clean, well-reasoned, and correctly addresses the root cause.
<!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Description of Change <!-- Enter description of the fix in this section --> The fix in PR #34382 , currently in the inflight/current branch, causes conflicts in ItemsFactory2.cs and build failed. Issues [34165](#34165) and [17799](#17799) were already resolved by PR #31215 , which is also included in the inflight/current branch. Therefore, removing the PR #34382 changes from inflight/current to resolve the conflicts in ItemsFactory2.cs.
…iew (#34382) ### Root Cause When `RefreshView` initializes on iOS, it attaches the native `UIRefreshControl` to the CollectionView’s scroll layer. This attachment triggers an internal layout pass. During that layout pass, iOS temporarily reports an incorrect container width to the layout engine, typically double the actual screen width. `UICollectionViewCompositionalLayout`, which is used by the CV2 handler, uses this incorrect width to calculate the total content size and caches it. After the animation settles and the correct screen width is restored, the layout engine does not recalculate the content size and continues using the cached double-width value. As a result, the scroll system believes the content is wider than the screen and enables horizontal scrolling. ### Description of Change The fix was implemented by overriding the `CollectionViewContentSize` property inside `CustomUICollectionViewCompositionalLayout`. This property is where the scroll system queries the layout for the total content size. The override intercepts the content size before it is returned to the scroll system. If the layout is vertical and the reported content width is greater than the actual screen width, the width is clamped to the real screen width before being returned. By ensuring that a vertical layout never reports a content width larger than the screen, horizontal scrolling is prevented. The change is minimal, localized to a single file, and safe because a vertical layout can never legitimately have content wider than the screen. ### Issues Fixed Fixes #34165 Tested the behaviour in the following platforms - [x] Android - [x] Windows - [x] iOS - [x] Mac ### Output Video Before Issue Fix | After Issue Fix | |----------|----------| |<video width="40" height="60" alt="Before Fix" src="https://github.com/user-attachments/assets/0acc28a6-a526-4799-8de7-99c4b0dbf4fe">|<video width="50" height="40" alt="After Fix" src="https://github.com/user-attachments/assets/61100874-a442-4a50-96ee-0539a2544ac3">|
<!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Description of Change <!-- Enter description of the fix in this section --> The fix in PR #34382 , currently in the inflight/current branch, causes conflicts in ItemsFactory2.cs and build failed. Issues [34165](#34165) and [17799](#17799) were already resolved by PR #31215 , which is also included in the inflight/current branch. Therefore, removing the PR #34382 changes from inflight/current to resolve the conflicts in ItemsFactory2.cs.
…iew (#34382) ### Root Cause When `RefreshView` initializes on iOS, it attaches the native `UIRefreshControl` to the CollectionView’s scroll layer. This attachment triggers an internal layout pass. During that layout pass, iOS temporarily reports an incorrect container width to the layout engine, typically double the actual screen width. `UICollectionViewCompositionalLayout`, which is used by the CV2 handler, uses this incorrect width to calculate the total content size and caches it. After the animation settles and the correct screen width is restored, the layout engine does not recalculate the content size and continues using the cached double-width value. As a result, the scroll system believes the content is wider than the screen and enables horizontal scrolling. ### Description of Change The fix was implemented by overriding the `CollectionViewContentSize` property inside `CustomUICollectionViewCompositionalLayout`. This property is where the scroll system queries the layout for the total content size. The override intercepts the content size before it is returned to the scroll system. If the layout is vertical and the reported content width is greater than the actual screen width, the width is clamped to the real screen width before being returned. By ensuring that a vertical layout never reports a content width larger than the screen, horizontal scrolling is prevented. The change is minimal, localized to a single file, and safe because a vertical layout can never legitimately have content wider than the screen. ### Issues Fixed Fixes #34165 Tested the behaviour in the following platforms - [x] Android - [x] Windows - [x] iOS - [x] Mac ### Output Video Before Issue Fix | After Issue Fix | |----------|----------| |<video width="40" height="60" alt="Before Fix" src="https://github.com/user-attachments/assets/0acc28a6-a526-4799-8de7-99c4b0dbf4fe">|<video width="50" height="40" alt="After Fix" src="https://github.com/user-attachments/assets/61100874-a442-4a50-96ee-0539a2544ac3">|
<!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Description of Change <!-- Enter description of the fix in this section --> The fix in PR #34382 , currently in the inflight/current branch, causes conflicts in ItemsFactory2.cs and build failed. Issues [34165](#34165) and [17799](#17799) were already resolved by PR #31215 , which is also included in the inflight/current branch. Therefore, removing the PR #34382 changes from inflight/current to resolve the conflicts in ItemsFactory2.cs.
Root Cause
When
RefreshViewinitializes on iOS, it attaches the nativeUIRefreshControlto the CollectionView’s scroll layer. This attachment triggers an internal layout pass. During that layout pass, iOS temporarily reports an incorrect container width to the layout engine, typically double the actual screen width.UICollectionViewCompositionalLayout, which is used by the CV2 handler, uses this incorrect width to calculate the total content size and caches it. After the animation settles and the correct screen width is restored, the layout engine does not recalculate the content size and continues using the cached double-width value.As a result, the scroll system believes the content is wider than the screen and enables horizontal scrolling.
Description of Change
The fix was implemented by overriding the
CollectionViewContentSizeproperty insideCustomUICollectionViewCompositionalLayout. This property is where the scroll system queries the layout for the total content size.The override intercepts the content size before it is returned to the scroll system. If the layout is vertical and the reported content width is greater than the actual screen width, the width is clamped to the real screen width before being returned.
By ensuring that a vertical layout never reports a content width larger than the screen, horizontal scrolling is prevented.
The change is minimal, localized to a single file, and safe because a vertical layout can never legitimately have content wider than the screen.
Issues Fixed
Fixes #34165
Tested the behaviour in the following platforms
Output Video
Beforefix.mov
AfterFix.mov