Skip to content

Hug the content in every iOS content-fit sheet - #281

Open
swedishfrenchpress wants to merge 1 commit into
cashubtc:mainfrom
swedishfrenchpress:ios-content-fit-sheets
Open

Hug the content in every iOS content-fit sheet#281
swedishfrenchpress wants to merge 1 commit into
cashubtc:mainfrom
swedishfrenchpress:ios-content-fit-sheets

Conversation

@swedishfrenchpress

@swedishfrenchpress swedishfrenchpress commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

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.kt even says so: "Compact (no fillMaxHeight) so the sheet hugs this empty state."

before (556566f) after (601e7e7)
592pt — cluster centered in a void 347pt — hugs its content

Root cause, part 1: a measurement loop

Every partial-height sheet derives its detent from a height measured inside that same sheet:

measured height → detent → sheet height → height proposed back to the content

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 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, 5/5:

Site Measured Status
SendView.inputForm inner VStack in a ScrollView fine
ReceiveView.inputForm inner VStack in a ScrollView fine
ConnectMintPicker inner VStack in a ScrollView fine
SendView.noBalanceState bare NativeEmptyState latched
AddMintFormView bare VStack latched

Root 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 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 a transient measurement wins and the final one is silently ignored. Instrumented on an iPhone 17 Pro:

[CFS] measured=0    → detent 298   (first-frame estimate)
[CFS] measured=469  → detent 547   ← transient, and what the sheet stayed on
[CFS] measured=241  → detent 319   ← correct, silently ignored

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:

.contentFitMeasured { ... }   // inside the NavigationStack — owns the ScrollView + measurement
.contentFitDetent(height)     // on the sheet root — owns the arithmetic and the selection

ContentFitSheetMetrics is the single place the arithmetic lives:

detent = content + navigationBar (44, when present) + bottomSafeArea,  clamped to 0.9 × screen

Notably absent: 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 108 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. 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 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.

before after
~391pt ~397pt

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.

  • base 556566f (upstream/main) · head 601e7e7
  • iOS 26.5 (23F77), iPhone 17 Pro, 1206×2622 @3x (402×874pt)
  • Xcode 26.6 (17F113)
  • light appearance, en_US, default Dynamic Type, status bar pinned to 9:41
  • fixture: RESET_WALLET + UITEST_SEED_WALLET + UITEST_SEED_MINT with a synthetic mint URL (https://mint.example.test) — no network, no real wallet data

Not 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.pbxproj changes.

🤖 Generated with Claude Code

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant