From c495222a20ce6f7e251b627fb8fd78d80243bb5a Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 18:41:10 -0400 Subject: [PATCH 1/5] Add DragGesture and the long press gesture --- .../Sources/AndroidSwiftUICore/Gesture.swift | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 AndroidSwiftUICore/Sources/AndroidSwiftUICore/Gesture.swift diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Gesture.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Gesture.swift new file mode 100644 index 0000000..da1d642 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Gesture.swift @@ -0,0 +1,109 @@ +// +// Gesture.swift +// AndroidSwiftUICore +// +// Continuous gestures. A drag reports through the fixed string callback rather +// than growing the bridge with a two-number entry point: the interpreter sends +// ";,;," in points, which rebuilds a Value here. +// + +import Foundation + +/// A gesture recognized on a view. `.gesture(_:)` currently accepts `DragGesture`. +public protocol Gesture {} + +public struct DragGesture: Gesture { + + /// The state of a drag as it is recognized. + public struct Value { + public var startLocation: CGPoint + public var location: CGPoint + public var translation: CGSize + } + + /// How far the touch must move before the drag is recognized. + public var minimumDistance: Double + + internal var changedAction: ((Value) -> Void)? + internal var endedAction: ((Value) -> Void)? + + public init(minimumDistance: Double = 10) { + self.minimumDistance = minimumDistance + } + + public func onChanged(_ action: @escaping (Value) -> Void) -> DragGesture { + var copy = self + copy.changedAction = action + return copy + } + + public func onEnded(_ action: @escaping (Value) -> Void) -> DragGesture { + var copy = self + copy.endedAction = action + return copy + } +} + +internal extension DragGesture.Value { + + /// Rebuilds a value from the interpreter's `";,;,"`. + init?(payload: String) { + let fields = payload.split(separator: ";") + guard fields.count == 3 else { return nil } + let start = fields[1].split(separator: ",").compactMap { Double($0) } + let current = fields[2].split(separator: ",").compactMap { Double($0) } + guard start.count == 2, current.count == 2 else { return nil } + self.startLocation = CGPoint(x: start[0], y: start[1]) + self.location = CGPoint(x: current[0], y: current[1]) + self.translation = CGSize(width: current[0] - start[0], height: current[1] - start[1]) + } +} + +// MARK: - Modifiers + +public struct _GestureModifier: RenderModifier, _CallbackModifier { + + let gesture: DragGesture + + public var _modifierNode: ModifierNode { ModifierNode(kind: "drag") } + + public func _callbackNode(in context: ResolveContext) -> ModifierNode { + let changed = gesture.changedAction + let ended = gesture.endedAction + let id = context.callbacks.register(.string { payload in + guard let value = DragGesture.Value(payload: payload) else { return } + if payload.hasPrefix("ended") { + ended?(value) + } else { + changed?(value) + } + }) + return ModifierNode(kind: "drag", args: [ + "action": .int(Int(id)), + "minimumDistance": .double(gesture.minimumDistance), + ]) + } +} + +public struct _LongPressModifier: RenderModifier, _CallbackModifier { + + let action: () -> Void + + public var _modifierNode: ModifierNode { ModifierNode(kind: "longPress") } + + public func _callbackNode(in context: ResolveContext) -> ModifierNode { + let id = context.callbacks.register(.void(action)) + return ModifierNode(kind: "longPress", args: ["action": .int(Int(id))]) + } +} + +public extension View { + + func gesture(_ gesture: DragGesture) -> ModifiedContent { + modifier(_GestureModifier(gesture: gesture)) + } + + func onLongPressGesture(perform action: @escaping () -> Void) -> ModifiedContent { + modifier(_LongPressModifier(action: action)) + } +} From b17978601aee6086f73d5a1908f3fced793265a2 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 18:41:10 -0400 Subject: [PATCH 2/5] Recognize drag and long press gestures --- .../kotlin/com/pureswift/swiftui/Render.kt | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index efb78dc..2c69b5c 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -3,6 +3,11 @@ package com.pureswift.swiftui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll @@ -1062,6 +1067,7 @@ private fun fontWeightFor(name: String): FontWeight = when (name) { /// animation (explicit `withAnimation` tree, or this node's `.animation` /// modifier) applies, and `snap` otherwise. @Composable +@OptIn(ExperimentalFoundationApi::class) internal fun ViewNode.composeModifiers(): Modifier { val implicit = modifiers.firstOrNull { it.kind == "animation" && it.args["curve"] != null } ?.let { AnimSpec(it.args.string("curve") ?: "easeInOut", (it.args.double("durationMs") ?: 350.0).toInt()) } @@ -1124,9 +1130,66 @@ internal fun ViewNode.composeModifiers(): Modifier { modifier.clip(shape) } + // Tap and long press share one detector: two competing pointer + // handlers on the same view would fight over the gesture. "onTapGesture" -> { val id = entry.args.long("action") - if (id != null) modifier.clickable { SwiftBridge.sink.invokeVoid(id) } else modifier + val longID = modifiers.firstOrNull { it.kind == "longPress" }?.args?.long("action") + when { + id == null -> modifier + longID == null -> modifier.clickable { SwiftBridge.sink.invokeVoid(id) } + else -> modifier.combinedClickable( + onClick = { SwiftBridge.sink.invokeVoid(id) }, + onLongClick = { SwiftBridge.sink.invokeVoid(longID) }, + ) + } + } + + "longPress" -> { + val id = entry.args.long("action") + // already wired above when this view also has a tap handler + val hasTap = modifiers.any { it.kind == "onTapGesture" } + if (id == null || hasTap) modifier + else modifier.combinedClickable(onClick = {}, onLongClick = { SwiftBridge.sink.invokeVoid(id) }) + } + + // Continuous drag: positions are reported in points, converted from + // the pointer's pixels, as ";,;,". + "drag" -> { + val id = entry.args.long("action") + if (id == null) modifier else { + // The detector is a long-running coroutine, so it must be keyed + // by the node's STABLE id: keying by the callback id would tear + // the gesture down mid-drag, since every onChanged re-evaluates + // the tree and mints a fresh id. The lambda then reads the id + // from a holder so it never dispatches to a reclaimed callback. + val latest = remember(this@composeModifiers.id) { longArrayOf(id) } + latest[0] = id + modifier.pointerInput(this@composeModifiers.id) { + var start = Offset.Zero + // Accumulate per-event deltas rather than reading absolute + // positions: a view that offsets itself by the translation + // moves under the finger, and absolute readings would + // compensate for that motion and collapse toward zero. + var total = Offset.Zero + fun payload(phase: String): String { + val end = start + total + return "$phase;${start.x.toDp().value},${start.y.toDp().value};" + + "${end.x.toDp().value},${end.y.toDp().value}" + } + detectDragGestures( + onDragStart = { start = it; total = Offset.Zero }, + onDrag = { change, dragAmount -> + change.consume() + total += dragAmount + SwiftBridge.sink.invokeString(latest[0], payload("changed")) + }, + // ends carrying the final translation, as SwiftUI does + onDragEnd = { SwiftBridge.sink.invokeString(latest[0], payload("ended")) }, + onDragCancel = { SwiftBridge.sink.invokeString(latest[0], payload("ended")) }, + ) + } + } } // Dim disabled content; controls also drop interactivity via their @@ -1155,7 +1218,7 @@ private val KNOWN_MODIFIER_KINDS = setOf( "scale", "opacity", "border", "shadow", "clipShape", "onTapGesture", "disabled", "font", "fontWeight", "italic", "foregroundColor", "lineLimit", "multilineTextAlignment", "tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem", - "transition", "focused", + "transition", "focused", "longPress", "drag", ) // Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded From e0bed35188bda2cea1d41a6a26021d5eb58e394a Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 18:41:10 -0400 Subject: [PATCH 3/5] Test gesture emission and drag payload parsing --- .../EvaluatorTests.swift | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index 91aa689..cab4dbe 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -187,6 +187,68 @@ struct ModifierTests { #expect(tapped) } + @Test("onLongPressGesture registers its own callback distinct from a tap") + func longPress() { + var tapped = false + var pressed = false + let host = ViewHost( + Text("x") + .onTapGesture { tapped = true } + .onLongPressGesture { pressed = true } + ) + let node = host.evaluate() + guard case .int(let tapID)? = node.modifiers.first(where: { $0.kind == "onTapGesture" })?.args["action"], + case .int(let pressID)? = node.modifiers.first(where: { $0.kind == "longPress" })?.args["action"] else { + Issue.record("missing gesture callbacks"); return + } + #expect(tapID != pressID) + host.callbacks.invokeVoid(Int64(pressID)) + #expect(pressed) + #expect(!tapped) + } + + @Test("A drag reports translation to onChanged, then a final value to onEnded") + func dragGesture() { + var changes: [CGSize] = [] + var ended: CGSize? + let host = ViewHost( + Text("x").gesture( + DragGesture() + .onChanged { changes.append($0.translation) } + .onEnded { ended = $0.translation } + ) + ) + let node = host.evaluate() + let drag = node.modifiers.first { $0.kind == "drag" } + #expect(drag?.args["minimumDistance"] == .double(10)) + guard case .int(let id)? = drag?.args["action"] else { + Issue.record("missing drag callback"); return + } + // the interpreter's payload: phase, start point, current point (in points) + host.callbacks.invokeString(Int64(id), "changed;10.0,20.0;40.0,25.0") + host.callbacks.invokeString(Int64(id), "ended;10.0,20.0;60.0,20.0") + #expect(changes.count == 1) + #expect(changes.first?.width == 30) // 40 − 10 + #expect(changes.first?.height == 5) // 25 − 20 + #expect(ended?.width == 50) + #expect(ended?.height == 0) + } + + @Test("A malformed drag payload is ignored rather than crashing") + func dragGestureMalformed() { + var changes = 0 + let host = ViewHost( + Text("x").gesture(DragGesture().onChanged { _ in changes += 1 }) + ) + let node = host.evaluate() + guard case .int(let id)? = node.modifiers.first(where: { $0.kind == "drag" })?.args["action"] else { + Issue.record("missing drag callback"); return + } + host.callbacks.invokeString(Int64(id), "changed;garbage") + host.callbacks.invokeString(Int64(id), "") + #expect(changes == 0) + } + @Test("onAppear and onDisappear emit distinct callback kinds") func appearDisappear() { let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate() From e6c8b22d592df8d407948d8f124e2d9c89d9a7f4 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 18:41:10 -0400 Subject: [PATCH 4/5] Add a gesture playground with a draggable box --- .../Sources/GesturePlaygrounds.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 Demo/App.swiftpm/Sources/GesturePlaygrounds.swift diff --git a/Demo/App.swiftpm/Sources/GesturePlaygrounds.swift b/Demo/App.swiftpm/Sources/GesturePlaygrounds.swift new file mode 100644 index 0000000..e56f138 --- /dev/null +++ b/Demo/App.swiftpm/Sources/GesturePlaygrounds.swift @@ -0,0 +1,57 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct GesturePlayground: View { + + @State private var dragX = 0.0 + @State private var dragY = 0.0 + @State private var dropped = "not yet" + @State private var presses = 0 + @State private var taps = 0 + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("DragGesture") { + VStack(alignment: .leading, spacing: 8) { + Text("Offset: \(Int(dragX)), \(Int(dragY))") + Text("Last drop: \(dropped)") + RoundedRectangle(cornerRadius: 12) + .fill(.blue) + .frame(width: 90, height: 90) + .offset(x: dragX, y: dragY) + .gesture( + DragGesture() + .onChanged { value in + dragX = value.translation.width + dragY = value.translation.height + } + .onEnded { value in + dropped = "\(Int(value.translation.width)), \(Int(value.translation.height))" + } + ) + Button("Reset") { + dragX = 0 + dragY = 0 + } + } + } + Example("onLongPressGesture") { + VStack(alignment: .leading, spacing: 8) { + Text("Taps: \(taps) Long presses: \(presses)") + Text("Tap or press and hold") + .foregroundColor(.white) + .padding() + .background(Color.purple) + .cornerRadius(8) + .onTapGesture { taps += 1 } + .onLongPressGesture { presses += 1 } + } + } + } + } + } +} From ba126fb994fff6d492938e328f7722dfff61a119 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 18:41:10 -0400 Subject: [PATCH 5/5] List the gesture playground in the catalog --- Demo/App.swiftpm/Sources/Catalog.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 7a31b4c..783361f 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -50,6 +50,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())), CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())), CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())), + CatalogEntry(id: "gesture", title: "Gestures", screen: AnyCatalogScreen(GesturePlayground())), CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())), CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())), CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),