Skip to content

Commit 775ecc9

Browse files
authored
Merge pull request #50 from PureSwift/feature/gestures
Add DragGesture and onLongPressGesture
2 parents 1f889b5 + ba126fb commit 775ecc9

5 files changed

Lines changed: 294 additions & 2 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//
2+
// Gesture.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Continuous gestures. A drag reports through the fixed string callback rather
6+
// than growing the bridge with a two-number entry point: the interpreter sends
7+
// "<phase>;<startX>,<startY>;<x>,<y>" in points, which rebuilds a Value here.
8+
//
9+
10+
import Foundation
11+
12+
/// A gesture recognized on a view. `.gesture(_:)` currently accepts `DragGesture`.
13+
public protocol Gesture {}
14+
15+
public struct DragGesture: Gesture {
16+
17+
/// The state of a drag as it is recognized.
18+
public struct Value {
19+
public var startLocation: CGPoint
20+
public var location: CGPoint
21+
public var translation: CGSize
22+
}
23+
24+
/// How far the touch must move before the drag is recognized.
25+
public var minimumDistance: Double
26+
27+
internal var changedAction: ((Value) -> Void)?
28+
internal var endedAction: ((Value) -> Void)?
29+
30+
public init(minimumDistance: Double = 10) {
31+
self.minimumDistance = minimumDistance
32+
}
33+
34+
public func onChanged(_ action: @escaping (Value) -> Void) -> DragGesture {
35+
var copy = self
36+
copy.changedAction = action
37+
return copy
38+
}
39+
40+
public func onEnded(_ action: @escaping (Value) -> Void) -> DragGesture {
41+
var copy = self
42+
copy.endedAction = action
43+
return copy
44+
}
45+
}
46+
47+
internal extension DragGesture.Value {
48+
49+
/// Rebuilds a value from the interpreter's `"<phase>;<sx>,<sy>;<x>,<y>"`.
50+
init?(payload: String) {
51+
let fields = payload.split(separator: ";")
52+
guard fields.count == 3 else { return nil }
53+
let start = fields[1].split(separator: ",").compactMap { Double($0) }
54+
let current = fields[2].split(separator: ",").compactMap { Double($0) }
55+
guard start.count == 2, current.count == 2 else { return nil }
56+
self.startLocation = CGPoint(x: start[0], y: start[1])
57+
self.location = CGPoint(x: current[0], y: current[1])
58+
self.translation = CGSize(width: current[0] - start[0], height: current[1] - start[1])
59+
}
60+
}
61+
62+
// MARK: - Modifiers
63+
64+
public struct _GestureModifier: RenderModifier, _CallbackModifier {
65+
66+
let gesture: DragGesture
67+
68+
public var _modifierNode: ModifierNode { ModifierNode(kind: "drag") }
69+
70+
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
71+
let changed = gesture.changedAction
72+
let ended = gesture.endedAction
73+
let id = context.callbacks.register(.string { payload in
74+
guard let value = DragGesture.Value(payload: payload) else { return }
75+
if payload.hasPrefix("ended") {
76+
ended?(value)
77+
} else {
78+
changed?(value)
79+
}
80+
})
81+
return ModifierNode(kind: "drag", args: [
82+
"action": .int(Int(id)),
83+
"minimumDistance": .double(gesture.minimumDistance),
84+
])
85+
}
86+
}
87+
88+
public struct _LongPressModifier: RenderModifier, _CallbackModifier {
89+
90+
let action: () -> Void
91+
92+
public var _modifierNode: ModifierNode { ModifierNode(kind: "longPress") }
93+
94+
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
95+
let id = context.callbacks.register(.void(action))
96+
return ModifierNode(kind: "longPress", args: ["action": .int(Int(id))])
97+
}
98+
}
99+
100+
public extension View {
101+
102+
func gesture(_ gesture: DragGesture) -> ModifiedContent<Self, _GestureModifier> {
103+
modifier(_GestureModifier(gesture: gesture))
104+
}
105+
106+
func onLongPressGesture(perform action: @escaping () -> Void) -> ModifiedContent<Self, _LongPressModifier> {
107+
modifier(_LongPressModifier(action: action))
108+
}
109+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,68 @@ struct ModifierTests {
187187
#expect(tapped)
188188
}
189189

190+
@Test("onLongPressGesture registers its own callback distinct from a tap")
191+
func longPress() {
192+
var tapped = false
193+
var pressed = false
194+
let host = ViewHost(
195+
Text("x")
196+
.onTapGesture { tapped = true }
197+
.onLongPressGesture { pressed = true }
198+
)
199+
let node = host.evaluate()
200+
guard case .int(let tapID)? = node.modifiers.first(where: { $0.kind == "onTapGesture" })?.args["action"],
201+
case .int(let pressID)? = node.modifiers.first(where: { $0.kind == "longPress" })?.args["action"] else {
202+
Issue.record("missing gesture callbacks"); return
203+
}
204+
#expect(tapID != pressID)
205+
host.callbacks.invokeVoid(Int64(pressID))
206+
#expect(pressed)
207+
#expect(!tapped)
208+
}
209+
210+
@Test("A drag reports translation to onChanged, then a final value to onEnded")
211+
func dragGesture() {
212+
var changes: [CGSize] = []
213+
var ended: CGSize?
214+
let host = ViewHost(
215+
Text("x").gesture(
216+
DragGesture()
217+
.onChanged { changes.append($0.translation) }
218+
.onEnded { ended = $0.translation }
219+
)
220+
)
221+
let node = host.evaluate()
222+
let drag = node.modifiers.first { $0.kind == "drag" }
223+
#expect(drag?.args["minimumDistance"] == .double(10))
224+
guard case .int(let id)? = drag?.args["action"] else {
225+
Issue.record("missing drag callback"); return
226+
}
227+
// the interpreter's payload: phase, start point, current point (in points)
228+
host.callbacks.invokeString(Int64(id), "changed;10.0,20.0;40.0,25.0")
229+
host.callbacks.invokeString(Int64(id), "ended;10.0,20.0;60.0,20.0")
230+
#expect(changes.count == 1)
231+
#expect(changes.first?.width == 30) // 40 − 10
232+
#expect(changes.first?.height == 5) // 25 − 20
233+
#expect(ended?.width == 50)
234+
#expect(ended?.height == 0)
235+
}
236+
237+
@Test("A malformed drag payload is ignored rather than crashing")
238+
func dragGestureMalformed() {
239+
var changes = 0
240+
let host = ViewHost(
241+
Text("x").gesture(DragGesture().onChanged { _ in changes += 1 })
242+
)
243+
let node = host.evaluate()
244+
guard case .int(let id)? = node.modifiers.first(where: { $0.kind == "drag" })?.args["action"] else {
245+
Issue.record("missing drag callback"); return
246+
}
247+
host.callbacks.invokeString(Int64(id), "changed;garbage")
248+
host.callbacks.invokeString(Int64(id), "")
249+
#expect(changes == 0)
250+
}
251+
190252
@Test("onAppear and onDisappear emit distinct callback kinds")
191253
func appearDisappear() {
192254
let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate()

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ struct CatalogEntry: Identifiable {
5050
CatalogEntry(id: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())),
5151
CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())),
5252
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
53+
CatalogEntry(id: "gesture", title: "Gestures", screen: AnyCatalogScreen(GesturePlayground())),
5354
CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())),
5455
CatalogEntry(id: "searchable", title: "Searchable", screen: AnyCatalogScreen(SearchablePlayground())),
5556
CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct GesturePlayground: View {
8+
9+
@State private var dragX = 0.0
10+
@State private var dragY = 0.0
11+
@State private var dropped = "not yet"
12+
@State private var presses = 0
13+
@State private var taps = 0
14+
15+
var body: some View {
16+
ScrollView {
17+
VStack(alignment: .leading, spacing: 0) {
18+
Example("DragGesture") {
19+
VStack(alignment: .leading, spacing: 8) {
20+
Text("Offset: \(Int(dragX)), \(Int(dragY))")
21+
Text("Last drop: \(dropped)")
22+
RoundedRectangle(cornerRadius: 12)
23+
.fill(.blue)
24+
.frame(width: 90, height: 90)
25+
.offset(x: dragX, y: dragY)
26+
.gesture(
27+
DragGesture()
28+
.onChanged { value in
29+
dragX = value.translation.width
30+
dragY = value.translation.height
31+
}
32+
.onEnded { value in
33+
dropped = "\(Int(value.translation.width)), \(Int(value.translation.height))"
34+
}
35+
)
36+
Button("Reset") {
37+
dragX = 0
38+
dragY = 0
39+
}
40+
}
41+
}
42+
Example("onLongPressGesture") {
43+
VStack(alignment: .leading, spacing: 8) {
44+
Text("Taps: \(taps) Long presses: \(presses)")
45+
Text("Tap or press and hold")
46+
.foregroundColor(.white)
47+
.padding()
48+
.background(Color.purple)
49+
.cornerRadius(8)
50+
.onTapGesture { taps += 1 }
51+
.onLongPressGesture { presses += 1 }
52+
}
53+
}
54+
}
55+
}
56+
}
57+
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ package com.pureswift.swiftui
33
import androidx.compose.foundation.background
44
import androidx.compose.foundation.border
55
import androidx.compose.foundation.clickable
6+
import androidx.compose.foundation.ExperimentalFoundationApi
7+
import androidx.compose.foundation.combinedClickable
8+
import androidx.compose.foundation.gestures.detectDragGestures
9+
import androidx.compose.ui.geometry.Offset
10+
import androidx.compose.ui.input.pointer.pointerInput
611
import androidx.compose.foundation.rememberScrollState
712
import androidx.compose.foundation.horizontalScroll
813
import androidx.compose.foundation.verticalScroll
@@ -1062,6 +1067,7 @@ private fun fontWeightFor(name: String): FontWeight = when (name) {
10621067
/// animation (explicit `withAnimation` tree, or this node's `.animation`
10631068
/// modifier) applies, and `snap` otherwise.
10641069
@Composable
1070+
@OptIn(ExperimentalFoundationApi::class)
10651071
internal fun ViewNode.composeModifiers(): Modifier {
10661072
val implicit = modifiers.firstOrNull { it.kind == "animation" && it.args["curve"] != null }
10671073
?.let { AnimSpec(it.args.string("curve") ?: "easeInOut", (it.args.double("durationMs") ?: 350.0).toInt()) }
@@ -1124,9 +1130,66 @@ internal fun ViewNode.composeModifiers(): Modifier {
11241130
modifier.clip(shape)
11251131
}
11261132

1133+
// Tap and long press share one detector: two competing pointer
1134+
// handlers on the same view would fight over the gesture.
11271135
"onTapGesture" -> {
11281136
val id = entry.args.long("action")
1129-
if (id != null) modifier.clickable { SwiftBridge.sink.invokeVoid(id) } else modifier
1137+
val longID = modifiers.firstOrNull { it.kind == "longPress" }?.args?.long("action")
1138+
when {
1139+
id == null -> modifier
1140+
longID == null -> modifier.clickable { SwiftBridge.sink.invokeVoid(id) }
1141+
else -> modifier.combinedClickable(
1142+
onClick = { SwiftBridge.sink.invokeVoid(id) },
1143+
onLongClick = { SwiftBridge.sink.invokeVoid(longID) },
1144+
)
1145+
}
1146+
}
1147+
1148+
"longPress" -> {
1149+
val id = entry.args.long("action")
1150+
// already wired above when this view also has a tap handler
1151+
val hasTap = modifiers.any { it.kind == "onTapGesture" }
1152+
if (id == null || hasTap) modifier
1153+
else modifier.combinedClickable(onClick = {}, onLongClick = { SwiftBridge.sink.invokeVoid(id) })
1154+
}
1155+
1156+
// Continuous drag: positions are reported in points, converted from
1157+
// the pointer's pixels, as "<phase>;<startX>,<startY>;<x>,<y>".
1158+
"drag" -> {
1159+
val id = entry.args.long("action")
1160+
if (id == null) modifier else {
1161+
// The detector is a long-running coroutine, so it must be keyed
1162+
// by the node's STABLE id: keying by the callback id would tear
1163+
// the gesture down mid-drag, since every onChanged re-evaluates
1164+
// the tree and mints a fresh id. The lambda then reads the id
1165+
// from a holder so it never dispatches to a reclaimed callback.
1166+
val latest = remember(this@composeModifiers.id) { longArrayOf(id) }
1167+
latest[0] = id
1168+
modifier.pointerInput(this@composeModifiers.id) {
1169+
var start = Offset.Zero
1170+
// Accumulate per-event deltas rather than reading absolute
1171+
// positions: a view that offsets itself by the translation
1172+
// moves under the finger, and absolute readings would
1173+
// compensate for that motion and collapse toward zero.
1174+
var total = Offset.Zero
1175+
fun payload(phase: String): String {
1176+
val end = start + total
1177+
return "$phase;${start.x.toDp().value},${start.y.toDp().value};" +
1178+
"${end.x.toDp().value},${end.y.toDp().value}"
1179+
}
1180+
detectDragGestures(
1181+
onDragStart = { start = it; total = Offset.Zero },
1182+
onDrag = { change, dragAmount ->
1183+
change.consume()
1184+
total += dragAmount
1185+
SwiftBridge.sink.invokeString(latest[0], payload("changed"))
1186+
},
1187+
// ends carrying the final translation, as SwiftUI does
1188+
onDragEnd = { SwiftBridge.sink.invokeString(latest[0], payload("ended")) },
1189+
onDragCancel = { SwiftBridge.sink.invokeString(latest[0], payload("ended")) },
1190+
)
1191+
}
1192+
}
11301193
}
11311194

11321195
// Dim disabled content; controls also drop interactivity via their
@@ -1155,7 +1218,7 @@ private val KNOWN_MODIFIER_KINDS = setOf(
11551218
"scale", "opacity", "border", "shadow", "clipShape", "onTapGesture", "disabled",
11561219
"font", "fontWeight", "italic", "foregroundColor", "lineLimit", "multilineTextAlignment",
11571220
"tint", "onAppear", "onDisappear", "task", "onChange", "animation", "tag", "tabItem",
1158-
"transition", "focused",
1221+
"transition", "focused", "longPress", "drag",
11591222
)
11601223

11611224
// Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded

0 commit comments

Comments
 (0)