Current section

Files

Jump to
mob ios MobRootView.swift
Raw

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) }
}
.frame(maxWidth: .infinity, alignment: .leading)
.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
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)
// ── 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)
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))
}
}
// ── 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)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
.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)
}
}
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
var body: some View {
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
)
}
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
}
}
var body: some View {
TextField(placeholder, text: $text)
.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
}
}
}
}
}