Skip to content

Commit afb19d8

Browse files
Saw-000Ie So
andauthored
Feature/support multi finger tap (#21)
* DragGestureをSpatialEventGestureに置換する。 * 一度フォーマットかけとく * ピンチのデフォルトジェスチャを追加。 --------- Co-authored-by: Ie So <[email protected]>
1 parent c190314 commit afb19d8

18 files changed

+836
-319
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
{
22
"permissions": {
33
"allow": [
4-
"Bash(git log:*)"
4+
"Bash(git log:*)",
5+
"Bash(swift build:*)"
56
],
67
"deny": [],
78
"ask": []

Package.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
// swift-tools-version: 6.2
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

4-
import PackageDescription
54
import Foundation
5+
import PackageDescription
66

77
let package = Package(
88
name: "SwiftUI-DetectGestureUtil",
99
platforms: [
10-
.iOS(.v14),
10+
.iOS(.v18),
1111
],
1212
products: [
1313
// Products define the executables and libraries a package produces, making them visible to other packages.
1414
.library(
1515
name: "SwiftUI-DetectGestureUtil",
1616
targets: [
17-
MyModule.swiftUIDetectGestureUtil.name // "SwiftUI-DetectGestureUtil"
17+
MyModule.swiftUIDetectGestureUtil.name, // "SwiftUI-DetectGestureUtil"
1818
]
1919
),
2020
],
@@ -23,32 +23,33 @@ let package = Package(
2323
name: MyModule.swiftUIDetectGestureUtil.name,
2424
dependencies: [
2525
MyModule.featureDetectGesture.dependency,
26-
MyModule.core.dependency
26+
MyModule.core.dependency,
2727
],
2828
path: MyModule.swiftUIDetectGestureUtil.folderPath
2929
),
3030
.target(
3131
name: MyModule.featureDetectGesture.name,
3232
dependencies: [
33-
MyModule.core.dependency
33+
MyModule.core.dependency,
3434
],
3535
path: MyModule.featureDetectGesture.folderPath
3636
),
3737
.target(
3838
name: MyModule.core.name,
3939
path: MyModule.core.folderPath
40-
)
40+
),
4141
]
4242
)
4343

4444
// MARK: - Utility
4545

46-
// 自作モジュール
46+
/// Custom modules for package organization
4747
enum MyModule {
4848
case core
4949
case featureDetectGesture
5050
case swiftUIDetectGestureUtil
5151

52+
/// Folder path for the module
5253
var folderPath: String {
5354
return switch self {
5455
case .core:
@@ -60,6 +61,7 @@ enum MyModule {
6061
}
6162
}
6263

64+
/// Module name
6365
var name: String {
6466
return switch self {
6567
case .swiftUIDetectGestureUtil:
@@ -71,6 +73,7 @@ enum MyModule {
7173
}
7274
}
7375

76+
/// Target dependency
7477
var dependency: Target.Dependency {
7578
return .byName(name: name, condition: nil)
7679
}

README.md

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
一つのViewに複数のカスタムジェスチャを設定し、その中の一つだけを検知させられるSwift Packageです。
44

5-
内部的には、DragGestureをカスタムしたものです
5+
内部的には、SpatialEventGestureを利用しており、マルチタップ(複数の指)にも対応しています
66

77
A Swift Package that allows you to detect only one of multiple custom gestures on a single SwiftUI View.
88

9-
It is internally something that customs DragGesture.
9+
It uses [SpatialEventGesture](https://developer.apple.com/documentation/swiftui/spatialeventgesture) internally and supports multi-touch (multiple fingers).
10+
11+
**Requirements: iOS 18.0+**
1012

1113
<img width="400" alt="Simulator Screenshot - iPad (A16) - 2025-11-03 at 05 22 09" src="https://github.com/user-attachments/assets/1ee868bc-91ad-48bd-9a0a-c507ba95c56a" />
1214

@@ -69,7 +71,7 @@ struct ContentView: View {
6971

7072
// Return value:
7173
// - Non-nil: Indicates that a gesture was detected and returns the detected gesture. The gesture detection phase is then complete and this closure will no longer be called. From then on, handleGesture will be called.
72-
// - nil: Indicates that no gesture was detected. As long as nil is returned, it will be called when the gesture state is updated, similar to Gesture.onChanged() and Gesture.onEnded(). Unlike DragGesture, new coordinates are added and called even if they remain at the same location.
74+
// - nil: Indicates that no gesture was detected. As long as nil is returned, it will be called when the gesture state is updated, similar to Gesture.onChanged() and Gesture.onEnded(). A heartbeat mechanism ensures continuous updates even if fingers remain at the same location.
7375

7476
if state.detected(.tap) { // Several default gesture detections are provided. See DefaultDetectGesture type.
7577
// Detect tap gesture
@@ -80,8 +82,8 @@ struct ContentView: View {
8082
} else {
8183
// Custom: Detect circle gesture without using default gestures
8284
let points = state.gestureValues
83-
.withRawDragGesture()
84-
.map { $0.dragGestureValue.location }
85+
.filterdWithRawDragGesture
86+
.compactMap { $0.spatialEventCollection.gestureValue?.location }
8587

8688
if detectCircle(points: points) {
8789
return .circle
@@ -96,7 +98,7 @@ struct ContentView: View {
9698

9799
// Return value:
98100
// - .finished: Indicates processing is complete. Gesture processing is completely finished. The closure will no longer be called.
99-
// - .yet: Indicates processing is incomplete. As long as .yet is returned, it will be called when the gesture state is updated, similar to Gesture.onChanged() and Gesture.onEnded(). Unlike DragGesture, new coordinates are added and called even if they remain at the same location.
101+
// - .yet: Indicates processing is incomplete. As long as .yet is returned, it will be called when the gesture state is updated, similar to Gesture.onChanged() and Gesture.onEnded(). A heartbeat mechanism ensures continuous updates even if fingers remain at the same location.
100102

101103
switch detection {
102104
case .tap:
@@ -144,23 +146,34 @@ You can access it in each handler of `View.detectGesture()`.
144146

145147
- `gestureValues: [DetectGestureValue]`: History of gesture information
146148
- `detected(_:gestureValues:) -> Bool`: Whether the specified default gesture has already been detected
147-
- `tapSplittedGestureValues: [[DetectGestureValue]]`: History of gesture information separated by tap
148-
- `lastTapGestureValues: [DetectGestureValue]?`: GestureValues with last (or current in tapping) tap
149-
- `lastGestureValue: DetectGestureValue?`: Last Detected Gestrue Value
149+
- `gestureValuesAsTapSequences: [DetectGestureTapSequence]`: Gesture values converted to tap sequences
150+
- `lastTapSequence: DetectGestureTapSequence?`: Last tap sequence
151+
- `lastGestureValue: DetectGestureValue?`: Last detected gesture value
152+
- `processPerSingleFingerTouch(_:)`: Process taps for each individual finger
150153
- etc...
151154

152155
### DetectGestureValue
153-
Value containing gesture state information. (like DragGesture.Value)
156+
Value containing gesture state information.
154157

155-
- `dragGestureValue: DragGesture.Value`: Drag gesture value from SwiftUI
158+
- `spatialEventCollection: SpatialEventCollection`: Spatial event collection from SwiftUI (supports multi-touch)
156159
- `geometryProxy: GeometryProxy`: Geometry proxy for view bounds
157-
- `timing: Timing`: Timing of this state update
158-
- `time: Date`: Timestamp of this state (using custom Date because DragGesture.Value.time has bugs)
159-
- `isInView() -> Bool`: Check if gesture location is within view bounds
160+
- `timing: Timing`: Timing of this state update (`.changed`, `.ended`, or `.heartbeat`)
161+
- `time: Date`: Timestamp of this state
162+
- `fingerCount: Int`: Number of fingers currently touching
163+
- `locations: [CGPoint]`: Locations of all fingers
164+
- `isAllFingersInView() -> Bool`: Check if all fingers are within view bounds
165+
- `asSingleFingerValues() -> [DetectGestureSingleFingerValue]`: Convert to single finger values for individual finger processing
160166
- etc...
161167

162-
## Caution
163-
- ※ Multi-Fingered Gesture has not supported yet. (No plans)
168+
### SpatialEventCollection Extensions
169+
170+
Convenience properties added to [SpatialEventCollection](https://developer.apple.com/documentation/swiftui/spatialeventcollection) for easier access:
171+
172+
- `translation: CGSize`: Translation from start location
173+
- `velocity: CGSize`: Velocity of the gesture movement
174+
- `diff: CGPoint`: Distance moved from the initial tap location
175+
176+
For more information about SpatialEventGesture, see [Apple's official documentation](https://developer.apple.com/documentation/swiftui/spatialeventgesture).
164177

165178
## Sample
166179
Run the project in Sample folder.

Sample/SwiftUIDetectGestureSample/SwiftUIDetectGestureSample/ContentView.swift

Lines changed: 60 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,20 @@ struct ContentView: View {
1313

1414
// First gesture detection view
1515
VStack {
16-
Text("tap\n" + "long tap\n" + "drag")
16+
Text("tap\n" + "long tap\n" + "pinch")
1717
.font(.title2)
1818
}
1919
.frame(maxWidth: .infinity, maxHeight: .infinity)
2020
.background(.blue)
2121
.detectGesture(
2222
MyGestureDetection1.self,
2323
detectGesture: { state in
24-
if state.detected(.tap) {
24+
if state.detected(.tap()) {
2525
return .tap
2626
} else if state.detected(.longTap(minimumMilliSeconds: 1000)) {
2727
return .longTap
28-
} else if state.detected(.drag(minimumDistance: 30)) {
29-
return .drag
28+
} else if state.detected(.pinch(minimumDistance: 30)) {
29+
return .pinch
3030
} else {
3131
return nil
3232
}
@@ -41,13 +41,22 @@ struct ContentView: View {
4141
detectedGestureText = "Long Tap"
4242
return .finished
4343

44-
case .drag:
45-
if state.lastGestureValue?.timing == .ended {
46-
detectedGestureText = "Drag End"
47-
return .finished
48-
} else {
49-
detectedGestureText = "Drag location: \(state.lastGestureValue?.dragGestureValue.location)"
44+
case .pinch:
45+
// Display center position and distance
46+
if let lastPinch = state.pinchState.last, let lastValue = lastPinch.values.last {
47+
if lastPinch.isEnded {
48+
detectedGestureText = nil
49+
return .finished
50+
}
51+
52+
let center = lastValue.center
53+
let distance = lastValue.distance
54+
detectedGestureText = "Pinch center: (\(String(format: "%.1f", center.x)), \(String(format: "%.1f", center.y))) distance: \(String(format: "%.1f", distance))"
55+
5056
return .yet
57+
} else {
58+
detectedGestureText = "Pinch failed??"
59+
return .finished
5160
}
5261
}
5362
}
@@ -80,7 +89,7 @@ struct ContentView: View {
8089
detectedGestureText = "Right Slide End"
8190
return .finished
8291
} else {
83-
detectedGestureText = "Right Slide location: \(state.lastGestureValue?.dragGestureValue.location)"
92+
detectedGestureText = "Right Slide location: \(state.lastGestureValue?.locations)"
8493
return .yet
8594
}
8695

@@ -95,7 +104,6 @@ struct ContentView: View {
95104
}
96105
)
97106

98-
99107
ZStack {
100108
// Third gesture detection view
101109
VStack {
@@ -109,15 +117,21 @@ struct ContentView: View {
109117
detectGesture: { state in
110118
detectGestureState3 = state
111119

112-
for values in state.tapSplittedGestureValues {
113-
let points = values
114-
.withRawDragGesture() // Get coordinates only when moved.
115-
.map { $0.dragGestureValue.location }
116-
117-
if detectStar(points: points) {
118-
return .star_swipe
119-
} else if detectCircle(points: points) {
120-
return .circle
120+
for tapSequence in state.gestureValuesAsTapSequences {
121+
for singleFingerTouch in tapSequence.touches {
122+
guard !singleFingerTouch.isOverlapped(with: tapSequence.touches) else {
123+
continue
124+
}
125+
126+
let points = singleFingerTouch.values
127+
.withRawNotifiedGesture() // Get coordinates only when moved.
128+
.map { $0.fingerEvent.location }
129+
130+
if detectStar(points: points) {
131+
return .star_swipe
132+
} else if detectCircle(points: points) {
133+
return .circle
134+
}
121135
}
122136
}
123137

@@ -132,7 +146,7 @@ struct ContentView: View {
132146
detectedGestureText = "Circle End"
133147
return .finished
134148
} else {
135-
detectedGestureText = "Circle location: \(state.lastGestureValue?.dragGestureValue.location)"
149+
detectedGestureText = "Circle location: \(state.lastGestureValue?.locations)"
136150
return .yet
137151
}
138152

@@ -141,18 +155,22 @@ struct ContentView: View {
141155

142156
// End when swiped
143157
guard
144-
let lastTapValues = state.lastTapGestureValues?.withRawDragGesture(),
145-
lastTapValues.count >= 2
158+
let lastTapSequence = state.lastTapSequence,
159+
lastTapSequence.touches.count > 0
146160
else {
147161
return .yet
148162
}
149163

150-
if
151-
state.detected(.swipe(direction: .up), gestureValues: lastTapValues)
164+
let isSwiped = lastTapSequence.anySingleFingerTouchContains { singleFingerTouch, _ in
165+
let lastTapValues = singleFingerTouch.values.map { $0.attachmentInfo }
166+
167+
return state.detected(.swipe(direction: .up), gestureValues: lastTapValues)
152168
|| state.detected(.swipe(direction: .left), gestureValues: lastTapValues)
153169
|| state.detected(.swipe(direction: .right), gestureValues: lastTapValues)
154170
|| state.detected(.swipe(direction: .down), gestureValues: lastTapValues)
155-
{
171+
}
172+
173+
if isSwiped {
156174
detectedGestureText = "Star Swiped!"
157175
return .finished
158176
}
@@ -167,11 +185,13 @@ struct ContentView: View {
167185

168186
// Drawing trajectory
169187
Path { path in
170-
detectGestureState3?.tapSplittedGestureValues.forEach { gestureValues in
171-
let points = gestureValues.map { $0.dragGestureValue.location }
188+
detectGestureState3?.processPerSingleFingerTouch { singleFingerTouch, _ in
189+
let points = singleFingerTouch.values.map { $0.fingerEvent.location }
172190
guard let first = points.first else { return }
173191
path.move(to: first)
174-
for p in points { path.addLine(to: p) }
192+
for p in points {
193+
path.addLine(to: p)
194+
}
175195
}
176196
}
177197
.stroke(.black.opacity(0.5), lineWidth: 3)
@@ -193,8 +213,8 @@ enum MyGestureDetection1 {
193213
case tap
194214
/// Long tap gesture
195215
case longTap
196-
/// Drag gesture
197-
case drag
216+
/// Pinch gesture
217+
case pinch
198218
}
199219

200220
/// Wanted Gesture Detection
@@ -216,6 +236,7 @@ enum MyGestureDetection3 {
216236
}
217237

218238
// MARK: - Shape detection algorithms
239+
219240
/// Simple circle detection
220241
private func detectCircle(points: [CGPoint]) -> Bool {
221242
guard points.count > 100 else { return false }
@@ -227,12 +248,12 @@ private func detectCircle(points: [CGPoint]) -> Bool {
227248

228249
// Calculate variance (variation) of radius (distance from center to each point).
229250
let center = CGPoint(
230-
x: points.map{$0.x}.reduce(0,+)/CGFloat(points.count),
231-
y: points.map{$0.y}.reduce(0,+)/CGFloat(points.count)
251+
x: points.map { $0.x }.reduce(0,+) / CGFloat(points.count),
252+
y: points.map { $0.y }.reduce(0,+) / CGFloat(points.count)
232253
)
233254
let radii = points.map { hypot($0.x - center.x, $0.y - center.y) }
234-
let mean = radii.reduce(0,+)/CGFloat(radii.count)
235-
let variance = radii.map{ pow($0 - mean, 2.0) }.reduce(0,+) / CGFloat(radii.count)
255+
let mean = radii.reduce(0,+) / CGFloat(radii.count)
256+
let variance = radii.map { pow($0 - mean, 2.0) }.reduce(0,+) / CGFloat(radii.count)
236257

237258
// Calculate number of corners: consider as circle only if there are few sharp angle changes
238259
let angles = calculateTurningAngles(points: points)
@@ -278,10 +299,10 @@ private func totalLength(_ points: [CGPoint]) -> CGFloat {
278299
private func calculateTurningAngles(points: [CGPoint]) -> [CGFloat] {
279300
guard points.count > 2 else { return [] }
280301
var angles: [CGFloat] = []
281-
for i in 1..<points.count-1 {
282-
let a = points[i-1]
302+
for i in 1 ..< points.count - 1 {
303+
let a = points[i - 1]
283304
let b = points[i]
284-
let c = points[i+1]
305+
let c = points[i + 1]
285306
let v1 = CGPoint(x: b.x - a.x, y: b.y - a.y)
286307
let v2 = CGPoint(x: c.x - b.x, y: c.y - b.y)
287308
let angle = atan2(v2.y, v2.x) - atan2(v1.y, v1.x)

Sources/Core/CGSize+Extension.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22

33
public extension CGSize {
4+
/// Calculate the Euclidean distance (magnitude) of the size vector
45
var distance: CGFloat {
56
return sqrt(width * width + height * height)
67
}

0 commit comments

Comments
 (0)