diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6f25a..b7fb273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). --- +## [Unreleased] + +### Fixed +- **iOS canvas now delivers finger-drag (`on_drag`) — at parity with Android.** + The SwiftUI `MobCanvasView` rendered draw ops but attached no drag recognizer, + so a canvas's `on_drag` handle (wired through the NIF to `node.onDrag`) was + never invoked — continuous finger-drag was dead on iOS, while Android's + `MobCanvas` had `detectDragGestures`. Added a canvas-scoped + `DragGesture(minimumDistance: 0)` that calls `node.onDrag` with + began/dragging/ended phases; the gesture's local-space location is already in + canvas logical units (the frame is sized to the declared width/height), so no + rescale is needed. Verified on a physical iPhone (iOS 26.5): a finger-drawing + screen with a color picker and thickness control routes drags and renders + strokes correctly. + +--- + ## [0.7.4] - 2026-06-20 ### Fixed diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index b9bdfbc..adb36d7 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -623,8 +623,12 @@ private func boxAlignmentFromString(_ s: String) -> Alignment { 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 { - Canvas { ctx, size in + let canvas = Canvas { ctx, size in let ops = node.canvasOps as? [[String: Any]] ?? [] for op in ops { drawOp(op, in: &ctx, size: size) @@ -634,6 +638,48 @@ private struct MobCanvasView: View { 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) { diff --git a/test/mob/renderer_test.exs b/test/mob/renderer_test.exs index 80435d5..fd8d26a 100644 --- a/test/mob/renderer_test.exs +++ b/test/mob/renderer_test.exs @@ -176,6 +176,22 @@ defmodule Mob.RendererTest do assert is_integer(decoded["props"]["on_tap"]) end + test "on_drag {pid, tag} is replaced by integer handle" do + pid = self() + tree = %{type: :canvas, props: %{on_drag: {pid, :draw}}, children: []} + Renderer.render(tree, :ios, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + assert is_integer(decoded["props"]["on_drag"]) + end + + test "register_tap is called for an on_drag handle" do + pid = self() + tree = %{type: :canvas, props: %{on_drag: {pid, :draw}}, children: []} + Renderer.render(tree, :ios, MockNIF) + assert Enum.any?(MockNIF.calls(), fn {f, _} -> f == :register_tap end) + end + test "on_change {pid, tag} is replaced by integer handle" do pid = self() tree = %{type: :text_field, props: %{value: "hi", on_change: {pid, :name}}, children: []}