[Masterclass] Let a fill child reach the scroll viewport's height - #48
Conversation
A scroll view measures its content against an UNBOUNDED main axis — that is what makes it scrollable — so nothing inside it knows the viewport height. A `fill` / `h-full` child therefore has nothing to fill, reports its own content height, and `justify-center` gets no slack to distribute: the child hugs and the content stays pinned to the top. Same shape on both platforms. It is the problem CSS `min-height: 100%` exists for, and the fix is the same idea on each side: measure the viewport OUTSIDE the scroll view and hand that height to the filling child as a MINIMUM. iOS — GeometryReader around the ScrollView. The height is applied per CHILD, not to the LazyVStack: a lazy stack sizes children to their ideal height and does not redistribute slack the way a plain VStack does, so a minimum on the stack would grow the stack and leave the child hugging anyway. Android — BoxWithConstraints supplies the viewport and the filling child is wrapped in `Modifier.heightIn(min = viewport)`. The min constraint passes through, so Compose measures the child at max(contentHeight, viewport) without relying on fillMaxHeight resolving against an unbounded parent. A minimum, not an exact height: content taller than the viewport must still grow and scroll. Both paths are gated on a DIRECT child actually asking for fill height, so every existing scroll view keeps its original measurement. That matters most on iOS, where GeometryReader is greedy — it claims all offered space and reports it — so wrapping every scroll view in one would change how content-sized scroll views measure. Direct children only: a nested descendant's fill resolves against ITS parent, which is ordinary flex behaviour and needs nothing from here. Fixes NativePHP/mobile-air#303. Native changes are compile-unverified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first attempt wrapped the filling child in `Box(Modifier.heightIn(min = viewport))`. Compose's Box RELAXES min constraints for its children — only `matchParentSize()` opts back in — so the Box became viewport-tall while the column inside it went on hugging its content. The original bug, one layer down. Applying the minimum to the child's own modifier fixes it: Compose enforces min constraints, so a column measured at minHeight = viewport IS viewport tall, and its own Arrangement.Center finally has slack to distribute. Width has to be reproduced alongside it because `overrideModifier` replaces NodeView's sizing block wholesale — same pattern StackRenderer already uses in this file. Also guard the unbounded case. `BoxWithConstraints.maxHeight` is Dp.Infinity when the scroll view is itself content-sized, and `heightIn(min = Infinity)` would be catastrophic; that now falls through to the normal path, since there is no viewport to fill against. iOS was unaffected: `.frame(minHeight:)` proposes the clamped height DOWN to its child rather than relaxing it, so the fill child already received the viewport height there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed Cause: I wrapped the filling child in The minimum now goes on the child's own modifier. Compose enforces min constraints, so a column measured at Also guarded the unbounded case: Worth recording why iOS never had this: |
Fixes NativePHP/mobile-air#303. cc @shrutibalasawebdev
Independent of #47 — different files, merges in any order.
Demos:
/masterclass/scroll-center(three boxed cases against a visible 160pt viewport) and/masterclass/scroll-center-login(the realistic full-screen centred login from the issue) in the super-native demo app.Cause
A scroll view measures its content against an unbounded main axis — that's what makes it scrollable — so nothing inside it knows the viewport height.
A
fill/h-fullchild therefore has nothing to fill. It reports its own content height,justify-centergets no slack to distribute, and the content stays pinned to the top. Exactly as the issue described: "the child hugs its own content height, sojustify-centerhas no effect."Same shape on both platforms, which is why it reproduced on both. @shrutibalasawebdev's observation that the identical markup centres correctly inside a plain
<column>is the tell — a column has a definite height to distribute; a scroll view's content deliberately does not.Fix
It's the problem CSS
min-height: 100%exists for, and the fix is the same idea on each side: measure the viewport outside the scroll view and hand that height to the filling child as a minimum.iOS —
GeometryReaderaround theScrollView. The height is applied per child, not to the LazyVStack. A lazy stack sizes children to their ideal height and does not redistribute slack the way a plainVStackdoes, so a minimum on the stack would grow the stack and leave the child hugging anyway. That one cost me a wrong first attempt.Android —
BoxWithConstraintssupplies the viewport; the filling child is wrapped inModifier.heightIn(min = viewport). The min constraint passes through, so Compose measures the child atmax(contentHeight, viewport)without relying onfillMaxHeightresolving against an unbounded parent (where it would be a no-op).A minimum, not an exact height. Content taller than the viewport must still grow and scroll — squashing it to the viewport would be wrong in the other direction, so the demo has a case for it.
Scoping
Both paths are gated on a direct child actually asking for fill height, so every existing scroll view keeps its original measurement.
That matters most on iOS:
GeometryReaderis greedy — it claims all offered space and reports it — so wrapping every scroll view in one would change how content-sized scroll views measure. The gate keeps the common path byte-for-byte unchanged.Direct children only.
fillresolves against the scroll viewport; a nested descendant's fill resolves against its parent, which is ordinary flex behaviour and needs nothing from here.Testing
Full suite green: 213 passed, Pint clean. No PHP surface changed — this is a renderer-layout fix on both sides, verified by the demo screens.
Note for anyone running the login demo: it uses
max-w-[360px], which needs NativePHP/mobile-air#310 (mobile-air PR #313). Without that the card just isn't width-capped; the centring being demonstrated here is unaffected.🤖 Generated with Claude Code