Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions decisions/2026-08-26-native-sheet-primitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Native Sheet primitive — presentation model, styling, exactly-once dismissal

- Date: 2026-08-26
- Status: accepted

## Context

Adding `Mob.UI.sheet/2` — a native modal bottom sheet (iOS `.sheet`,
Android Material 3 `ModalBottomSheet`) that composes ordinary Mob nodes
as content. Several parts of the design aren't obvious from the API
surface alone.

## Decision

### Presentation state lives in view/composable identity, not a boolean prop

Neither `MobSheetView` (iOS) nor `MobSheet` (Android) take a
`presented:` boolean prop. Instead, each holds local presentation
state (`@State private var isPresented = true` / `remember {
mutableStateOf(true) }`) seeded once when the composable/view first
appears at its position in the tree.

Both SwiftUI and Compose preserve that local state across re-renders
that keep dispatching to the same position (same case in a `switch`/
`when`, even though the `MobNode` object itself is rebuilt fresh from
JSON every render) — the same mechanism `MobToggle`/`MobSlider`
already rely on for user-driven state against a BEAM-pushed node. This
gives two required behaviors for free, with no explicit prop needed:

- **A rerender that still includes the sheet node updates its content
without dismissing/re-presenting** — same identity, state preserved.
- **Removing the sheet node from the tree dismisses it** — the
composable stops being called, its whole state (and the live
presentation) is torn down.

### Exactly-once dismissal via a remembered flag, not just the platform callback

Both platforms' native dismiss callback (`.sheet(onDismiss:)` /
`ModalBottomSheet(onDismissRequest:)`) is expected to fire once per
user-initiated dismissal, but a local `dismissSent` flag guards the
actual BEAM-facing send anyway. Cheap insurance against any platform-
specific double-invocation edge case, and it's the literal shape
requested for the Android side — keeping both platforms symmetric.

### background/corner_radius are NOT threaded through the standard modifier pipeline

Every other node type gets `background`/`corner_radius` baked into its
modifier by the generic pipeline (iOS: read directly onto
`node.backgroundColor`/`node.cornerRadius`, which every case already
does; Android: `nodeModifier(node.props)`, applied centrally in
`RenderNodeInner` before dispatch). For most types that's correct — a
plain `.background()`/`.clip()` is exactly what a `Box`/`Row`/etc.
wants.

Sheet can't reuse that path. `ModalBottomSheet` (and SwiftUI's
`.presentationBackground`/`.presentationCornerRadius`) own their own
container paint and corner shape — the same reason Android's `Button`
already reads `background`/`corner_radius` directly instead of via
modifier (`ButtonDefaults.buttonColors`/`shape=`). Passing the
already-baked modifier straight into the sheet would double-apply:
once from the outer modifier's full-rect background/clip, once from
the sheet's own top-corners-only shape — visible as double-painted
background or clipped corners that don't match the requested radius.

