Skip to content

feat(android): render intrinsic sheets and box semantics - #39

Merged
GenericJam merged 2 commits into
masterfrom
feat/intrinsic-sheet-detents
Aug 28, 2026
Merged

feat(android): render intrinsic sheets and box semantics#39
GenericJam merged 2 commits into
masterfrom
feat/intrinsic-sheet-detents

Conversation

@GenericJam

@GenericJam GenericJam commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • render intrinsic, maximum-height-capped Sheet content in generated Android apps
  • scroll Sheet overflow internally while preserving existing medium/large behavior
  • expose generic Box accessibility labels, button roles, and disabled semantics in Compose
  • add focused template and generated-output regression coverage

Ownership

This is the generated Android half of the coordinated Mob renderer change. The downstream UI library remains responsible for Select/BottomSheet composition and visual styling; the generated host owns Material Sheet mechanics and generic Box semantics.

The matching runtime/iOS implementation is GenericJam/mob#94.

Verification

  • mix format --check-formatted
  • mix credo --strict
  • mix compile --warnings-as-errors
  • full suite: 337 passed; the two worktree path-resolution cases passed separately with explicit MOB_DIR and MOB_DEV_DIR (339 total behavior checks green)
  • mix test --only lint — generated Kotlin lint passed
  • git diff --check origin/master..HEAD

This PR intentionally does not bump the archive version or generated Mob dependency floor. Release bookkeeping follows after the matching Mob release is approved.

Four findings from an adversarial pass, one of them a hard crash. All verified
on a physical Moto G power (2021), including a negative control.

1. CRASH — an intrinsic sheet containing a scrollable child killed the app.
   The content-detent path wrapped the sheet body in verticalScroll
   unconditionally. Compose's checkScrollableContainerConstraints THROWS when
   a scrollable is measured with an infinite max height, so a `scroll` or
   `lazy_list` inside `detents: [:content]` — the most natural use of an
   intrinsic sheet — died at first measure with

     FATAL EXCEPTION: java.lang.IllegalStateException: Vertically scrollable
     component was measured with an infinity maximum height constraints

   iOS survives the equivalent nesting because SwiftUI tolerates it; Compose
   does not. The sheet now only adds its own scroll when the content has none
   (isScrollableNode walks the subtree); the height cap applies either way and
   the child keeps owning its scrolling.

   Proven, not assumed: with the guard disabled the app dies with that
   exception; with it, the same screen reports 40 rows and stays alive.

2. A disabled box still dispatched taps unless it also carried
   accessibility_role: :button. The non-button `when` arm had no `enabled`
   check while the semantics block below emitted disabled() — TalkBack
   announced it as disabled and it kept firing. iOS disables every box
   regardless of role, so this was also a platform split.

3. A box declared `accessibility_role: :button` whose tap is handled by an
   ancestor was announced as "…, button, disabled". Compose publishes
   disabled() semantics whenever clickable's `enabled` is false, so encoding
   "no handler" as enabled = false mislabelled a live control. The branch now
   requires a handler and falls through to semantics-only otherwise.

4. The height cap sat outside the node's own padding, so a padded sheet
   overshot max_height by the padding amount and disagreed with iOS, which
   caps the already-padded body. Modifier order corrected.

Also: merge descendants on label OR button role, matching the iOS fix — role
without merging leaves children as separate nodes inside a "button".

Device results (Moto G power): short content hugs (3 rows), max_height 320 caps
and scrolls internally (40 rows), scrollable child renders and survives (40
rows), app alive across all cases.

340 tests, format, credo --strict, ktlint generate-then-lint, and a real Gradle
build all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam
GenericJam merged commit b3a1831 into master Aug 28, 2026
3 checks passed
@GenericJam
GenericJam deleted the feat/intrinsic-sheet-detents branch August 28, 2026 02:04
GenericJam added a commit that referenced this pull request Aug 28, 2026
…crash fix

Pairs with mob 0.7.32, published first.

Ships #39: the generated Android renderer parses typed content detents, hugs
short content, caps against max_height and the parent constraint, scrolls
overflow internally, and honours composite Box accessibility (label as
contentDescription, explicit button role preserved without a tap handle,
disabled semantics, no dispatch when disabled, passive labelled boxes stay
non-buttons).

Plus four fixes from an adversarial review, one of them a hard crash:

- A content Sheet containing a scroll/lazy_list child KILLED THE APP. The
  content-detent path wrapped the body in verticalScroll unconditionally, and
  Compose throws when a scrollable is measured with infinite max height. Proven
  on a Moto G with a negative control: guard disabled, the app dies with
  IllegalStateException; guard enabled, the same screen renders 40 rows and
  survives.
- A disabled Box dispatched taps unless it also had a button role, while
  reporting disabled() semantics — announced disabled, still firing, and a
  split from iOS which disables every Box regardless of role.
- A Box declared a button whose tap is handled by an ancestor was announced as
  "button, disabled", because Compose publishes disabled semantics whenever
  clickable's enabled is false.
- The height cap sat outside the node's padding, so a padded sheet overshot
  max_height; iOS caps the already-padded body.

Generated dependency floor raised to {:mob, "~> 0.7.32"}. The comment now
records both reasons a floor exists here — mob_send_dismiss since 0.7.31, and
0.7.32's validation/encoding of detents and accessibility props, without which
the Kotlin silently renders a default sheet and drops the semantics rather than
erroring.

Preflight: format, credo --strict, compile --warnings-as-errors, 340 tests,
git diff --check, ktlint generate-then-lint, and a real Gradle build + device
deploy all clean.

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