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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 47 additions & 1 deletion ios/MobRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -623,8 +623,12 @@
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)
Expand All @@ -634,6 +638,48 @@
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) {
Expand Down Expand Up @@ -903,7 +949,7 @@
// no manual frame management required.
private class CameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var cameraLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }

Check warning on line 952 in ios/MobRootView.swift

View workflow job for this annotation

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

Force casts should be avoided (force_cast)
}

private struct MobCameraPreviewView: UIViewRepresentable {
Expand Down
16 changes: 16 additions & 0 deletions test/mob/renderer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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: []}
Expand Down
Loading