[iOS] Fix for CollectionView.Measure() returning incorrect height when called before the view is mounted#34331
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 34331Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 34331" |
|
/azp run maui-pr-uitests |
|
Azure Pipelines successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
Fixes iOS CollectionView (Items2 handler) measuring to an incorrect height when Measure() is invoked before the native view is mounted, by forcing a UIKit layout pass so ContentSize is populated.
Changes:
- Force a temporary
LayoutIfNeeded()pass for pre-mount measurement whenContentSizeis still zero. - Add a HostApp reproduction page and an iOS UI test verifying the correct bottom-sheet detent height.
- Add an iOS snapshot baseline for the new UI test.
Reviewed changes
Copilot reviewed 3 out of 5 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs | Forces a pre-mount layout pass to compute ContentSize before falling back to base measurement. |
| src/Controls/tests/TestCases.HostApp/Issues/Issue32983.cs | Adds a reproduction page that measures unmounted content and presents it in a sheet detent. |
| src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32983.cs | Adds an iOS-only UI test that opens the sheet and validates via screenshot. |
| src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/BottomSheetDetentHeightIsCorrectWhenCollectionViewIsMeasuredBeforeMount.png | Adds the snapshot baseline for the new screenshot test. |
…t before view is mounted.
1a0a9f7 to
9c56745
Compare
🤖 AI Summary📊 Expand Full Review —
|
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #34331 | Force layout pass pre-mount via SetNeedsLayout/LayoutIfNeeded with clamped frame | ⏳ PENDING (Gate) | ItemsViewHandler2.iOS.cs |
Accepted Copilot suggestion for ClampConstraint |
🚦 Gate — Test Verification
Gate Result: ✅ PASSED
Platform: iOS
Mode: Full Verification
- Tests FAIL without fix: ✅
- Tests PASS with fix: ✅
Details: iPhone 11 Pro simulator. Test BottomSheetDetentHeightIsCorrectWhenCollectionViewIsMeasuredBeforeMount correctly catches the regression (fails without fix, passes with fix).
🔧 Fix — Analysis & Comparison
Fix Candidates
| # | Source | Approach | Test Result | Files Changed | Notes |
|---|---|---|---|---|---|
| PR | PR #34331 | Pre-mount layout pass: clamp constraints, set Frame, SetNeedsLayout+LayoutIfNeeded, restore frame | ✅ PASSED (Gate) | ItemsViewHandler2.iOS.cs |
ClampConstraint helper accepted from Copilot review |
| 1 | try-fix (Sonnet) | Same as PR but use Bounds instead of Frame for semantically precise layout space communication |
✅ PASS | ItemsViewHandler2.iOS.cs |
Functionally identical to PR; Bounds is more correct (no position change in parent's coordinate space) |
| 2 | try-fix (Opus) | Bounds + explicit InvalidateLayout() + PrepareLayout() before LayoutIfNeeded() |
✅ PASS | ItemsViewHandler2.iOS.cs |
Extra invalidation steps not required; LayoutIfNeeded alone is sufficient per Attempt 1 |
| 3 | try-fix (Sonnet) | Temporarily attach to hidden UIWindow (AddSubview/RemoveFromSuperview) | ❌ FAIL | ItemsViewHandler2.iOS.cs |
6.15% visual diff — UIWindow changes CompositionalLayout behavior; confirms PR's decision to avoid hierarchy manipulation |
Cross-Pollination
| Model | Round | New Ideas? | Details |
|---|---|---|---|
| Sonnet | 2 | Yes | Temp UIWindow approach (→ Attempt 3) |
| Opus | 2 | Yes | Bypass UIKit, compute from MAUI measurement system (too complex/fragile — not attempted) |
| Sonnet | 3 | No | NO NEW IDEAS — frame space exhausted |
| Opus | 3 | No | NO NEW IDEAS — all lightweight alternatives covered |
Exhausted: Yes
Selected Fix: PR's fix — Frame mutation + ClampConstraint + SetNeedsLayout/LayoutIfNeeded + restore in finally
Reason: The PR fix and Attempt 1 are functionally equivalent (Frame vs Bounds makes no measurable difference pre-mount since no superview exists). The PR fix has already been properly reviewed (including an accepted Copilot suggestion for ClampConstraint). Attempt 1's Bounds approach is a minor semantic improvement but does not justify changing the PR. Attempt 3 empirically confirmed that the PR authors were correct to avoid AddSubview/RemoveFromSuperview.
📋 Report — Final Recommendation
✅ Final Recommendation: APPROVE
Phase Status
| Phase | Status | Notes |
|---|---|---|
| Pre-Flight | ✅ COMPLETE | iOS regression in CV2 handler (Items2); pre-mount Measure() returns wrong height |
| Gate | ✅ PASSED | iOS — tests FAIL without fix, PASS with fix |
| Try-Fix | ✅ COMPLETE | 3 attempts: 2 passing, 1 failing; PR's fix is best candidate |
| Report | ✅ COMPLETE |
Summary
PR #34331 fixes a regression introduced in .NET 10's new CollectionView handler (CV2, ItemsViewHandler2.iOS.cs). When CollectionView.Measure() is called before the view is added to the window (a common pattern for bottom sheet sizing), UICollectionViewCompositionalLayout has not run a layout pass, so ContentSize is {0,0}. The old fallback returned base.GetDesiredSize() — the full constraint height — which is wrong.
The fix is well-targeted, correctly gated on collectionView.Window == null, and has been iteratively improved (a Copilot code review suggestion for ClampConstraint was accepted). Gate verification confirmed the test correctly catches the regression.
Root Cause
UICollectionViewCompositionalLayout only computes collectionViewContentSize after a layout pass, which requires a real bounds/frame to work with. Before mounting (Window == null), no layout pass has occurred. The code in EnsureContentSizeForScrollDirection detected contentSize == 0 and fell back to base.GetDesiredSize() which returns the full constraint height — the incorrect direction (constraint ≠ content size).
Fix Quality
Strengths:
- ✅ Correctly scoped: only activates when
Window == nullANDcontentSize == 0 - ✅ Safe frame restore via
try/finally— original frame always restored - ✅ Robust infinity/NaN guard via
ClampConstraint(accepted from Copilot review suggestion) - ✅ No view-hierarchy manipulation (no
AddSubview/RemoveFromSuperview) — empirically validated as the correct choice (Attempt 3's UIWindow approach caused a 6.15% layout difference) - ✅ Falls through to original fallback if forced layout still gives zero (double-safety)
- ✅ Tests catch the bug and verify the fix visually via snapshot
Minor concern (non-blocking):
FramevsBounds: Attempt 1 showed that usingBoundsinstead ofFrameis semantically more precise (Bounds sets the view's own coordinate space; Frame sets position+size in parent's space). Since there is no superview whenWindow == null, both are functionally identical, so this is cosmetic only and does not warrant blocking the PR.- The
VerifyScreenshot()call withoutretryTimeoutcould be flaky if the sheet animation is slow. The author's argument (WaitForElement("BottomSheetCollectionView")as synchronization) is reasonable butVerifyScreenshot(retryTimeout: TimeSpan.FromSeconds(2))would be more robust per project guidelines.
Try-Fix conclusion: The PR's fix is the best available approach. Alternative passing approaches (Attempt 1: Bounds, Attempt 2: +InvalidateLayout+PrepareLayout) add complexity without benefit. The failing approach (Attempt 3: UIWindow) confirmed the PR authors were correct to avoid hierarchy manipulation.
Selected Fix
PR #34331 — Frame mutation + ClampConstraint + SetNeedsLayout/LayoutIfNeeded + finally restore
Result: ✅ APPROVE
📋 Expand PR Finalization Review
PR #34331 Finalization Review
PR: #34331
Title: [iOS] Fix for CollectionView.Measure() returning incorrect height when called before the view is mounted
Author: BagavathiPerumal (community ✨, partner/syncfusion)
Fixes: #32983
Files Changed: 5 (+223, -2)
Phase 1: Title & Description
🟡 Title: Minor Improvement Suggested
Current:
[iOS] Fix for CollectionView.Measure() returning incorrect height when called before the view is mounted
Assessment: Good structure with [iOS] prefix and clear description of the fix. "Fix for" is wordy noise; removing it produces a tighter commit message. Slightly long for a headline.
Recommended:
[iOS] CollectionView: Fix Measure() returning incorrect height before view is mounted
✅ Description: Good Quality — Minor Additions Needed
Quality assessment:
| Indicator | Status | Notes |
|---|---|---|
| NOTE block | ✅ Present | Correctly at the top |
| Root cause | ✅ Clear | UICollectionViewCompositionalLayout hasn't run a layout pass pre-mount |
| Fix description | ✅ Clear | Forced layout pass with frame restore in finally block |
| Issues Fixed | ✅ Present | Fixes https://github.com/dotnet/maui/issues/32983 |
| Platforms Tested | ✅ Present | iOS ✅ Mac ✅ Android ❌ Windows ❌ |
| Mac behavior note | ✅ Present | Explains why MacCatalyst shows full-height sheets |
| Screenshot section | ❌ Empty | Both Before/After image slots are empty |
| Items2-specific scope | ❌ Missing | Fix only applies to CollectionViewHandler2 (Items2/), not legacy handler |
| "What NOT to do" | ❌ Missing | No record of failed alternatives |
Overall verdict: The existing description is well-structured and technically sound. Keep it with additions below.
Additions needed:
-
Add note about Items2-specific scope — The fix is in
ItemsViewHandler2.iOS.cs(the new Items2/CollectionViewHandler2 handler introduced in .NET 10). The legacyItems/handler is unaffected. This context is critical for future agents. -
Fill in or remove Screenshots section — Both image slots are empty. Either add screenshots or remove the placeholder.
-
Add "What NOT to Do" section — Document that
AddSubview,RemoveFromSuperview, andReloadData()were explicitly avoided, as they would trigger side effects.
Recommended additions to description:
### Scope
This fix applies only to **CollectionViewHandler2** (`Items2/`), the new iOS/MacCatalyst handler introduced in .NET 10. The legacy `Items/` handler (used in .NET 9) is unaffected. The regression was introduced specifically because CollectionViewHandler2 relies on `UICollectionViewCompositionalLayout`, which defers layout until after view mounting.
### What NOT to Do (for future agents)
- ❌ **Don't use AddSubview/RemoveFromSuperview** — Temporarily attaching the view to a window causes side effects (hit-testing, view hierarchy changes, visibility notifications)
- ❌ **Don't call ReloadData()** — This would trigger data source callbacks and potentially cause data inconsistencies
- ❌ **Don't compare window-level SafeAreaInsets** — Not applicable here, but: the fix correctly uses `collectionView.Window == null` (per-view state), not any window-level propertyPhase 2: Code Review
🔴 Critical Issues
None identified.
🟡 Suggestions
1. VerifyScreenshot() without retryTimeout — potential flakiness
- File:
src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue32983.cs - Lines: 20–24
- Problem: The test taps a button that triggers an animated UISheetPresentationController presentation. The sheet animation takes time.
VerifyScreenshot()is called immediately afterApp.WaitForElement("BottomSheetCollectionView")— the element being present doesn't mean the animation is complete. This can cause screenshot mismatches on slower devices. - Recommendation:
App.WaitForElement("BottomSheetCollectionView"); VerifyScreenshot(retryTimeout: TimeSpan.FromSeconds(2));
2. _measuredHeightLabel is never updated in HostApp
- File:
src/Controls/tests/TestCases.HostApp/Issues/Issue32983.cs - Lines: 18–22,
OnShowBottomSheetClicked - Problem:
_measuredHeightLabelwithAutomationId = "MeasuredHeight"hasText = "Pending"and is never updated. TheMeasure()result (minimumSize) is computed insideOnShowBottomSheetClickedbut never displayed. This makes the label dead code, and theAutomationIdis unused in the test. - Options:
- Update the label:
_measuredHeightLabel.Text = $"{minimumSize.Height:F2}";after callingMeasure() - Or remove the label/AutomationId entirely if not used in tests
- Update the label:
3. Double-cast overflow guard in ClampConstraint is correct but slightly redundant
- File:
src/Controls/src/Core/Handlers/Items2/ItemsViewHandler2.iOS.cs - Lines: 218–230 (approx)
- Problem: The local function casts
double→nfloat, then casts back todoubleto check for overflow/NaN. The comment says "Guard against overflow to infinity/NaN or negative after casting". On platforms wherenfloatisfloat(32-bit), this guard is meaningful. On 64-bit it is effectively a no-op. The code is correct and safe; this is an informational note only. - Recommendation: No change required; the defensive check is appropriate for cross-architecture compatibility.
✅ Looks Good
- Core fix approach is sound: Forcing a layout pass via
SetNeedsLayout()+LayoutIfNeeded()with a try/finally frame restore is the correct minimal approach. No view hierarchy manipulation. collectionView.Window == nullguard is correct: This reliably detects pre-mount state and gates the expensive forced layout to only when necessary. Mounted views benefit from the normal layout pass.- Fallback path preserved: If the forced layout still produces zero content size, the original fallback (
base.GetDesiredSize()) is still used — correct defensive behavior. UIView.UILayoutFittingExpandedSizeas fallback forClampConstraint: This matches UIKit's own conventions for "largest possible size" (10000×10000).try/finallyframe restore: GuaranteespreviousFrameis always restored even ifLayoutIfNeeded()throws.- Test scope (
#if IOS): Correctly scoped sinceUISheetPresentationControllercustom detents are iOS-only and MacCatalyst always presents full-height. [Category(UITestCategories.CollectionView)]: Correct category for CollectionView tests.- Two snapshot folders (
ios/andios-26/): Correct pattern for iOS-26 Liquid Glass visual differences. PlatformAffected.iOSin[Issue]attribute: Correct — the bug and fix are iOS-specific.#if IOS || MACCATALYSTin HostApp: Correct platform guard for UIKit APIs.
Summary
| Aspect | Status | Action |
|---|---|---|
| Title | 🟡 Minor improvement | Remove "Fix for" prefix, tighten phrasing |
| Description | 🟡 Good, minor additions | Add Items2 scope note, fill/remove screenshots, add "What NOT to Do" |
| Core fix logic | ✅ Sound | No changes needed |
| Test flakiness risk | 🟡 Low-moderate | Add retryTimeout to VerifyScreenshot() |
| Dead UI label | 🟡 Minor | Update _measuredHeightLabel or remove it |
| Breaking changes | ✅ None | Internal Items2 handler change only |
| Platform coverage | ✅ Appropriate | iOS-only fix, iOS+Mac tested |
…n called before the view is mounted (#34331) <!-- 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! <!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Root Cause: The issue occurs because CollectionView (Items2 / CollectionViewHandler2) depends on the native UIKit layout pass to compute its content size. When Measure() is called before the view is mounted, the native collection view has not performed layout yet, so ContentSize remains {0,0}. MAUI then falls back to base.GetDesiredSize(), which returns the full constraint height, producing an incorrect measured value. ### Fix Description: The fix involves performing a temporary in-place layout pass during pre-mount measurement. If Window == null, the CollectionView frame is set directly to the given constraints (clamping ∞ to 10000 to match UIKit’s UILayoutFittingExpandedSize). Then SetNeedsLayout() and LayoutIfNeeded() are called so UIKit computes the real content size. The original frame is always restored in a finally block. No AddSubview, RemoveFromSuperview, or ReloadData() is used. This allows Measure() to return the correct height even before the control is mounted. **Reason for Mac behavior:** On macOS via MacCatalyst, UISheetPresentationController always presents sheets as full-height modal panels. Custom detents (custom heights) are ignored by the platform. ### Issues Fixed Fixes #32983 ### Tested the behaviour in the following platforms - [x] iOS - [x] Mac - [ ] Android - [ ] Windows ### Output Screenshot Before Issue Fix | After Issue Fix | |----------|----------| |<video width="100" height="100" alt="Before Fix" src="https://github.com/user-attachments/assets/8f31b5fe-6482-490c-b6f4-c77376257042">|<video width="100" height="100" alt="After Fix" src="https://github.com/user-attachments/assets/d0b2e698-8843-4d9b-a773-0bd3db427b7c">|
…n called before the view is mounted (#34331) <!-- 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! <!-- !!!!!!! MAIN IS THE ONLY ACTIVE BRANCH. MAKE SURE THIS PR IS TARGETING MAIN. !!!!!!! --> ### Root Cause: The issue occurs because CollectionView (Items2 / CollectionViewHandler2) depends on the native UIKit layout pass to compute its content size. When Measure() is called before the view is mounted, the native collection view has not performed layout yet, so ContentSize remains {0,0}. MAUI then falls back to base.GetDesiredSize(), which returns the full constraint height, producing an incorrect measured value. ### Fix Description: The fix involves performing a temporary in-place layout pass during pre-mount measurement. If Window == null, the CollectionView frame is set directly to the given constraints (clamping ∞ to 10000 to match UIKit’s UILayoutFittingExpandedSize). Then SetNeedsLayout() and LayoutIfNeeded() are called so UIKit computes the real content size. The original frame is always restored in a finally block. No AddSubview, RemoveFromSuperview, or ReloadData() is used. This allows Measure() to return the correct height even before the control is mounted. **Reason for Mac behavior:** On macOS via MacCatalyst, UISheetPresentationController always presents sheets as full-height modal panels. Custom detents (custom heights) are ignored by the platform. ### Issues Fixed Fixes #32983 ### Tested the behaviour in the following platforms - [x] iOS - [x] Mac - [ ] Android - [ ] Windows ### Output Screenshot Before Issue Fix | After Issue Fix | |----------|----------| |<video width="100" height="100" alt="Before Fix" src="https://github.com/user-attachments/assets/8f31b5fe-6482-490c-b6f4-c77376257042">|<video width="100" height="100" alt="After Fix" src="https://github.com/user-attachments/assets/d0b2e698-8843-4d9b-a773-0bd3db427b7c">|
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 from this PR and let us know in a comment if this change resolves your issue. Thank you!
Root Cause:
The issue occurs because CollectionView (Items2 / CollectionViewHandler2) depends on the native UIKit layout pass to compute its content size. When Measure() is called before the view is mounted, the native collection view has not performed layout yet, so ContentSize remains {0,0}. MAUI then falls back to base.GetDesiredSize(), which returns the full constraint height, producing an incorrect measured value.
Fix Description:
The fix involves performing a temporary in-place layout pass during pre-mount measurement. If Window == null, the CollectionView frame is set directly to the given constraints (clamping ∞ to 10000 to match UIKit’s UILayoutFittingExpandedSize). Then SetNeedsLayout() and LayoutIfNeeded() are called so UIKit computes the real content size. The original frame is always restored in a finally block. No AddSubview, RemoveFromSuperview, or ReloadData() is used. This allows Measure() to return the correct height even before the control is mounted.
Reason for Mac behavior:
On macOS via MacCatalyst, UISheetPresentationController always presents sheets as full-height modal panels. Custom detents (custom heights) are ignored by the platform.
Issues Fixed
Fixes #32983
Tested the behaviour in the following platforms
Output Screenshot
32983-BeforeFix.mov
32983-AfterFix.mov