Current section

Files

Jump to
dala ios DalaRootView.swift
Raw

ios/DalaRootView.swift

// DalaRootView.swift — SwiftUI entry point. Observes DalaViewModel and renders the
// node tree pushed by BEAM NIFs via DalaViewModel.setRoot().
import AVKit
import SwiftUI
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"
//
// DalaNativeViewRegistry.shared.register("MyApp_ChartComponent") { props, send in
// AnyView(ChartView(data: props["data"]) { index in
// send("tapped", ["index": index])
// })
// }
public typealias DalaNativeSend = (_ event: String, _ payload: [String: Any]) -> Void
public typealias DalaNativeViewFactory = (_ props: [String: Any], _ send: @escaping DalaNativeSend) ->
AnyView
public final class DalaNativeViewRegistry {
public static let shared = DalaNativeViewRegistry()
private var factories: [String: DalaNativeViewFactory] = [:]
public func register(_ name: String, factory: @escaping DalaNativeViewFactory) {
factories[name] = factory
}
func view(for node: DalaNode) -> 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: DalaNativeSend = { event, payload in
if let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8)
{
dala_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 Dala 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 dalaGestures(_ node: DalaNode) -> 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 DalaNode to be used as ForEach identity (NSObject provides hash/isEqual).
extension DalaNode: Identifiable {
public var id: ObjectIdentifier { ObjectIdentifier(self) }
}
// Equatable conformance for SwiftUI diffing optimization.
// SwiftUI uses Equatable to skip re-rendering unchanged subtrees.
// We compare the properties that actually affect rendering.
extension DalaNode: Equatable {
public static func == (lhs: DalaNode, rhs: DalaNode) -> Bool {
// Fast path: same object
if lhs === rhs { return true }
// Compare identity and core rendering properties
guard lhs.nodeType == rhs.nodeType,
lhs.nodeId == rhs.nodeId,
lhs.text == rhs.text,
lhs.textSize == rhs.textSize,
lhs.textColor == rhs.textColor,
lhs.backgroundColor == rhs.backgroundColor,
lhs.cornerRadius == rhs.cornerRadius,
lhs.borderWidth == rhs.borderWidth,
lhs.borderColor == rhs.borderColor,
lhs.paddingTop == rhs.paddingTop,
lhs.paddingBottom == rhs.paddingBottom,
lhs.paddingLeading == rhs.paddingLeading,
lhs.paddingTrailing == rhs.paddingTrailing,
lhs.fillWidth == rhs.fillWidth,
lhs.fixedWidth == rhs.fixedWidth,
lhs.fixedHeight == rhs.fixedHeight,
lhs.iconName == rhs.iconName,
lhs.src == rhs.src,
lhs.placeholder == rhs.placeholder,
lhs.value == rhs.value,
lhs.thickness == rhs.thickness,
lhs.axis == rhs.axis,
lhs.showIndicator == rhs.showIndicator,
lhs.letterSpacing == rhs.letterSpacing,
lhs.videoAutoplay == rhs.videoAutoplay,
lhs.videoLoop == rhs.videoLoop,
lhs.videoControls == rhs.videoControls,
lhs.cameraFacing == rhs.cameraFacing,
lhs.onTap != nil == rhs.onTap != nil else {
return false
}
// Compare children count (fast check before recursive comparison)
if lhs.childNodes.count != rhs.childNodes.count { return false }
// For performance, only compare child IDs (not full equality)
// Full equality would cause O(n²) comparisons in deep trees
for (lhsChild, rhsChild) in zip(lhs.childNodes, rhs.childNodes) {
if lhsChild.nodeId != rhsChild.nodeId { return false }
}
return true
}
}
// 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"
default: return logical // raw SF Symbol pass-through
}
}
extension DalaNode {
var childNodes: [DalaNode] {
children.compactMap { $0 as? DalaNode }
}
/// EdgeInsets that honour per-edge padding props (padding_top etc.).
/// Falls back to the uniform `padding` value for any unset edge.
/// Cached to avoid recomputation on every SwiftUI render cycle.
var paddingEdgeInsets: EdgeInsets {
// Use objc associated objects for caching since we can't modify DalaNode directly
// For now, compute each time but optimize by avoiding redundant calculations
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.
/// Cached to avoid font resolution on every render cycle.
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 DalaNodeView: View, Equatable {
let node: DalaNode
// Equatable conformance for SwiftUI diffing
// This allows SwiftUI to skip re-rendering when node hasn't changed
static func == (lhs: DalaNodeView, rhs: DalaNodeView) -> Bool {
return lhs.node == rhs.node
}
var body: some View {
Group {
switch node.nodeType {
case .column:
VStack(alignment: .leading, spacing: node.gap) {
ForEach(node.childNodes) { child in
DalaNodeView(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() }
}
.dalaGestures(node)
// Use drawingGroup for columns with many children to improve performance
.modifier(DrawingGroupModifier(shouldApply: node.childNodes.count > 10))
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
case .row:
HStack(spacing: node.gap) {
ForEach(node.childNodes) { child in
DalaNodeView(node: child)
}
}
.padding(node.paddingEdgeInsets)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
.ifLet(node.onTap) { view, tap in
view.contentShape(Rectangle()).onTapGesture { tap() }
}
.dalaGestures(node)
// Use drawingGroup for rows with many children
.modifier(DrawingGroupModifier(shouldApply: node.childNodes.count > 10))
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
case .box:
ZStack(alignment: .topLeading) {
ForEach(node.childNodes) { child in
DalaNodeView(node: child)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.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() }
}
.dalaGestures(node)
// Use drawingGroup for boxes with many children
.modifier(DrawingGroupModifier(shouldApply: node.childNodes.count > 10))
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
case .label:
Text(node.text ?? "")
.font(node.resolvedFont)
.foregroundColor(node.textColor.map { Color($0) } ?? Color.primary)
.multilineTextAlignment(node.textAlignEnum)
.lineSpacing(node.computedLineSpacing)
.kerning(node.letterSpacing)
.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() }
}
.dalaGestures(node)
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
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) }
.dalaGestures(node)
case .button:
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)
.clipShape(RoundedRectangle(cornerRadius: node.cornerRadius))
.disabled(node.disabled)
.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(node.childNodes) { child in
DalaNodeView(node: child)
}
}
.frame(maxHeight: .infinity, alignment: .topLeading)
} else {
VStack(alignment: .leading, spacing: 0) {
ForEach(node.childNodes) { child in
DalaNodeView(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(DalaScrollObserverGate(node: node, isHorizontal: isHorizontal))
case .textField:
let placeholder = node.placeholder ?? ""
let initialText = node.text ?? ""
DalaTextField(node: node, placeholder: placeholder, initialText: initialText)
.padding(node.paddingEdgeInsets)
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
case .toggle:
DalaToggle(node: node)
.padding(node.paddingEdgeInsets)
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
case .slider:
DalaSlider(node: node)
.padding(node.paddingEdgeInsets)
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
case .divider:
Divider()
.frame(height: node.thickness)
.overlay(
node.color.map { Color($0) } ?? Color(UIColor.separator)
)
.padding(node.paddingEdgeInsets)
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
case .spacer:
if node.fixedSize > 0 {
Spacer().frame(minHeight: node.fixedSize, maxHeight: node.fixedSize)
} else {
Spacer()
}
case .image:
DalaImage(node: node)
.padding(node.paddingEdgeInsets)
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
case .lazyList:
ScrollView(.vertical, showsIndicators: true) {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(node.childNodes) { child in
DalaNodeView(node: child)
.onAppear {
if child == node.childNodes.last {
node.onTap?()
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: .infinity)
.padding(node.paddingEdgeInsets)
.background(node.backgroundColor.map { Color($0) } ?? Color.clear)
// Use drawingGroup for lazy lists with many items to improve performance
.drawingGroup(opaque: false)
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)
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
} else {
ProgressView(value: node.value, total: 1.0)
.progressViewStyle(.linear)
.tint(trackColor)
.frame(maxWidth: .infinity)
.padding(node.paddingEdgeInsets)
.ifLet(node.accessibilityId) { view, id in view.accessibilityIdentifier(id) }
}
case .tabBar:
let tabs = node.tabDefs as? [[String: Any]] ?? []
DalaTabView(node: node, tabs: tabs)
case .video:
if let src = node.src {
DalaVideoPlayer(
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:
DalaCameraPreviewView(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:
DalaWebView(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 = DalaNativeViewRegistry.shared.view(for: node) {
view.padding(node.paddingEdgeInsets)
}
@unknown default:
EmptyView()
}
}
}
}
// ── Tab bar ───────────────────────────────────────────────────────────────────
private struct DalaTabView: View {
let node: DalaNode
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]
DalaNodeView(node: child)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(child.backgroundColor.map { Color($0) } ?? Color.clear)
.tabItem {
Label(
tab["label"] as? String ?? "",
systemImage: tab["icon"] as? String ?? "circle"
)
}
.tag(tab["id"] as? String ?? "\(index)")
.ignoresSafeArea(.container, edges: .bottom)
}
}
}
}
}
// ── Video player ─────────────────────────────────────────────────────────────
private struct DalaVideoPlayer: UIViewControllerRepresentable {
let src: String
let autoplay: Bool
let loop: Bool
let controls: Bool
// Store observer token for cleanup
@State private var observerToken: NSObjectProtocol?
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 {
// Store observer token for later removal
observerToken = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem, queue: .main
) { [weak player] _ in
player?.seek(to: .zero)
player?.play()
}
}
if autoplay { player.play() }
return vc
}
func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {}
// Cleanup when view disappears
func dismantleUIViewControllerRepresentation() {
if let token = observerToken {
NotificationCenter.default.removeObserver(token)
observerToken = nil
}
}
}
// ── 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 DalaCameraPreviewView: 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
// Observe future session changes (start, stop, facing swap).
context.coordinator.startObserving(view: view)
return view
}
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: .dalaCameraSessionChanged,
object: nil,
queue: .main
) { [weak self] _ in
self?.hostView?.cameraLayer.session = g_preview_session
}
}
deinit {
if let obs = observer { NotificationCenter.default.removeObserver(obs) }
}
}
}
extension Notification.Name {
static let dalaCameraSessionChanged = Notification.Name("DalaCameraSessionChanged")
}
// ── WebView ───────────────────────────────────────────────────────────────────
private let kDalaJsShimSwift =
"(function(){" + "if(window.dala)return;" + "var _h=[];" + "window.dala={"
+ "send:function(d){window.webkit.messageHandlers.dala.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 DalaWebView: View {
let node: DalaNode
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)
}
DalaWKWebView(node: node)
}
}
}
private struct DalaWKWebView: UIViewRepresentable {
let node: DalaNode
func makeCoordinator() -> Coordinator { Coordinator(node: node) }
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.userContentController.add(context.coordinator, name: "dala")
let shim = WKUserScript(
source: kDalaJsShimSwift,
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: DalaNode
init(node: DalaNode) { self.node = node }
// JS → Elixir: window.dala.send(data) arrives here.
// Delegates to dala_deliver_webview_message() in dala_nif.m.
func userContentController(
_ ucc: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "dala", let json = message.body as? String else { return }
dala_deliver_webview_message(json)
}
// Handle JS evaluation results and deliver to Elixir
func webView(_ wv: WKWebView, didFinishEvaluatingJavaScript jsResult: Any?, error: Error?) {
if let error = error {
let errorJson = "{\"error\": \"\(error.localizedDescription)\""}\n dala_deliver_webview_eval_result(errorJson)
} else if let result = jsResult {
let json: String
if let stringResult = result as? String {
json = "{\"result\": \"\(stringResult)\""}\n } else if let numberResult = result as? NSNumber {
json = "{\"result\": \(numberResult)}\n } else if let boolResult = result as? Bool {
json = "{\"result\": \(boolResult)}\n } else {
json = "{\"result\": null}"\n }\n dala_deliver_webview_eval_result(json)\n }\n }
// Take screenshot of WebView content
func takeScreenshot(_ wv: WKWebView, completion: @escaping (Data?) -> Void) {
let config = WKSnapshotConfiguration()
wv.takeSnapshot(with: config) { image, error in
if let image = image {
completion(image.pngData())
} else {
completion(nil)
}
}
}
// 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)
dala_deliver_webview_blocked(url)
}
}
}
}
// ── Input component views ──────────────────────────────────────────────────
private struct DalaTextField: View {
let node: DalaNode
let placeholder: String
let initialText: String
@State private var text: String
@FocusState private var isFocused: Bool
init(node: DalaNode, 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 Dala.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 DalaTextField
// 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 DalaToggle: View {
let node: DalaNode
@State private var isOn: Bool
init(node: DalaNode) {
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)
}
}
private struct DalaSlider: View {
let node: DalaNode
@State private var value: Double
init(node: DalaNode) {
self.node = node
let initial = node.value.isNaN ? node.minValue : node.value
_value = State(initialValue: initial)
}
var body: some View {
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)
}
}
private struct DalaImage: View {
let node: DalaNode
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 DalaRootView: View {
@ObservedObject var model = DalaViewModel.shared
@Environment(\.colorScheme) private var colorScheme
@State private var currentRoot: DalaNode? = nil
@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 {
DalaNodeView(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))
// Disable implicit animations for non-navigation updates
// This prevents SwiftUI from animating state changes like
// text field updates, toggle changes, etc.
.animation(nil, value: root)
} 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
// Skip update if root hasn't actually changed
// This avoids unnecessary SwiftUI re-evaluations
if let current = currentRoot, let new = newRoot, current == new && t == "none" {
return
}
// 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: [DalaNav] transition=<push|pop|reset|none> navVersion=<n>
if t != "none" {
NSLog("[DalaNav] 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
// (Dala.Device.Device → Dala.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
dala_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
//
// DalaScrollObserver wires SwiftUI's scroll-geometry observer to DalaNode'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.
// DalaScrollObserverGate 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 DalaScrollObserverGate: ViewModifier {
let node: DalaNode
let isHorizontal: Bool
func body(content: Content) -> some View {
if #available(iOS 18.0, *) {
content.modifier(DalaScrollObserver(node: node, isHorizontal: isHorizontal))
} else {
content
}
}
}
// DalaScrollObserver wires SwiftUI's onScrollGeometryChange (iOS 18+) to the
// DalaNode closures populated by dala_nif.m. Throttling and delta-thresholding
// happen native-side in dala_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 DalaScrollObserver: ViewModifier {
let node: DalaNode
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>? = nil
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
}
}
}
}
}
// MARK: - Performance Modifiers
/// Conditional drawingGroup modifier.
/// Use drawingGroup for complex views with many children to improve rendering performance.
/// The `shouldApply` parameter allows conditional application based on view complexity.
struct DrawingGroupModifier: ViewModifier {
let shouldApply: Bool
func body(content: Content) -> some View {
if shouldApply {
content.drawingGroup(opaque: false)
} else {
content
}
}
}