Packages
mob
0.7.11
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
ios/MobRootView.swift
// MobRootView.swift — SwiftUI entry point. Observes MobViewModel and renders the
// node tree pushed by BEAM NIFs via MobViewModel.setRoot().
import SwiftUI
import AVKit
import WebKit
// ── Native view component registry ───────────────────────────────────────────
// Register platform-native views by name at app startup. The name is the
// Elixir module with "Elixir." stripped and "." replaced with "_":
// MyApp.ChartComponent → "MyApp_ChartComponent"
//
// MobNativeViewRegistry.shared.register("MyApp_ChartComponent") { props, send in
// AnyView(ChartView(data: props["data"]) { index in
// send("tapped", ["index": index])
// })
// }
public typealias MobNativeSend = (_ event: String, _ payload: [String: Any]) -> Void
public typealias MobNativeViewFactory = (_ props: [String: Any], _ send: @escaping MobNativeSend) -> AnyView
public final class MobNativeViewRegistry {
public static let shared = MobNativeViewRegistry()
private var factories: [String: MobNativeViewFactory] = [:]
public func register(_ name: String, factory: @escaping MobNativeViewFactory) {
factories[name] = factory
}
func view(for node: MobNode) -> AnyView? {
guard let name = node.nativeViewModule,
let factory = factories[name],
let props = node.nativeViewProps as? [String: Any] else { return nil }
let handle = node.nativeViewHandle
let send: MobNativeSend = { event, payload in
if let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8) {
mob_send_component_event(handle, event, json)
}
}
return factory(props, send)
}
}
extension View {
@ViewBuilder
func ifLet<T>(_ value: T?, transform: (Self, T) -> some View) -> some View {
if let value {
transform(self, value)
} else {
self
}
}
/// Apply Mob gesture handlers from a node — long press, double tap, swipe.
/// Each is opt-in (nil callbacks become no-ops). Per-widget; most widgets
/// won't have any of these set, so the cost is one nil check per gesture.
/// Drag gesture is only attached if at least one swipe handler is set
/// (otherwise it would interfere with ScrollView and tap behaviors).
@ViewBuilder
func mobGestures(_ node: MobNode) -> some View {
let hasSwipe =
node.onSwipe != nil ||
node.onSwipeLeft != nil ||
node.onSwipeRight != nil ||
node.onSwipeUp != nil ||
node.onSwipeDown != nil
self
.ifLet(node.onLongPress) { view, cb in
view.onLongPressGesture(minimumDuration: 0.5) { cb() }
}
.ifLet(node.onDoubleTap) { view, cb in
view.onTapGesture(count: 2) { cb() }
}
.ifLet(hasSwipe ? node : nil) { view, n in
view.gesture(
DragGesture(minimumDistance: 30)
.onEnded { value in
let dx = value.translation.width
let dy = value.translation.height
let direction: String
if abs(dx) > abs(dy) {
direction = dx > 0 ? "right" : "left"
} else {
direction = dy > 0 ? "down" : "up"
}
n.onSwipe?(direction)
switch direction {
case "left": n.onSwipeLeft?()
case "right": n.onSwipeRight?()
case "up": n.onSwipeUp?()
case "down": n.onSwipeDown?()
default: break
}
}
)
}
}
}
// Allow MobNode to be used as ForEach identity (NSObject provides hash/isEqual).
extension MobNode: Identifiable {
public var id: ObjectIdentifier { ObjectIdentifier(self) }
}
// Logical icon name → SF Symbol. Logical names are platform-agnostic so the
// same Elixir tree renders an Apple-styled icon on iOS and a Material-styled
// icon on Android. Unknown logical names pass through verbatim, letting
// power users use raw SF Symbol identifiers (e.g. "globe.americas.fill").
private func sfSymbolName(_ logical: String) -> String {
switch logical {
case "settings": return "gear"
case "back": return "chevron.left"
case "forward": return "chevron.right"
case "close": return "xmark"
case "add": return "plus"
case "remove": return "minus"
case "edit": return "pencil"
case "check": return "checkmark"
case "chevron_right": return "chevron.right"
case "chevron_left": return "chevron.left"
case "chevron_up": return "chevron.up"
case "chevron_down": return "chevron.down"
case "info": return "info.circle"
case "warning": return "exclamationmark.triangle"
case "error": return "xmark.circle"
case "search": return "magnifyingglass"
case "trash": return "trash"
case "share": return "square.and.arrow.up"
case "more": return "ellipsis"
case "menu": return "line.3.horizontal"
case "refresh": return "arrow.clockwise"
case "favorite": return "heart"
case "favorite_filled": return "heart.fill"
case "star": return "star"
case "star_filled": return "star.fill"
case "user": return "person"
case "home": return "house"
case "history": return "clock.arrow.circlepath"
case "list": return "list.bullet"
case "qr_code": return "qrcode"
case "link": return "link"
case "snowflake": return "snowflake"
default: return logical // raw SF Symbol pass-through
}
}
extension MobNode {
var childNodes: [MobNode] {
children.compactMap { $0 as? MobNode }
}
/// EdgeInsets that honour per-edge padding props (padding_top etc.).
/// Falls back to the uniform `padding` value for any unset edge.
var paddingEdgeInsets: EdgeInsets {
let top = paddingTop >= 0 ? paddingTop : padding
let right = paddingRight >= 0 ? paddingRight : padding
let bottom = paddingBottom >= 0 ? paddingBottom : padding
let left = paddingLeft >= 0 ? paddingLeft : padding
return EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right)
}
/// Resolved SwiftUI Font respecting font family, size, weight, and italic.
var resolvedFont: Font {
let size: CGFloat = textSize > 0 ? textSize : 16.0
let weight: Font.Weight = {
switch fontWeight {
case "bold": return .bold
case "semibold": return .semibold
case "medium": return .medium
case "light": return .light
case "thin": return .thin
default: return .regular
}
}()
var font: Font
if let family = fontFamily, !family.isEmpty {
font = Font.custom(family, size: size)
} else {
font = .system(size: size)
}
font = font.weight(weight)
if italic { font = font.italic() }
return font
}
var textAlignEnum: TextAlignment {
switch textAlign {
case "center": return .center
case "right": return .trailing
default: return .leading
}
}
var frameTextAlignment: Alignment {
switch textAlign {
case "center": return .center
case "right": return .trailing
default: return .leading
}
}
/// Extra inter-line spacing derived from the lineHeight multiplier.
var computedLineSpacing: CGFloat {
guard lineHeight > 0 else { return 0 }
let size: CGFloat = textSize > 0 ? textSize : 16.0
return (lineHeight - 1.0) * size
}
}
// ── Recursive node renderer ────────────────────────────────────────────────
struct MobNodeView: View {
let node: MobNode
var body: some View {
Group {
switch node.nodeType {
case .column:
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) }
}
// fill_height: true lets a column flex to fill its parent so children
// with Spacer() or fill_height of their own can pin to the bottom.
// Without maxHeight the VStack hugs its content vertically and a
// trailing footer sits directly below the last child instead of the
// parent's bottom edge.
.frame(maxWidth: .infinity, maxHeight: node.fillHeight ? .infinity : nil, alignment: .topLeading)
.padding(node.paddingEdgeInsets)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
.ifLet(node.onTap) { view, tap in
view.contentShape(Rectangle()).onTapGesture { tap() }
}
.mobGestures(node)
case .row:
let alignment: VerticalAlignment = {
switch node.rowAlign {
case "top": return .top
case "bottom": return .bottom
case "baseline": return .lastTextBaseline
default: return .center
}
}()
HStack(alignment: alignment, spacing: 0) {
ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) }
}
// Without maxWidth: .infinity an HStack hugs its content.
// Flex Spacers inside then have nothing to expand into and
// centering tricks (spacer / content / spacer) collapse.
// Honors `fill_width: true` to match Android's row behaviour.
.ifLet(node.fillWidth ? () : nil) { view, _ in view.frame(maxWidth: .infinity) }
.padding(node.paddingEdgeInsets)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
.ifLet(node.onTap) { view, tap in
view.contentShape(Rectangle()).onTapGesture { tap() }
}
.mobGestures(node)
case .box:
MobBox(node: node)
case .label:
// Only span the parent's full width when the caller explicitly
// asks (via `fill_width: true` or a non-leading `text_align`).
// Defaulting to .frame(maxWidth: .infinity) made every Text
// greedily fill its container, which broke centering when a
// Text sat inside a row meant to size to its content (the row
// would inflate to full width and the box's centering had
// nothing to position).
let textShouldFill = node.fillWidth || node.textAlign == "center" || node.textAlign == "right"
Text(node.text ?? "")
.font(node.resolvedFont)
.foregroundColor(node.textColor.map { Color($0) } ?? Color.primary)
.multilineTextAlignment(node.textAlignEnum)
.lineSpacing(node.computedLineSpacing)
.kerning(node.letterSpacing)
.ifLet(textShouldFill ? () : nil) { view, _ in
view.frame(maxWidth: .infinity, alignment: node.frameTextAlignment)
}
.padding(node.paddingEdgeInsets)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
.ifLet(node.onTap) { view, tap in
view.contentShape(Rectangle()).onTapGesture { tap() }
}
.mobGestures(node)
case .icon:
// Resolve logical icon name (e.g. "settings") to the matching
// SF Symbol. textSize controls glyph size; textColor controls tint.
// node.text serves as accessibility label when set.
Image(systemName: sfSymbolName(node.iconName ?? "questionmark"))
.font(.system(size: node.textSize > 0 ? node.textSize : 20))
.foregroundColor(node.textColor.map { Color($0) } ?? Color.primary)
.padding(node.paddingEdgeInsets)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
.ifLet(node.onTap) { view, tap in
view.contentShape(Rectangle()).onTapGesture { tap() }
}
.ifLet(node.text) { view, label in view.accessibilityLabel(label) }
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
.mobGestures(node)
case .button:
// Padding + background INSIDE the Button's label, not outside.
// SwiftUI's Button only registers taps on its content view's
// bounds — applying `.padding()` to the Button itself leaves
// the padded area visually present but not tappable, so users
// tap the visible edge of the button and nothing happens.
// contentShape(Rectangle()) ensures the full padded area is
// hit-testable even when the background is .clear.
Button(action: { node.onTap?() }) {
Text(node.text ?? "")
.font(node.resolvedFont)
.foregroundColor(node.textColor.map { Color($0) } ?? Color.clear)
.lineLimit(1)
.frame(maxWidth: node.fillWidth ? .infinity : nil)
.padding(node.paddingEdgeInsets)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
.contentShape(Rectangle())
}
.clipShape(RoundedRectangle(cornerRadius: node.cornerRadius))
.ifLet(node.accessibilityId) { view, id in
view.accessibilityIdentifier(id)
}
case .scroll:
let isHorizontal = node.axis == "horizontal"
let axes: Axis.Set = isHorizontal ? .horizontal : .vertical
ScrollView(axes, showsIndicators: node.showIndicator) {
if isHorizontal {
HStack(alignment: .top, spacing: 0) {
ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) }
}
.frame(maxHeight: .infinity, alignment: .topLeading)
} else {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) }
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.scrollDismissesKeyboard(.interactively)
.padding(node.paddingEdgeInsets)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
// Expose the node :id on the backing UIScrollView so the test
// harness (Mob.Test.scroll_info/scroll_to) can address it by id.
.ifLet(node.nativeViewId) { view, id in view.accessibilityIdentifier(id) }
// ── Batch 5 Tier 1: scroll position observation ──
// SwiftUI's onScrollGeometryChange is iOS 18+. On older iOS
// there's no clean SwiftUI API for raw offset; UIKit-backed
// alternative pending. Until then, scroll events are silently
// unavailable on iOS 17 (renderer still accepts on_scroll
// props — they just won't fire).
.modifier(MobScrollObserverGate(node: node, isHorizontal: isHorizontal))
case .textField:
let placeholder = node.placeholder ?? ""
let initialText = node.text ?? ""
MobTextField(node: node, placeholder: placeholder, initialText: initialText)
.padding(node.paddingEdgeInsets)
case .toggle:
MobToggle(node: node)
.padding(node.paddingEdgeInsets)
case .slider:
MobSlider(node: node)
.padding(node.paddingEdgeInsets)
case .divider:
Divider()
.frame(height: node.thickness)
.overlay(
node.color.map { Color($0) } ?? Color(UIColor.separator)
)
.padding(node.paddingEdgeInsets)
case .spacer:
if node.fixedSize > 0 {
// Constrain both axes so a sized Spacer works as a fixed
// gap in both VStack (column) and HStack (row). Without
// the width constraint, an HStack treats it as flexible
// alongside other Spacers and the layout collapses.
Spacer().frame(width: node.fixedSize, height: node.fixedSize)
} else {
Spacer()
}
case .image:
MobImage(node: node)
.padding(node.paddingEdgeInsets)
case .lazyList:
ScrollView(.vertical, showsIndicators: true) {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(node.childNodes.enumerated()), id: \.offset) { index, child in
MobNodeView(node: child)
.onAppear {
if index == node.childNodes.count - 1 {
node.onTap?()
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: .infinity)
.padding(node.paddingEdgeInsets)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
.ifLet(node.nativeViewId) { view, id in view.accessibilityIdentifier(id) }
case .progress:
let trackColor = node.color.map { Color($0) } ?? Color.accentColor
if node.value.isNaN {
ProgressView()
.progressViewStyle(.linear)
.tint(trackColor)
.frame(maxWidth: .infinity)
.padding(node.paddingEdgeInsets)
} else {
ProgressView(value: node.value, total: 1.0)
.progressViewStyle(.linear)
.tint(trackColor)
.frame(maxWidth: .infinity)
.padding(node.paddingEdgeInsets)
}
case .tabBar:
let tabs = node.tabDefs as? [[String: Any]] ?? []
MobTabView(node: node, tabs: tabs)
case .video:
if let src = node.src {
MobVideoPlayer(src: src, autoplay: node.videoAutoplay,
loop: node.videoLoop, controls: node.videoControls)
.ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) }
.ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) }
.padding(node.paddingEdgeInsets)
}
case .cameraPreview:
MobCameraPreviewView(facing: node.cameraFacing)
.ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) }
.ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) }
.padding(node.paddingEdgeInsets)
case .webView:
MobWebView(node: node)
.ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) }
.ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) }
.padding(node.paddingEdgeInsets)
case .nativeView:
if let view = MobNativeViewRegistry.shared.view(for: node) {
view.padding(node.paddingEdgeInsets)
}
case .canvas:
MobCanvasView(node: node)
.padding(node.paddingEdgeInsets)
case .gpuView:
MobGpuView(node: node)
.ifLet(node.fixedWidth > 0 ? node.fixedWidth : nil) { v, w in v.frame(width: CGFloat(w)) }
.ifLet(node.fixedHeight > 0 ? node.fixedHeight : nil) { v, h in v.frame(height: CGFloat(h)) }
.padding(node.paddingEdgeInsets)
@unknown default:
EmptyView()
}
}
// Per-node offset — applied uniformly to every node type. Default is
// (0, 0) which is a no-op. Used by SquareTriangle's hexagonal
// snowflake to position rings absolutely within a center-aligned box.
.offset(x: CGFloat(node.offsetX), y: CGFloat(node.offsetY))
// Record on-screen frame + set accessibilityIdentifier for any node
// carrying an :id, so the agent can read positions via the
// element_frames NIF without a screenshot.
.modifier(MobFrameTracker(node: node))
}
}
// MobFrameTracker — for any node with an :id, set it as the accessibility
// identifier and report the element's global frame (logical points) to the C
// registry as it lays out / moves. Untagged nodes pass through untouched, so
// there's no cost unless a dev opts an element in by giving it an :id.
private struct MobFrameTracker: ViewModifier {
let node: MobNode
func body(content: Content) -> some View {
if let id = node.nativeViewId {
content
.accessibilityIdentifier(id)
.background(
GeometryReader { geo in
Color.clear.onChange(of: geo.frame(in: .global), initial: true) { _, frame in
mob_register_frame(
id, Double(frame.minX), Double(frame.minY),
Double(frame.width), Double(frame.height))
}
}
)
} else {
content
}
}
}
// ── Box ──────────────────────────────────────────────────────────────────────
// Extracted from MobNodeView so the conditional sizing chain doesn't blow
// past SwiftUI's type-inference budget. When fixedWidth is set the box
// uses that exact width; otherwise it stretches to fill maxWidth (the
// original behavior). Same logic for fixedHeight (otherwise content-sized).
//
// The fixed-width path is what makes circular ring cells possible without
// a dedicated primitive — set width: N, height: N, corner_radius: N/2,
// border_color + border_width and the box renders as a ring.
private struct MobBox: View {
let node: MobNode
var body: some View {
let alignment: Alignment = boxAlignmentFromString(node.boxAlign)
let stack = ZStack(alignment: alignment) {
ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in
MobNodeView(node: child)
}
}
return Group {
if node.fixedWidth > 0 {
stack.frame(
width: CGFloat(node.fixedWidth),
height: node.fixedHeight > 0 ? CGFloat(node.fixedHeight) : nil,
alignment: alignment
)
} else if node.fillHeight {
// fill_height: true is what lets a wrapping box stretch to the
// viewport so center alignment lands on the visible midpoint
// (e.g. for floating dialogs that need to sit mid-screen
// regardless of their sibling's content size).
stack.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment)
} else {
stack.frame(maxWidth: .infinity, alignment: alignment)
}
}
.padding(node.paddingEdgeInsets)
.mobBoxBackground(node: node)
.overlay(
// Border opt-in via border_color + border_width on the BEAM side.
// When width is 0 (default) the stroke draws nothing — no perf cost.
// .allowsHitTesting(false) is critical: without it, SwiftUI routes
// taps inside the border's bounding rect to the overlay instead of
// the box's children, swallowing tap events on every nested button.
RoundedRectangle(cornerRadius: node.cornerRadius)
.stroke(node.borderColor.map { Color($0) } ?? Color.clear,
lineWidth: node.borderWidth)
.allowsHitTesting(false)
)
.ifLet(node.onTap) { view, tap in
view.contentShape(Rectangle()).onTapGesture { tap() }
}
.mobGestures(node)
// (offset is applied uniformly by MobNodeView's body; not here)
}
}
// Backgrounds for `MobBox`. When the active theme has `glass: true` the BEAM
// renderer sets `useGlass` on every box that has a `background:` so we swap
// the solid fill for a translucent material. Liquid Glass landed on iOS 26;
// on older systems we fall back to `.ultraThinMaterial` (visually similar,
// less expensive). Without `useGlass` the original solid behaviour is kept.
private extension View {
@ViewBuilder
func mobBoxBackground(node: MobNode) -> some View {
let radius = node.cornerRadius
let shape: AnyShape =
radius > 0
? AnyShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
: AnyShape(Rectangle())
if node.useGlass {
// Liquid Glass on iOS 26+; otherwise the closest visual approximation
// that ships in older system SDKs.
//
// `Glass.clear` (vs `Glass.regular`) — the surface is noticeably
// more transparent; what's behind shows through. Card-style
// surfaces look "floating" rather than "frosted". Switch to
// `.regular` if a tinted, opaque-leaning glass is wanted.
if #available(iOS 26.0, *) {
self.glassEffect(.clear, in: shape)
} else {
self.background(.ultraThinMaterial, in: shape)
}
} else {
// `in: shape` so the solid fill is clipped to the corner radius — without
// it the fill is a plain rectangle and only the (separately-stroked)
// border looks rounded, leaving square fill corners on non-glass boxes.
self.background(node.backgroundColor.map { Color($0) } ?? Color.clear, in: shape)
}
}
}
private func boxAlignmentFromString(_ s: String) -> Alignment {
switch s {
case "center": return .center
case "top": return .top
case "top_center": return .top
case "top_trailing": return .topTrailing
case "leading": return .leading
case "trailing": return .trailing
case "bottom": return .bottom
case "bottom_leading": return .bottomLeading
case "bottom_center": return .bottom
case "bottom_trailing": return .bottomTrailing
default: return .topLeading
}
}
// ── Canvas (Mob.Canvas declarative draw spec) ────────────────────────────────
// Renders the node.canvasOps array via SwiftUI Canvas. Each op is an
// NSDictionary with an "op" key plus op-specific fields, pre-resolved by
// the Elixir renderer (color tokens already converted to ARGB integers).
private struct MobCanvasView: View {
let node: MobNode
// Tracks whether the active drag has emitted its "began" sample yet, so the
// first onChanged reports phase "began" and the rest "dragging".
@State private var dragging = false
var body: some View {
let canvas = Canvas { ctx, size in
let ops = node.canvasOps as? [[String: Any]] ?? []
for op in ops {
drawOp(op, in: &ctx, size: size)
}
}
.frame(
width: node.canvasWidth > 0 ? CGFloat(node.canvasWidth) : nil,
height: node.canvasHeight > 0 ? CGFloat(node.canvasHeight) : nil
)
// Finger-drag input: when the node registered an on_drag handle, attach a
// continuous drag recognizer (the iOS analog of Android MobCanvas's
// detectDragGestures). The Canvas frame is sized to the declared logical
// units (points), and draw ops are drawn in that same space, so the
// gesture's local-space location is already in canvas coordinates — no
// pixel→logical rescale needed (unlike Android, where it is).
//
// minimumDistance: 0 is intentional: a finger-drawing canvas wants an
// immediate response and a stationary tap to register as a single point
// (a dot). This is a deliberate divergence from Android's
// detectDragGestures, which has a touch-slop threshold, so a bare tap
// fires a zero-length began/ended drag on iOS but nothing on Android.
if node.onDrag != nil {
canvas.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
// Flip the @State flag only once, on the first sample, so
// a fast drag does not invalidate the view on every move.
let phase: String
if dragging {
phase = "dragging"
} else {
dragging = true
phase = "began"
}
node.onDrag?(
value.translation.width, value.translation.height,
value.location.x, value.location.y, phase
)
}
.onEnded { value in
dragging = false
node.onDrag?(
value.translation.width, value.translation.height,
value.location.x, value.location.y, "ended"
)
}
)
} else {
canvas
}
}
private func drawOp(_ op: [String: Any], in ctx: inout GraphicsContext, size: CGSize) {
guard let opName = op["op"] as? String else { return }
let color = canvasColor(op["color"])
let opacity = (op["opacity"] as? Double) ?? 1.0
let strokeStyle = canvasStrokeStyle(op)
let isFill = (op["fill"] as? Bool) ?? false
ctx.opacity = opacity
defer { ctx.opacity = 1.0 }
switch opName {
case "line":
let path = Path { p in
p.move(to: CGPoint(x: cgNum(op["x1"]), y: cgNum(op["y1"])))
p.addLine(to: CGPoint(x: cgNum(op["x2"]), y: cgNum(op["y2"])))
}
ctx.stroke(path, with: .color(color), style: strokeStyle)
case "circle":
let r = cgNum(op["r"])
let rect = CGRect(x: cgNum(op["x"]) - r, y: cgNum(op["y"]) - r, width: r * 2, height: r * 2)
let path = Path(ellipseIn: rect)
if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) }
case "ellipse":
let rx = cgNum(op["rx"])
let ry = cgNum(op["ry"])
let rect = CGRect(x: cgNum(op["x"]) - rx, y: cgNum(op["y"]) - ry, width: rx * 2, height: ry * 2)
let path = Path(ellipseIn: rect)
if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) }
case "arc":
// Mob.Canvas arc convention: degrees, 0° to the right, sweeping clockwise.
// SwiftUI Path.addArc uses radians; we negate for clockwise from the start.
let center = CGPoint(x: cgNum(op["x"]), y: cgNum(op["y"]))
let r = cgNum(op["r"])
let start = Angle(degrees: cgDouble(op["start_deg"]))
let end = Angle(degrees: cgDouble(op["end_deg"]))
let path = Path { p in
p.addArc(center: center, radius: r, startAngle: start, endAngle: end, clockwise: false)
}
ctx.stroke(path, with: .color(color), style: strokeStyle)
case "rect":
let rect = CGRect(
x: cgNum(op["x"]),
y: cgNum(op["y"]),
width: cgNum(op["w"]),
height: cgNum(op["h"])
)
let radius = cgNum(op["radius"])
let path: Path = radius > 0
? Path(roundedRect: rect, cornerRadius: radius)
: Path(rect)
if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) }
case "path":
guard let pts = op["points"] as? [[Double]], !pts.isEmpty else { return }
let closed = (op["closed"] as? Bool) ?? false
let path = Path { p in
p.move(to: CGPoint(x: pts[0][0], y: pts[0][1]))
for pt in pts.dropFirst() {
p.addLine(to: CGPoint(x: pt[0], y: pt[1]))
}
if closed || isFill { p.closeSubpath() }
}
if isFill { ctx.fill(path, with: .color(color)) } else { ctx.stroke(path, with: .color(color), style: strokeStyle) }
case "text":
let str = (op["text"] as? String) ?? ""
let size = cgNum(op["size"])
let weight = canvasFontWeight(op["weight"] as? String)
var text = Text(str).font(.system(size: size, weight: weight))
if let family = op["family"] as? String, !family.isEmpty {
text = Text(str).font(.custom(family, size: size).weight(weight))
}
let resolved = ctx.resolve(text.foregroundColor(color))
// Anchor: SwiftUI's draw(at:anchor:) takes a UnitPoint.
let anchor: UnitPoint = {
switch op["anchor"] as? String {
case "center": return .leading // y stays top; horizontal center handled by switching
default: return .topLeading
}
}()
// For horizontal anchor handling we measure first.
let measured = resolved.measure(in: CGSize(width: CGFloat.infinity, height: CGFloat.infinity))
let x = cgNum(op["x"])
let y = cgNum(op["y"])
let drawX: CGFloat = {
switch op["anchor"] as? String {
case "center": return x - measured.width / 2
case "end": return x - measured.width
default: return x
}
}()
ctx.draw(resolved, at: CGPoint(x: drawX, y: y), anchor: .topLeading)
_ = anchor // anchor variable unused after switching to manual offset; kept to document intent
case "image":
guard let src = op["source"] as? String else { return }
if let img = UIImage(named: src) {
let rect = CGRect(
x: cgNum(op["x"]),
y: cgNum(op["y"]),
width: cgNum(op["w"]),
height: cgNum(op["h"])
)
ctx.draw(Image(uiImage: img), in: rect)
}
default:
break
}
}
}
// ── Canvas helpers ───────────────────────────────────────────────────────────
private func cgNum(_ value: Any?) -> CGFloat {
if let d = value as? Double { return CGFloat(d) }
if let i = value as? Int { return CGFloat(i) }
if let n = value as? NSNumber { return CGFloat(n.doubleValue) }
return 0
}
private func cgDouble(_ value: Any?) -> Double {
if let d = value as? Double { return d }
if let i = value as? Int { return Double(i) }
if let n = value as? NSNumber { return n.doubleValue }
return 0
}
private func canvasColor(_ value: Any?) -> Color {
// Pre-resolved ARGB integer from the renderer, or hex string fallback.
if let argb = value as? Int {
let a = Double((argb >> 24) & 0xFF) / 255.0
let r = Double((argb >> 16) & 0xFF) / 255.0
let g = Double((argb >> 8) & 0xFF) / 255.0
let b = Double((argb >> 0) & 0xFF) / 255.0
return Color(red: r, green: g, blue: b, opacity: a)
}
if let n = value as? NSNumber {
return canvasColor(n.intValue)
}
if let hex = value as? String, hex.hasPrefix("#") {
let scanner = Scanner(string: String(hex.dropFirst()))
var rgb: UInt64 = 0
if scanner.scanHexInt64(&rgb) {
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double((rgb >> 0) & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
}
return Color.black
}
private func canvasStrokeStyle(_ op: [String: Any]) -> StrokeStyle {
let width = cgNum(op["width"])
let cap: CGLineCap = {
switch op["cap"] as? String {
case "round": return .round
case "square": return .square
default: return .butt
}
}()
let join: CGLineJoin = {
switch op["join"] as? String {
case "round": return .round
case "bevel": return .bevel
default: return .miter
}
}()
let dash: [CGFloat] = (op["dash"] as? [Any])?.compactMap { cgNum($0) } ?? []
return StrokeStyle(
lineWidth: width > 0 ? width : 1,
lineCap: cap,
lineJoin: join,
dash: dash
)
}
private func canvasFontWeight(_ name: String?) -> Font.Weight {
switch name {
case "thin": return .thin
case "light": return .light
case "medium": return .medium
case "semibold": return .semibold
case "bold": return .bold
default: return .regular
}
}
// ── Tab bar ───────────────────────────────────────────────────────────────────
private struct MobTabView: View {
let node: MobNode
let tabs: [[String: Any]]
var body: some View {
let activeId = node.activeTab ?? (tabs.first?["id"] as? String ?? "")
TabView(selection: Binding(
get: { activeId },
set: { newId in node.onTabSelect?(newId) }
)) {
ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in
if index < node.childNodes.count {
let child = node.childNodes[index]
MobNodeView(node: child)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(child.backgroundColor.map { Color($0) } ?? Color.clear)
.tabItem {
Label(
tab["label"] as? String ?? "",
systemImage: sfSymbolName(tab["icon"] as? String ?? "circle")
)
}
.tag(tab["id"] as? String ?? "\(index)")
.ignoresSafeArea(.container, edges: .bottom)
}
}
}
}
}
// ── Video player ─────────────────────────────────────────────────────────────
private struct MobVideoPlayer: UIViewControllerRepresentable {
let src: String
let autoplay: Bool
let loop: Bool
let controls: Bool
func makeUIViewController(context: Context) -> AVPlayerViewController {
let vc = AVPlayerViewController()
let url: URL
if src.hasPrefix("http://") || src.hasPrefix("https://") {
url = URL(string: src)!
} else {
url = URL(fileURLWithPath: src)
}
let player = AVPlayer(url: url)
vc.player = player
vc.showsPlaybackControls = controls
if loop {
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem, queue: .main) { _ in
player.seek(to: .zero)
player.play()
}
}
if autoplay { player.play() }
return vc
}
func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {}
}
// ── Camera preview ────────────────────────────────────────────────────────
// Custom UIView whose backing layer is an AVCaptureVideoPreviewLayer.
// UIKit automatically keeps the layer frame in sync with the view bounds —
// no manual frame management required.
private class CameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var cameraLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}
private struct MobCameraPreviewView: UIViewRepresentable {
let facing: String
func makeUIView(context: Context) -> CameraPreviewUIView {
let view = CameraPreviewUIView()
view.backgroundColor = .black
view.cameraLayer.videoGravity = .resizeAspectFill
// Connect immediately if the session is already running.
view.cameraLayer.session = g_preview_session
rotatePreviewConnection(view: view)
// Observe future session changes (start, stop, facing swap).
context.coordinator.startObserving(view: view)
return view
}
// Pin the preview to portrait so what the user sees matches the
// upright frame we ship to the model. Without this, the sensor's
// landscape-native output renders sideways in a portrait UI.
private func rotatePreviewConnection(view: CameraPreviewUIView) {
guard let conn = view.cameraLayer.connection else { return }
if #available(iOS 17.0, *) {
if conn.isVideoRotationAngleSupported(90) {
conn.videoRotationAngle = 90
}
} else if conn.isVideoOrientationSupported {
conn.videoOrientation = .portrait
}
}
func updateUIView(_ view: CameraPreviewUIView, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator() }
class Coordinator: NSObject {
private var observer: NSObjectProtocol?
private weak var hostView: CameraPreviewUIView?
func startObserving(view: CameraPreviewUIView) {
hostView = view
observer = NotificationCenter.default.addObserver(
forName: .mobCameraSessionChanged,
object: nil,
queue: .main
) { [weak self] _ in
guard let view = self?.hostView else { return }
view.cameraLayer.session = g_preview_session
if let conn = view.cameraLayer.connection {
if #available(iOS 17.0, *) {
if conn.isVideoRotationAngleSupported(90) {
conn.videoRotationAngle = 90
}
} else if conn.isVideoOrientationSupported {
conn.videoOrientation = .portrait
}
}
}
}
deinit {
if let obs = observer { NotificationCenter.default.removeObserver(obs) }
}
}
}
extension Notification.Name {
static let mobCameraSessionChanged = Notification.Name("MobCameraSessionChanged")
}
// ── WebView ───────────────────────────────────────────────────────────────────
private let kMobJsShimSwift =
"(function(){" +
"if(window.mob)return;" +
"var _h=[];" +
"window.mob={" +
"send:function(d){window.webkit.messageHandlers.mob.postMessage(JSON.stringify(d));}," +
"onMessage:function(h){_h.push(h);return function(){_h=_h.filter(function(x){return x!==h;});};}," +
"_dispatch:function(j){try{var d=JSON.parse(j);_h.forEach(function(h){h(d);});}catch(e){}}" +
"};" +
"})();"
private struct MobWebView: View {
let node: MobNode
var body: some View {
VStack(spacing: 0) {
if let title = node.webViewTitle {
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
MobWKWebView(node: node)
}
}
}
private struct MobWKWebView: UIViewRepresentable {
let node: MobNode
func makeCoordinator() -> Coordinator { Coordinator(node: node) }
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.userContentController.add(context.coordinator, name: "mob")
let shim = WKUserScript(source: kMobJsShimSwift,
injectionTime: .atDocumentStart,
forMainFrameOnly: true)
config.userContentController.addUserScript(shim)
let wv = WKWebView(frame: .zero, configuration: config)
wv.navigationDelegate = context.coordinator
g_webview = wv
if let urlStr = node.webViewUrl, let url = URL(string: urlStr) {
wv.load(URLRequest(url: url))
}
return wv
}
func updateUIView(_ wv: WKWebView, context: Context) {
g_webview = wv
context.coordinator.node = node
}
class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
var node: MobNode
init(node: MobNode) { self.node = node }
// JS → Elixir: window.mob.send(data) arrives here.
// Delegates to mob_deliver_webview_message() in mob_nif.m.
func userContentController(_ ucc: WKUserContentController,
didReceive message: WKScriptMessage) {
guard message.name == "mob", let json = message.body as? String else { return }
mob_deliver_webview_message(json)
}
// URL whitelist enforcement.
func webView(_ wv: WKWebView,
decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url?.absoluteString else {
decisionHandler(.allow); return
}
let allowStr = node.webViewAllow ?? ""
let allowList = allowStr.split(separator: ",").map(String.init).filter { !$0.isEmpty }
guard !allowList.isEmpty else { decisionHandler(.allow); return }
if allowList.contains(where: { url.hasPrefix($0) }) {
decisionHandler(.allow)
} else {
decisionHandler(.cancel)
mob_deliver_webview_blocked(url)
}
}
}
}
// ── Input component views ──────────────────────────────────────────────────
private struct MobTextField: View {
let node: MobNode
let placeholder: String
let initialText: String
@State private var text: String
@FocusState private var isFocused: Bool
init(node: MobNode, placeholder: String, initialText: String) {
self.node = node
self.placeholder = placeholder
self.initialText = initialText
_text = State(initialValue: initialText)
}
private var keyboardType: UIKeyboardType {
switch node.keyboardTypeStr {
case "number": return .numberPad
case "decimal": return .decimalPad
case "email": return .emailAddress
case "phone": return .phonePad
case "url": return .URL
default: return .default
}
}
private var submitLabel: SubmitLabel {
switch node.returnKeyStr {
case "next": return .next
case "go": return .go
case "search": return .search
case "send": return .send
default: return .done
}
}
@ViewBuilder
private var field: some View {
if node.isSecure {
SecureField(placeholder, text: $text)
} else {
TextField(placeholder, text: $text)
}
}
var body: some View {
field
.focused($isFocused)
.keyboardType(keyboardType)
.submitLabel(submitLabel)
.onSubmit {
node.onSubmit?()
// dismiss for terminal actions; "next" intentionally keeps keyboard open
if node.returnKeyStr != "next" { isFocused = false }
}
.onChange(of: text) { _, newValue in
node.onChangeStr?(newValue)
}
.onChange(of: isFocused) { _, focused in
if focused { node.onFocus?() } else { node.onBlur?() }
}
// Sync from parent when the `value:` prop changes externally —
// but only if the user isn't actively typing (which would yank
// the cursor and overwrite their in-flight edits). This is the
// controlled-input fix for the case where Elixir code updates
// the bound value via Mob.Socket.assign without user input.
.onChange(of: initialText) { _, newValue in
if !isFocused && text != newValue {
text = newValue
}
}
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
// Only contribute keyboard-toolbar items when THIS field is
// focused. Without the `if isFocused` guard, every MobTextField
// on the screen contributes its own Done button to the shared
// keyboard accessory toolbar — producing N stacked Done buttons
// when there are N fields visible. Guarded, only the focused
// field's button shows.
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
if isFocused {
Spacer()
Button("Done") { isFocused = false }
}
}
}
}
}
private struct MobToggle: View {
let node: MobNode
@State private var isOn: Bool
init(node: MobNode) {
self.node = node
_isOn = State(initialValue: node.checked)
}
var body: some View {
let label = node.text ?? ""
Toggle(label, isOn: $isOn)
.onChange(of: isOn) { _, newValue in
node.onChangeBool?(newValue)
}
.frame(maxWidth: .infinity, alignment: .leading)
// issues.md #8: SwiftUI's Toggle("Label", …) initializer does
// not propagate the label string into the underlying control's
// accessibilityLabel — the AX tree exposes the visual Text as
// a separate node and the Switch as a button with empty label.
// Setting it here lets `Mob.Test.toggle(node, "Notifications")`
// find the toggle via plain label match.
.accessibilityLabel(label)
}
}
private struct MobSlider: View {
let node: MobNode
@State private var value: Double
init(node: MobNode) {
self.node = node
let initial = node.value.isNaN ? node.minValue : node.value
_value = State(initialValue: initial)
}
var body: some View {
// issues.md #7: SwiftUI's plain Slider doesn't emit AX adjustable
// actions unless `.accessibilityAdjustableAction` is attached. Without
// it, VoiceOver users (and `Mob.Test.adjust_slider/4` which calls the
// same AX API) see :ok back from increment/decrement but the value
// never changes. Default step is (max - min) / 10 — the same default
// VoiceOver picks for native UISlider when no explicit step is set.
let step = (node.maxValue - node.minValue) / 10.0
Slider(value: $value, in: node.minValue...node.maxValue)
.onChange(of: value) { _, newValue in
node.onChangeFloat?(newValue)
}
.tint(node.color.map { Color($0) } ?? Color.accentColor)
.frame(maxWidth: .infinity)
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
value = Swift.min(value + step, node.maxValue)
node.onChangeFloat?(value)
case .decrement:
value = Swift.max(value - step, node.minValue)
node.onChangeFloat?(value)
@unknown default:
break
}
}
}
}
private struct MobImage: View {
let node: MobNode
private var contentMode: ContentMode {
node.contentModeStr == "fill" ? .fill : .fit
}
private var placeholder: Color {
node.placeholderColor.map { Color($0) } ?? Color(UIColor.systemGray5)
}
var body: some View {
Group {
if let src = node.src {
if src.hasPrefix("http://") || src.hasPrefix("https://"),
let url = URL(string: src) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image.resizable().aspectRatio(contentMode: contentMode)
default:
placeholder
}
}
} else if let uiImage = UIImage(contentsOfFile: src) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: contentMode)
} else {
placeholder
}
} else {
placeholder
}
}
.frame(
width: node.fixedWidth > 0 ? node.fixedWidth : nil,
height: node.fixedHeight > 0 ? node.fixedHeight : nil
)
.clipShape(RoundedRectangle(cornerRadius: node.cornerRadius))
}
}
// ── Root view — observed by the hosting controller ─────────────────────────
public struct MobRootView: View {
@ObservedObject var model = MobViewModel.shared
@Environment(\.colorScheme) private var colorScheme
@State private var currentRoot: MobNode?
@State private var currentTransition: String = "none"
// Local mirror of model.navVersion so the .id() change happens INSIDE
// the withAnimation block (the model's @Published value changes via
// SwiftUI observation, which doesn't carry the animation context and
// produces a default crossfade instead of the .move transition).
@State private var currentNavVersion: Int = 0
public init() {}
public var body: some View {
ZStack {
if let root = currentRoot {
MobNodeView(node: root)
// .id changes only on navigation (push/pop/reset), so
// typing in a TextField doesn't tear the view down.
// Driven by currentNavVersion (not model.navVersion)
// so the change happens inside withAnimation — see
// .onChange(of: model.rootVersion) below.
.id(currentNavVersion)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.transition(navTransition(currentTransition))
} else {
ZStack {
Color.black.ignoresSafeArea()
VStack(spacing: 20) {
if let error = model.startupError {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 44))
.foregroundColor(Color.orange)
Text("Startup Error")
.font(.system(size: 20, weight: .bold))
.foregroundColor(.white)
Text(error)
.font(.system(size: 13, design: .monospaced))
.foregroundColor(Color(red: 0.9, green: 0.5, blue: 0.5))
.multilineTextAlignment(.center)
.padding(.horizontal, 28)
} else {
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
.scaleEffect(1.3)
Text(model.startupPhase)
.font(.system(size: 17, weight: .medium))
.foregroundColor(.white.opacity(0.75))
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity)
}
}
.ignoresSafeArea(.container, edges: [.bottom, .horizontal])
.onChange(of: model.rootVersion) {
let t = model.transition
let newRoot = model.root
let newNavVersion = model.navVersion
// Capture transition before the animation block so the modifier
// sees the right value when the new view is inserted.
currentTransition = t
// Log every nav transition so log-tail-based checks can verify
// the animation fired without resorting to video recording.
// Format: [MobNav] transition=<push|pop|reset|none> navVersion=<n>
if t != "none" {
NSLog("[MobNav] transition=%@ navVersion=%d", t, newNavVersion)
}
if let animation = navAnimation(t) {
withAnimation(animation) {
currentRoot = newRoot
// Apply navVersion inside withAnimation so the .id()
// change carries the animation context — without this
// SwiftUI replaces the view via default crossfade and
// the .move transition never plays.
currentNavVersion = newNavVersion
}
} else {
currentRoot = newRoot
currentNavVersion = newNavVersion
}
}
// Notify Elixir when the OS appearance toggles so subscribers
// (Mob.Device → Mob.Theme.Adaptive consumers) can re-resolve.
// SwiftUI re-evaluates `colorScheme` automatically on system change,
// so this fires reliably without polling.
.onChange(of: colorScheme) { _, newScheme in
mob_notify_color_scheme(newScheme == .dark ? "dark" : "light")
}
}
private func navTransition(_ t: String) -> AnyTransition {
switch t {
case "push":
return .asymmetric(
insertion: .move(edge: .trailing),
removal: .move(edge: .leading)
)
case "pop":
return .asymmetric(
insertion: .move(edge: .leading),
removal: .move(edge: .trailing)
)
case "reset":
return .opacity
default:
return .identity
}
}
private func navAnimation(_ t: String) -> Animation? {
switch t {
case "push", "pop":
return .spring(response: 0.3, dampingFraction: 0.85)
case "reset":
return .easeInOut(duration: 0.25)
default:
return nil
}
}
}
// MARK: - Batch 5: scroll position observation
//
// MobScrollObserver wires SwiftUI's scroll-geometry observer to MobNode's
// closures. Tier 1 (raw deltas) goes through node.onScroll which is throttled
// native-side. Tier 2 (semantic begin/end/top) is derived here. Tier 3 (parallax,
// fade-on-scroll, sticky) is rendered with no BEAM round-trip.
// MobScrollObserverGate applies the iOS 18+ observer when available and
// falls through to a no-op on older iOS. Once a UIKit-backed observer for
// iOS 17 lands, this is where the alternative would dispatch.
struct MobScrollObserverGate: ViewModifier {
let node: MobNode
let isHorizontal: Bool
func body(content: Content) -> some View {
if #available(iOS 18.0, *) {
content.modifier(MobScrollObserver(node: node, isHorizontal: isHorizontal))
} else {
content
}
}
}
// MobScrollObserver wires SwiftUI's onScrollGeometryChange (iOS 18+) to the
// MobNode closures populated by mob_nif.m. Throttling and delta-thresholding
// happen native-side in mob_send_scroll, so this modifier just forwards every
// geometry change. End-of-scroll is detected by a debounced "no motion for N
// ms" timer.
@available(iOS 18.0, *)
struct MobScrollObserver: ViewModifier {
let node: MobNode
let isHorizontal: Bool
@State private var lastX: CGFloat = 0
@State private var lastY: CGFloat = 0
@State private var lastTs: TimeInterval = 0
@State private var hasBegun: Bool = false
@State private var pastThreshold: Bool = false
@State private var endTask: Task<Void, Never>?
private static let endDebounceMs: Int = 150
func body(content: Content) -> some View {
content
.onScrollGeometryChange(for: CGPoint.self, of: { $0.contentOffset }) { _, offset in
let now = ProcessInfo.processInfo.systemUptime
let dt = lastTs > 0 ? now - lastTs : 0
let x = offset.x
let y = offset.y
let dx = x - lastX
let dy = y - lastY
let vx = dt > 0 ? dx / CGFloat(dt) : 0
let vy = dt > 0 ? dy / CGFloat(dt) : 0
if !hasBegun {
hasBegun = true
node.onScrollBegan?()
node.onScroll?(0, 0, x, y, 0, 0, "began")
} else {
node.onScroll?(dx, dy, x, y, vx, vy, "dragging")
}
// Tier 2 — top reached (fires on entering y == 0)
if y <= 0.001 && lastY > 0.001 {
node.onTopReached?()
}
// Tier 2 — scrolled-past (latched, only fires on transition)
let threshold = node.scrolledPastThreshold
if threshold > 0 {
let nowPast = (isHorizontal ? x : y) > threshold
if nowPast && !pastThreshold {
node.onScrolledPast?()
}
pastThreshold = nowPast
}
lastX = x
lastY = y
lastTs = now
// Debounced scroll-ended detector. Cancel any prior task and
// schedule a fresh one — fires only after motion stops for
// endDebounceMs.
endTask?.cancel()
let ms = Self.endDebounceMs
endTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
if Task.isCancelled { return }
if hasBegun {
node.onScrollEnded?()
node.onScrollSettled?()
node.onScroll?(0, 0, lastX, lastY, 0, 0, "ended")
hasBegun = false
}
}
}
}
}