Skip to content

[Masterclass] Let a fill child reach the scroll viewport's height - #48

Merged
shanerbaner82 merged 2 commits into
mainfrom
fix/masterclass-303-scrollview-center
Aug 11, 2026
Merged

[Masterclass] Let a fill child reach the scroll viewport's height#48
shanerbaner82 merged 2 commits into
mainfrom
fix/masterclass-303-scrollview-center

Conversation

@shanerbaner82

Copy link
Copy Markdown
Contributor

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-full child therefore has nothing to fill. It reports its own content height, justify-center gets no slack to distribute, and the content stays pinned to the top. Exactly as the issue described: "the child hugs its own content height, so justify-center has 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.

iOSGeometryReader 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. That one cost me a wrong first attempt.

AndroidBoxWithConstraints supplies the viewport; 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 (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: 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. The gate keeps the common path byte-for-byte unchanged.

Direct children only. fill resolves 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.

⚠️ Compile-unverified — no iOS or Android build has been run against this yet.

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

shanerbaner82 and others added 2 commits August 10, 2026 21:34
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>
@shanerbaner82

Copy link
Copy Markdown
Contributor Author

Pushed 1499761 — the Android half was wrong on device. Section 1 still hugged (iOS was fine).

Cause: I 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. The original bug, one layer down.

The minimum now goes on the child's own modifier. Compose enforces min constraints, so a column measured at minHeight = viewport is viewport tall and its Arrangement.Center finally has slack to distribute. Width is reproduced alongside it because overrideModifier replaces NodeView's sizing block wholesale — the same pattern StackRenderer already uses in this file.

Also guarded 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's no viewport to fill against.

Worth recording why iOS never had this: .frame(minHeight:) proposes the clamped height down to its child rather than relaxing it, so the fill child already received the viewport height there. Same intent, opposite constraint semantics between the two frameworks.

@shanerbaner82
shanerbaner82 merged commit 37a7114 into main Aug 11, 2026
3 checks passed
@shanerbaner82
shanerbaner82 deleted the fix/masterclass-303-scrollview-center branch August 11, 2026 02:02
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.

[Masterclass] Cannot vertically center content inside <scroll-view>

1 participant