Hug the content in every iOS content-fit sheet - #281
Open
swedishfrenchpress wants to merge 1 commit into
Open
Conversation
The zero-balance Send sheet and the Add Mint sheet opened at ~70% of the
screen with their content floating in a large void, where Android's
equivalents hug their content.
Every partial-height sheet derives its detent from a height measured inside
that same sheet, which closes a loop: measured height -> detent -> sheet
height -> the height proposed back to the content. Because the chrome
allowance nearly matched the real chrome, the loop was neutrally stable and
settled wherever the first layout pass left it -- roughly full-screen, during
the presentation transition -- and never recovered.
A ScrollView proposes nil height to its content, so a view measured inside
one always reports its ideal size regardless of how tall the sheet currently
is. That breaks the loop, and it predicted exactly which sheets were broken:
the three that measured inside a ScrollView were fine, and the two that
measured a bare view latched.
Replace four hand-rolled copies of the mechanism with one pair of modifiers:
contentFitMeasured { ... } // inside the NavigationStack, owns the ScrollView
contentFitDetent(height) // on the sheet root, owns the arithmetic
Breaking the measurement loop is necessary but not sufficient. The body's
height is reported more than once as layout settles -- a transient value, then
the real one, milliseconds apart. Handing presentationDetents a fresh
single-element set does not move the sheet: UIKit resolves the new set but
keeps whichever detent it had already selected, so the transient wins and the
final measurement is silently ignored. Instrumented on an iPhone 17 Pro,
0 -> 469 -> 241pt arrived within 70ms and the sheet stayed on the 547pt detent
instead of settling on 319pt. Binding the selection alongside the set removes
that race: the sheet is told which detent to be on, not merely which exist.
ContentFitSheetMetrics is now the single place the arithmetic lives:
detent = content + navigationBar (44, when present) + bottomSafeArea
Notably absent is any allowance for the drag indicator.
presentationDragIndicator draws the grabber as an overlay over the content's
own top padding, so reserving height for it never moved the content down --
it landed as dead space at the bottom. The old flat 108pt constant carried
20pt of that plus 10pt of slack. Removing both tightens every sheet by 30pt.
The bottom safe area is resolved per device rather than assumed, so
home-button iPhones no longer over-shoot by 34pt, and the detent is clamped
to 90% of the screen so accessibility text sizes scroll inside the sheet
instead of silently pinning it full-height.
Onboarding's "What is ecash?" sheet joins the same mechanism. It hosts no
NavigationStack, hence the navigationBar flag. It loses its second .large
detent: the ScrollView and the clamp cover the large-text case that detent
existed for, and every other content-fit sheet is single-detent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
swedishfrenchpress
force-pushed
the
ios-content-fit-sheets
branch
from
August 2, 2026 23:32
d2eaa12 to
601e7e7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
The zero-balance Send sheet and the Add Mint sheet opened at ~70% of the screen with their content floating in a large void. Android's equivalents hug their content —
UnifiedSendScreen.kteven says so: "Compact (no fillMaxHeight) so the sheet hugs this empty state."556566f)601e7e7)Root cause, part 1: a measurement loop
Every partial-height sheet derives its detent from a height measured inside that same sheet:
Because the chrome allowance nearly matched the real chrome, the loop was neutrally stable: it settled wherever the first layout pass left it — roughly full-screen, during the presentation transition — and never recovered.
A
ScrollViewproposesnilheight to its content, so a view measured inside one always reports its ideal size regardless of how tall the sheet currently is. That breaks the loop, and it predicted exactly which sheets were broken, 5/5:SendView.inputFormReceiveView.inputFormConnectMintPickerSendView.noBalanceStateNativeEmptyStateAddMintFormViewRoot cause, part 2: a detent race
Breaking the loop is necessary but not sufficient, and this one only showed up under the matched capture — manual testing won the race and looked fine.
The body's height is reported more than once as layout settles. Handing
presentationDetentsa fresh single-element set does not move the sheet: UIKit resolves the new set but keeps whichever detent it had already selected, so a transient measurement wins and the final one is silently ignored. Instrumented on an iPhone 17 Pro:All three within 70ms. Binding the selection alongside the set fixes it — the sheet is told which detent to be on, not merely which ones exist.
The change
Four hand-rolled copies of the mechanism collapse into one pair of modifiers:
ContentFitSheetMetricsis the single place the arithmetic lives:Notably absent: any allowance for the drag indicator.
presentationDragIndicatordraws the grabber as an overlay over the content's own top padding, so reserving height for it never moved the content down — it landed as dead space at the bottom. The old flat108carried 20pt of that plus 10pt of slack; removing both tightens every sheet by 30pt.The bottom safe area is resolved per device rather than assumed, so home-button iPhones no longer over-shoot by 34pt. The clamp means accessibility text sizes scroll inside the sheet rather than silently pinning it full-height.
Onboarding's "What is ecash?" sheet
Joined to the same mechanism for consistency. It hosts no
NavigationStack, hence thenavigationBar:flag. It loses its second.largedetent — the ScrollView and the clamp cover the large-text case that detent existed for, and every other content-fit sheet is single-detent.This one is essentially unchanged in size, and slightly taller by design: the old detent was the measured content with no chrome at all, so the sheet borrowed the home-indicator strip. It now accounts for it.
Capture conditions
Both sides built from isolated worktrees with separate Derived Data, captured on one simulator instance.
556566f(upstream/main) · head601e7e7RESET_WALLET+UITEST_SEED_WALLET+UITEST_SEED_MINTwith a synthetic mint URL (https://mint.example.test) — no network, no real wallet dataNot captured in the matched run: Add Mint, Receive, and the connect-a-mint picker. They go through the identical code path and were checked by hand, but they do not have before/after evidence here.
No Android changes. No
project.pbxprojchanges.🤖 Generated with Claude Code