Advanced multi-touch gesture recognizers for SwiftUI, bringing the full power of UIKit's UIGestureRecognizer to SwiftUI with complete feature parity and Swift 6 concurrency support.
Written by @jacobvo.
A.I. Disclaimer:
Initial concept of one gesture hand-coded but then Claude assisted with replication of additional gestures.
SwiftUI's built-in gesture system is powerful but has limitations:
DragGestureonly supports single-finger dragging- No native support for multi-finger gestures (2+ fingers)
- Limited access to UIKit-specific features like velocity, number of touches, and precise gesture states
- No control over gesture recognizer delegate methods for complex gesture interactions
UIKitGesturesForSwiftUI bridges this gap by exposing UIKit's full gesture recognizer capabilities directly in SwiftUI.
✅ Multi-finger gesture support - Pan, tap, swipe, pinch, rotate with 2+ fingers
✅ Full UIKit feature parity - Access velocity, translation, rotation, scale, and more
✅ Gesture delegate control - Customize simultaneous recognition, failure requirements, and touch handling
✅ Swift 6 concurrency safe - All closures are @MainActor isolated
✅ Declarative builder API - Chain .onBegan, .onChanged, .onEnded naturally
✅ Comprehensive documentation - Inline docs for every gesture and method
✅ Somewhat tested - 23 unit tests covering core functionality
- iOS 18.0+
- Swift 6.0+
- Xcode 16.0+
Add this package to your Package.swift:
dependencies: [
.package(url: "https://github.com/yourusername/UIKitGesturesForSwiftUI.git", from: "1.0.0")
]Or in Xcode:
- File → Add Package Dependencies
- Enter the repository URL
- Select version and add to your target
import SwiftUI
import UIKitGesturesForSwiftUI
struct ContentView: View {
@State private var offset = CGSize.zero
var body: some View {
Rectangle()
.fill(.blue)
.frame(width: 200, height: 200)
.offset(offset)
.gesture(
MultiFingerPanGesture(
minimumNumberOfTouches: 2,
maximumNumberOfTouches: 2
)
.onChanged { recognizer in
let translation = recognizer.translation(in: recognizer.view)
offset = CGSize(width: translation.x, height: translation.y)
}
)
}
}Continuous gesture for tracking multi-finger panning with velocity and translation.
MultiFingerPanGesture(
minimumNumberOfTouches: 2,
maximumNumberOfTouches: 2
)
.onBegan { recognizer in
print("Pan started")
}
.onChanged { recognizer in
let translation = recognizer.translation(in: recognizer.view)
let velocity = recognizer.velocity(in: recognizer.view)
print("Translation: \(translation), Velocity: \(velocity)")
}
.onEnded { recognizer in
let finalVelocity = recognizer.velocity(in: recognizer.view)
print("Pan ended with velocity: \(finalVelocity)")
}Use Cases:
- Two-finger scrolling
- Multi-touch drag operations
- Gesture-based navigation
Discrete gesture for detecting multi-finger taps with configurable tap count.
MultiFingerTapGesture(
numberOfTouchesRequired: 3,
numberOfTapsRequired: 2 // Double-tap with 3 fingers
)
.onEnded { recognizer in
print("Triple-finger double-tap detected!")
}Use Cases:
- Accessibility shortcuts
- Hidden debug menus
- Advanced user interactions
Continuous gesture for tracking pinch-to-zoom with scale and velocity.
@State private var scale: CGFloat = 1.0
MultiFingerPinchGesture()
.onChanged { recognizer in
scale *= recognizer.scale
recognizer.scale = 1.0 // Reset for next update
}
.onEnded { recognizer in
let finalVelocity = recognizer.velocity
print("Pinch ended with velocity: \(finalVelocity)")
}Use Cases:
- Zoom controls
- Image scaling
- Map interactions
Continuous gesture for tracking rotation with angle and velocity.
@State private var rotation: Angle = .zero
MultiFingerRotationGesture()
.onChanged { recognizer in
rotation += Angle(radians: recognizer.rotation)
recognizer.rotation = 0 // Reset for next update
}
.onEnded { recognizer in
let finalVelocity = recognizer.velocity
print("Rotation velocity: \(finalVelocity) radians/sec")
}Use Cases:
- Image rotation
- 3D object manipulation
- Creative tools
Discrete gesture for detecting directional swipes with multiple fingers.
MultiFingerSwipeGesture(
numberOfTouchesRequired: 3,
direction: .down
)
.onEnded { recognizer in
print("Three-finger swipe down detected!")
}Directions: .up, .down, .left, .right
Use Cases:
- Navigation gestures
- App switching
- Custom gesture controls
Continuous gesture for long-press detection with configurable duration and movement tolerance.
MultiFingerLongPressGesture(
numberOfTouchesRequired: 2,
minimumPressDuration: 1.0,
allowableMovement: 10
)
.onBegan { recognizer in
print("Long press began")
}
.onEnded { recognizer in
print("Long press ended")
}Use Cases:
- Context menus
- Selection mode
- Secondary actions
This is just an example of a custom UIGestureRecognizer subclass that is then extended into SwiftUI. Continuous gesture combining pan, pinch, and rotation into a single transform.
@State private var transform = CGAffineTransform.identity
MultiFingerTransformGesture(
minimumNumberOfTouches: 2,
maximumNumberOfTouches: 2
)
.onChanged { recognizer in
transform = recognizer.transform
print("Translation: \(recognizer.translation)")
print("Rotation: \(recognizer.rotation)")
print("Scale: \(recognizer.scale)")
}Use Cases:
- Photo editing
- Object manipulation
- Canvas interactions
All gestures support full UIGestureRecognizerDelegate customization via optional closures.
Allow multiple gestures to recognize at the same time:
MultiFingerPanGesture(minimumNumberOfTouches: 2, maximumNumberOfTouches: 2)
.shouldRecognizeSimultaneouslyWith { otherGesture in
// Allow simultaneous recognition with pinch gestures
return otherGesture is UIPinchGestureRecognizer
}Default: true (allows simultaneous recognition by default)
Conditionally allow or prevent gestures from starting:
@State private var isGestureEnabled = true
MultiFingerPanGesture(minimumNumberOfTouches: 2, maximumNumberOfTouches: 2)
.shouldBegin { recognizer in
return isGestureEnabled
}Control which touches or events the gesture responds to:
MultiFingerPanGesture(minimumNumberOfTouches: 2, maximumNumberOfTouches: 2)
.shouldReceiveTouch { recognizer, touch in
// Only respond to touches inside specific views
guard let view = touch.view else { return false }
return view.tag == 100
}Require one gesture to fail before another begins:
let tapGesture = MultiFingerTapGesture(numberOfTouchesRequired: 1, numberOfTapsRequired: 2)
let panGesture = MultiFingerPanGesture(minimumNumberOfTouches: 1, maximumNumberOfTouches: 1)
.shouldRequireFailureOf { otherGesture in
// Pan only starts if double-tap fails
return otherGesture is UITapGestureRecognizer
}MultiFingerPanGesture(
minimumNumberOfTouches: 2,
maximumNumberOfTouches: 2,
shouldBegin: { recognizer in
// Only allow if conditions are met
return someCondition
},
shouldRecognizeSimultaneouslyWith: { otherGesture in
// Allow with pinch, block others
return otherGesture is UIPinchGestureRecognizer
},
shouldReceiveTouch: { recognizer, touch in
// Filter touches by location
let location = touch.location(in: touch.view)
return location.x > 100
}
)
.onChanged { recognizer in
// Handle gesture
}All continuous gestures follow this state machine:
.possible → .began → .changed (repeated) → .ended
↘ .cancelled
↘ .failed
Discrete gestures (tap, swipe) transition directly to .onEnded
Callbacks provided:
.onBegan- Gesture started (continuous only).onChanged- Gesture updated (continuous only).onEnded- Gesture completed (continuous and discrete)
Note: .cancelled and .failed states are not exposed as they indicate the gesture did not complete successfully.
All gesture closures are marked @MainActor because:
✅ UIKit gesture recognizers always call delegates on the main thread
✅ SwiftUI view updates must happen on the main thread
✅ You can safely capture @MainActor isolated state (view models, etc.)
@MainActor
@Observable
class ViewModel {
var count = 0
}
let viewModel = ViewModel()
MultiFingerPanGesture(minimumNumberOfTouches: 2, maximumNumberOfTouches: 2)
.onChanged { recognizer in
viewModel.count += 1 // ✅ Safe - both are @MainActor
}struct ScrollableCanvas: View {
@State private var offset = CGSize.zero
@State private var scale: CGFloat = 1.0
var body: some View {
Canvas { context, size in
// Draw content here
}
.scaleEffect(scale)
.offset(offset)
.gesture(
MultiFingerPanGesture(
minimumNumberOfTouches: 2,
maximumNumberOfTouches: 2
)
.onChanged { recognizer in
let translation = recognizer.translation(in: recognizer.view)
offset = CGSize(width: translation.x, height: translation.y)
}
)
.gesture(
MultiFingerPinchGesture()
.onChanged { recognizer in
scale *= recognizer.scale
recognizer.scale = 1.0
}
)
}
}struct PhotoEditor: View {
@State private var imageTransform = CGAffineTransform.identity
var body: some View {
Image("photo")
.resizable()
.aspectRatio(contentMode: .fit)
.transformEffect(imageTransform)
.gesture(
MultiFingerTransformGesture(
minimumNumberOfTouches: 2,
maximumNumberOfTouches: 2
)
.onChanged { recognizer in
imageTransform = recognizer.transform
}
.onEnded { recognizer in
// Optionally apply momentum or snap to grid
}
)
}
}struct GestureDemo: View {
@State private var singleTapCount = 0
@State private var doubleTapCount = 0
var body: some View {
Rectangle()
.fill(.blue)
.gesture(
MultiFingerTapGesture(
numberOfTouchesRequired: 1,
numberOfTapsRequired: 1,
shouldRequireFailureOf: { other in
// Wait for double-tap to fail
other is UITapGestureRecognizer && other.numberOfTapsRequired == 2
}
)
.onEnded { _ in
singleTapCount += 1
}
)
.gesture(
MultiFingerTapGesture(
numberOfTouchesRequired: 1,
numberOfTapsRequired: 2
)
.onEnded { _ in
doubleTapCount += 1
}
)
}
}All gestures support these delegate customization parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
shouldBegin |
@MainActor (UIGestureRecognizer) -> Bool |
true |
Control if gesture should begin |
shouldRecognizeSimultaneouslyWith |
@MainActor (UIGestureRecognizer) -> Bool |
true |
Allow simultaneous recognition |
shouldReceiveTouch |
@MainActor (UIGestureRecognizer, UITouch) -> Bool |
true |
Filter touches |
shouldReceivePress |
@MainActor (UIGestureRecognizer, UIPress) -> Bool |
true |
Filter button presses |
shouldReceiveEvent |
@MainActor (UIGestureRecognizer, UIEvent) -> Bool |
true |
Filter events |
shouldRequireFailureOf |
@MainActor (UIGestureRecognizer) -> Bool |
false |
Require other gesture to fail |
shouldBeRequiredToFailBy |
@MainActor (UIGestureRecognizer) -> Bool |
false |
Be required to fail by other |
Note: Unlike UIKit's default of false, this library defaults shouldRecognizeSimultaneouslyWith to true for better multi-gesture composition.
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Write tests for new functionality
- Ensure all tests pass and there are no warnings
- Follow Swift API Design Guidelines
- Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.