Fix, symmetric on both platforms:
- iOS: `MobSheetView` reads `node.backgroundColor`/`node.cornerRadius`
directly for `.presentationBackground`/`.presentationCornerRadius`;
the sheet's *content* (children) gets no separate modifier — SwiftUI
never had a "baked-in" modifier to begin with (see `MobNode.h`: every
node type reads these two properties generically, but nothing
upstream of `MobSheetView` applies them as a `.background()`/`.clip()`
the way Android's central `nodeModifier` does).
- Android: `MobSheet` receives the dispatch call *without* the
`m`-derived modifier (`MobSheet(node)`, not `MobSheet(node, m)`), and
builds its own content modifier from `node.props - listOf("background",
"corner_radius")` before passing it to `nodeModifier`. A structural
lint (`MobNew.Templates.Lint.sheet_content_modifier_not_double_applied/1`
in `mob_new`) guards both halves of this against regression, since
it's not something a compiler catches — code that double-applies
still compiles and runs, it just looks wrong.

### iOS scrim opacity: documented limitation, not a bug to "fix" later

Android applies the requested `:scrim` color (including alpha) exactly
via `ModalBottomSheet`'s `scrimColor` param. iOS's `.sheet` presentation
owns its dimming layer with no public API to configure its opacity —
supported SwiftUI APIs leave it system-black at a fixed alpha. The only
way to force exact opacity is private `UIViewController`/presentation-
controller hierarchy manipulation, which this framework does not do
(same policy as everywhere else native internals are touched only
through documented APIs). Documented in `Mob.UI.sheet/2`'s moduledoc
and in the mob_new CHANGELOG rather than tracked as an open bug.

### Android medium-only short-content fix

Material 3's `ModalBottomSheet` can omit the `PartiallyExpanded` anchor
entirely when content measures shorter than half the viewport. A
medium-only sheet (`detents: [:medium]`) rejects `Expanded` via
`confirmValueChange`, so a short-content medium-only sheet would have
no valid anchor to land on and stay hidden. Content is wrapped in
`BoxWithConstraints`; when `mediumOnly` is true, a `heightIn(min =
maxHeight * 0.5f + 1.dp)` forces just enough height for Material 3 to
compute a real anchor. Full medium+large sheets are untouched — this
only kicks in for the medium-only case, and never allows `Expanded`
(the fix is sizing, not a detent-contract workaround).

Verified via a generated-project Android instrumentation test
(`MediumOnlySheetTest`, `mob_new`) — 1/1 pass on a real emulator with
deliberately short content and `detents == ["medium"]`. Note: a
negative-control check (temporarily disabling the fix) did not
reproduce a clean failure on the specific emulator/Compose-BOM
combination used for verification — the fix follows the requested
spec exactly and the positive case is real-device-confirmed, but the
without-fix failure mode wasn't independently reproduced in this
environment. Worth another look if this Material 3 behavior surfaces
again on a different device/BOM combination.

## Consequences

- Adding a `presented:`-style prop later (e.g., to let BEAM force-close
a sheet without a full tree diff) would need a real prop read in
addition to the identity-based default, not a replacement for it.
- The lint check only catches the *specific* double-application shape
(raw `m` threaded through, or an unstripped `node.props` build) — a
sufficiently different refactor of `MobSheet`'s modifier construction
could still double-apply without tripping it. It's a regression guard
for this exact class of mistake, not a general correctness proof.
25 changes: 25 additions & 0 deletions ios/MobNode.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ typedef NS_ENUM(NSInteger, MobNodeType) {
MobNodeTypeIcon,
MobNodeTypeCanvas,
MobNodeTypeGpuView,
MobNodeTypeSheet,
};

NS_ASSUME_NONNULL_BEGIN
Expand Down Expand Up @@ -261,6 +262,30 @@ NS_ASSUME_NONNULL_BEGIN
// expected packing semantics per element.
@property(nonatomic, strong, nullable) id gpuUniforms;

// Sheet — native modal bottom sheet. `backgroundColor` (declared above,
// shared by every node type) doubles as the sheet's own container
// background, matching how every other prop name is shared across types —
// `nil` unambiguously means "unset" for a color, so sharing it is safe.
// `cornerRadius` is NOT reused here: it defaults to 0 for every other node
// type, where 0-vs-unset is harmless (a 0-radius box looks like a
// system-default box in practice), but a sheet's corners are visibly
// square-vs-rounded, so `sheetCornerRadius` gets its own -1 sentinel
// (unset — use the system default) instead. `sheetDetents` is the raw
// "medium"/"large" string list from Mob.Renderer — mapped to
// PresentationDetent by MobSheetView (see MobRootView.swift), not here, so
// this header stays framework-agnostic. Indicator geometry defaults to -1
// (unset — use the system default indicator); Mob.UI.sheet's validation
// guarantees all four arrive together or not at all, so checking any one
// for >= 0 is enough to know whether a complete custom indicator was
// supplied.
@property(nonatomic) CGFloat sheetCornerRadius;
@property(nonatomic, strong, nullable) NSArray<NSString *> *sheetDetents;
@property(nonatomic, strong, nullable) UIColor *dragIndicatorColor;
@property(nonatomic) CGFloat dragIndicatorWidth;
@property(nonatomic) CGFloat dragIndicatorHeight;
@property(nonatomic) CGFloat dragIndicatorRailHeight;
@property(nonatomic, copy, nullable) void (^onDismiss)(void);

// Children
@property(nonatomic, strong, nonnull) NSMutableArray<MobNode *> *children;

Expand Down
6 changes: 5 additions & 1 deletion ios/MobNode.m
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ - (instancetype)init {
_fixedHeight = 0.0;
_fillWidth = NO;
_cornerRadius = 0.0;
_nativeViewHandle = -1; // -1 = no native component slot assigned (MOB-100)
_nativeViewHandle = -1; // -1 = no native component slot assigned (MOB-100)
_sheetCornerRadius = -1.0; // -1 = unset — use the system default sheet corner radius
_dragIndicatorWidth = -1.0; // -1 = no custom indicator supplied — use system default
_dragIndicatorHeight = -1.0;
_dragIndicatorRailHeight = -1.0;
_videoAutoplay = NO;
_videoLoop = NO;
_videoControls = YES;
Expand Down
105 changes: 104 additions & 1 deletion ios/MobRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,9 @@
.ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) }
.padding(node.paddingEdgeInsets)

