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
109 changes: 109 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Gesture.swift
Original file line number Diff line number Diff line change
@@ -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
// "<phase>;<startX>,<startY>;<x>,<y>" 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 `"<phase>;<sx>,<sy>;<x>,<y>"`.
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<Self, _GestureModifier> {
modifier(_GestureModifier(gesture: gesture))
}

func onLongPressGesture(perform action: @escaping () -> Void) -> ModifiedContent<Self, _LongPressModifier> {
modifier(_LongPressModifier(action: action))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
57 changes: 57 additions & 0 deletions Demo/App.swiftpm/Sources/GesturePlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
}
}
}
}
67 changes: 65 additions & 2 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) }
Expand Down Expand Up @@ -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 "<phase>;<startX>,<startY>;<x>,<y>".
"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
Expand Down Expand Up @@ -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
Expand Down
Loading