case .sheet:
MobSheetView(node: node)

@unknown default:
EmptyView()
}
Expand All @@ -518,7 +521,13 @@
let node: MobNode

func body(content: Content) -> some View {
if let id = node.nativeViewId {
// A sheet's own switch-case view is a zero-size anchor used only to
// attach `.sheet(isPresented:)` — its real, visible content is
// presented in a detached overlay that this GeometryReader can't
// see. Reporting the anchor's frame would silently report 0x0
// instead of the sheet's actual on-screen bounds, so tracking is
// skipped entirely rather than publishing a frame known to be wrong.
if let id = node.nativeViewId, node.nodeType != .sheet {
content
.accessibilityIdentifier(id)
.background(
Expand Down Expand Up @@ -981,7 +990,7 @@
// no manual frame management required.
private class CameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var cameraLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }

Check warning on line 993 in ios/MobRootView.swift

View workflow job for this annotation

GitHub Actions / Native formatters (clang-format + swiftlint)

Force casts should be avoided (force_cast)
}

private struct MobCameraPreviewView: UIViewRepresentable {
Expand Down Expand Up @@ -1297,6 +1306,100 @@
}
}

// MobSheetView — native modal bottom sheet. Presentation is owned by this
// view's own @State, not by the transient MobNode the BEAM rebuilds fresh
// every render — SwiftUI preserves @State across re-renders that keep the
// same view identity (same tree position, same case in MobNodeView's
// switch), exactly like MobToggle/MobSlider preserve user-driven state
// against a BEAM-pushed node above. That's what makes "content updates
// without dismissing/re-presenting" and "removing the node dismisses it"
// both fall out for free: a rerender with the sheet still present reuses
// this state; a rerender without it tears the view (and its presentation)
// down entirely.
private struct MobSheetView: View {
let node: MobNode
@State private var isPresented = true
@State private var dismissSent = false

var body: some View {
Color.clear
.frame(width: 0, height: 0)
.sheet(isPresented: $isPresented, onDismiss: sendDismissOnce) {
sheetContent
}
}

private func sendDismissOnce() {
guard !dismissSent else { return }
dismissSent = true
node.onDismiss?()
}

@ViewBuilder
private var sheetContent: some View {
VStack(spacing: 0) {
ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in
MobNodeView(node: child)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(node.paddingEdgeInsets)
// Screen readers should treat the sheet as a self-contained modal —
// VoiceOver focus stays inside it until dismissed, matching
// .presentationDetents/.sheet's own system-modal behavior.
.accessibilityElement(children: .contain)
.accessibilityAddTraits(.isModal)
.ifLet(node.backgroundColor) { view, bg in
view.presentationBackground(Color(bg))
}
// sheetCornerRadius has its own -1-means-unset sentinel (see
// MobNode.h) so an explicit corner_radius: 0 (square corners) is
// distinguishable from "not set" (system default radius).
.ifLet(node.sheetCornerRadius >= 0 ? node.sheetCornerRadius : nil) { view, radius in
view.presentationCornerRadius(radius)
}
.presentationDetents(detentSet)
.ifLet(hasCustomIndicator ? () : nil) { view, _ in
view.presentationDragIndicator(.hidden)
}
.overlay(alignment: .top) {
if hasCustomIndicator {
customIndicator
}
}
}

private var detentSet: Set<PresentationDetent> {
let requested = node.sheetDetents ?? ["medium", "large"]
var resolved: Set<PresentationDetent> = []
if requested.contains("medium") { resolved.insert(.medium) }
if requested.contains("large") { resolved.insert(.large) }
// Mob.UI.sheet/2 already validates :detents is a nonempty subset of
// [:medium, :large] — this fallback only matters for a hand-built
// node map that skipped that validation (e.g. `~MOB` sigil literal).
return resolved.isEmpty ? [.medium, .large] : resolved
}

// Mob.UI.sheet/2 requires all four custom-indicator props together or
// none — checking one non-sentinel value is enough once that contract
// holds, but check all four defensively for the same hand-built-node
// reason as detentSet above.
private var hasCustomIndicator: Bool {
node.dragIndicatorColor != nil
&& node.dragIndicatorWidth >= 0
&& node.dragIndicatorHeight >= 0
&& node.dragIndicatorRailHeight >= 0
}

private var customIndicator: some View {
Capsule()
.fill(node.dragIndicatorColor.map { Color($0) } ?? Color.secondary)
.frame(width: node.dragIndicatorWidth, height: node.dragIndicatorHeight)
.frame(height: node.dragIndicatorRailHeight)
.padding(.top, 6)
}
}

private struct MobImage: View {
let node: MobNode

Expand Down
48 changes: 48 additions & 0 deletions ios/mob_nif.m
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ static void mob_send_submit(int handle) {
static void mob_send_select(int handle) {
mob_send_event(handle, "select");
}
static void mob_send_dismiss(int handle) {
mob_send_event(handle, "dismiss");
}

// IME composition. Sends {compose, tag, %{text: ..., phase: ...}} where
// phase is one of began/updating/committed/cancelled. Called from the
Expand Down Expand Up @@ -646,6 +649,8 @@ static void mob_send_change_float(int handle, double value) {
node.nodeType = MobNodeTypeCanvas;
else if ([type isEqualToString:@"gpu_view"])
node.nodeType = MobNodeTypeGpuView;
else if ([type isEqualToString:@"sheet"])
node.nodeType = MobNodeTypeSheet;

NSDictionary *props = dict[@"props"];
if ([props isKindOfClass:[NSDictionary class]]) {
Expand Down Expand Up @@ -1129,6 +1134,49 @@ static void mob_send_change_float(int handle, double value) {
node.gpuUniforms = uniforms;
}

// sheet props. background is read generically above (shared prop
// name across every node type) — MobSheetView reuses
// node.backgroundColor directly for the sheet's own container
// background, see MobRootView.swift. corner_radius is read a
// second time here into the dedicated sheetCornerRadius (-1 =
// unset) instead: node.cornerRadius is a plain CGFloat with a 0
// default, so by the time Swift sees it, an explicit
// `corner_radius: 0` is indistinguishable from "never set" — a
// sheet's corners are visibly square-vs-rounded, so that ambiguity
// needs its own sentinel here (unlike other node types, where 0 and
// unset render identically).
if (node.nodeType == MobNodeTypeSheet) {
id sheetCornerRadius = props[@"corner_radius"];
if (sheetCornerRadius)
node.sheetCornerRadius = [sheetCornerRadius doubleValue];

id detents = props[@"detents"];
if ([detents isKindOfClass:[NSArray class]])
node.sheetDetents = detents;

id indicatorColor = props[@"drag_indicator_color"];
if (indicatorColor)
node.dragIndicatorColor = color_from_argb((long)[indicatorColor longLongValue]);

id indicatorWidth = props[@"drag_indicator_width"];
if (indicatorWidth)
node.dragIndicatorWidth = [indicatorWidth doubleValue];
id indicatorHeight = props[@"drag_indicator_height"];
if (indicatorHeight)
node.dragIndicatorHeight = [indicatorHeight doubleValue];
id indicatorRailHeight = props[@"drag_indicator_rail_height"];
if (indicatorRailHeight)
node.dragIndicatorRailHeight = [indicatorRailHeight doubleValue];

id onDismiss = props[@"on_dismiss"];
if (onDismiss && [onDismiss isKindOfClass:[NSNumber class]]) {
int handle = [onDismiss intValue];
node.onDismiss = ^{
mob_send_dismiss(handle);
};
}
}

// webview props
id webViewUrl = props[@"url"];
if ([webViewUrl isKindOfClass:[NSString class]])
Expand Down
Loading
Loading