SwiftUI Gestures & Interactions
Purpose
Implementing touch interactions, gestures, and haptic feedback in SwiftUI for engaging, responsive user experiences.
Basic Gestures
Tap Gesture
// Single tap
Text("Tap me")
.onTapGesture {
print("Tapped!")
}
// Double tap
Image("photo")
.onTapGesture(count: 2) {
toggleZoom()
}
// Tap with location
Color.blue
.frame(width: 200, height: 200)
.onTapGesture { location in
print("Tapped at: \(location)")
}
Long Press
struct LongPressButton: View {
@State private var isPressed = false
var body: some View {
Circle()
.fill(isPressed ? .red : .blue)
.frame(width: 100, height: 100)
.onLongPressGesture(minimumDuration: 0.5) {
// Completed long press
performAction()
} onPressingChanged: { pressing in
withAnimation(.easeInOut(duration: 0.2)) {
isPressed = pressing
}
}
}
}
Drag Gesture
struct DraggableCard: View {
@State private var offset = CGSize.zero
@State private var isDragging = false
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(.blue)
.frame(width: 150, height: 100)
.offset(offset)
.scaleEffect(isDragging ? 1.05 : 1.0)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
withAnimation(.spring) {
offset = .zero
isDragging = false
}
}
)
}
}
// Drag with velocity (swipe to dismiss)
struct SwipeToDismiss: View {
@State private var offset = CGSize.zero
@Binding var isPresented: Bool
var body: some View {
ContentView()
.offset(y: offset.height)
.gesture(
DragGesture()
.onChanged { value in
if value.translation.height > 0 {
offset = value.translation
}
}
.onEnded { value in
if value.translation.height > 100 ||
value.predictedEndTranslation.height > 300 {
withAnimation {
isPresented = false
}
} else {
withAnimation(.spring) {
offset = .zero
}
}
}
)
}
}
Magnification (Pinch)
struct ZoomableImage: View {
@State private var scale: CGFloat = 1.0
@State private var lastScale: CGFloat = 1.0
var body: some View {
Image("photo")
.resizable()
.scaledToFit()
.scaleEffect(scale)
.gesture(
MagnificationGesture()
.onChanged { value in
scale = lastScale * value
}
.onEnded { value in
lastScale = scale
// Clamp scale
if scale < 1 {
withAnimation(.spring) {
scale = 1
lastScale = 1
}
} else if scale > 4 {
withAnimation(.spring) {
scale = 4
lastScale = 4
}
}
}
)
}
}
Rotation
struct RotatableView: View {
@State private var angle: Angle = .zero
@State private var lastAngle: Angle = .zero
var body: some View {
Image(systemName: "arrow.up")
.font(.system(size: 60))
.rotationEffect(angle)
.gesture(
RotationGesture()
.onChanged { value in
angle = lastAngle + value
}
.onEnded { value in
lastAngle = angle
}
)
}
}
Gesture Composition
Simultaneous Gestures
// Pinch and rotate at the same time
struct TransformableView: View {
@State private var scale: CGFloat = 1.0
@State private var angle: Angle = .zero
var body: some View {
Image("photo")
.scaleEffect(scale)
.rotationEffect(angle)
.gesture(
MagnificationGesture()
.onChanged { scale = $0 }
.simultaneously(with:
RotationGesture()
.onChanged { angle = $0 }
)
)
}
}
Sequential Gestures
// Long press then drag
struct LongPressDraggable: View {
@State private var offset = CGSize.zero
@State private var isActive = false
var body: some View {
Circle()
.fill(isActive ? .green : .blue)
.frame(width: 100, height: 100)
.offset(offset)
.gesture(
LongPressGesture(minimumDuration: 0.3)
.onEnded { _ in
isActive = true
}
.sequenced(before:
DragGesture()
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation {
offset = .zero
isActive = false
}
}
)
)
}
}
Exclusive Gestures
// Only one gesture recognized
struct ExclusiveGestureView: View {
var body: some View {
Rectangle()
.gesture(
TapGesture(count: 2)
.onEnded { handleDoubleTap() }
.exclusively(before:
TapGesture()
.onEnded { handleSingleTap() }
)
)
}
}
High Priority Gestures
// Parent gesture takes priority
struct ParentGestureView: View {
var body: some View {
VStack {
Button("Child Button") {
print("Button tapped")
}
}
.frame(width: 200, height: 200)
.background(.gray.opacity(0.2))
.highPriorityGesture(
TapGesture()
.onEnded { print("Parent tapped") }
)
}
}
Haptic Feedback
UIKit Haptics
struct HapticButton: View {
var body: some View {
Button("Tap for Haptic") {
// Impact feedback
let impact = UIImpactFeedbackGenerator(style: .medium)
impact.impactOccurred()
}
}
}
// Different feedback types
func triggerHaptics() {
// Impact - for collisions
let impact = UIImpactFeedbackGenerator(style: .light) // .light, .medium, .heavy, .soft, .rigid
impact.impactOccurred()
// Selection - for selection changes
let selection = UISelectionFeedbackGenerator()
selection.selectionChanged()
// Notification - for success/warning/error
let notification = UINotificationFeedbackGenerator()
notification.notificationOccurred(.success) // .success, .warning, .error
}
Sensory Feedback (iOS 17+)
struct ModernHapticView: View {
@State private var value = 0
var body: some View {
Button("Increment") {
value += 1
}
.sensoryFeedback(.increase, trigger: value)
}
}
// Available feedback types:
// .success, .warning, .error
// .selection
// .increase, .decrease
// .start, .stop
// .alignment, .levelChange
// .impact(weight:intensity:), .impact(flexibility:intensity:)
Haptic Patterns
// Prepare haptics for responsive feedback
class HapticManager {
static let shared = HapticManager()
private var impactLight: UIImpactFeedbackGenerator?
private var impactMedium: UIImpactFeedbackGenerator?
func prepare() {
impactLight = UIImpactFeedbackGenerator(style: .light)
impactLight?.prepare()
impactMedium = UIImpactFeedbackGenerator(style: .medium)
impactMedium?.prepare()
}
func lightTap() {
impactLight?.impactOccurred()
impactLight?.prepare()
}
func mediumTap() {
impactMedium?.impactOccurred()
impactMedium?.prepare()
}
}
Interactive Components
Custom Slider
struct CustomSlider: View {
@Binding var value: Double
let range: ClosedRange<Double>
@State private var isDragging = false
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Track
RoundedRectangle(cornerRadius: 4)
.fill(.gray.opacity(0.3))
.frame(height: 8)
// Fill
RoundedRectangle(cornerRadius: 4)
.fill(.blue)
.frame(width: thumbPosition(in: geometry), height: 8)
// Thumb
Circle()
.fill(.white)
.shadow(radius: 2)
.frame(width: 24, height: 24)
.offset(x: thumbPosition(in: geometry) - 12)
.scaleEffect(isDragging ? 1.2 : 1.0)
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
isDragging = true
updateValue(gesture.location.x, in: geometry)
// Haptic on drag start
if !isDragging {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
.onEnded { _ in
isDragging = false
}
)
}
.frame(height: 24)
}
private func thumbPosition(in geometry: GeometryProxy) -> CGFloat {
let percent = (value - range.lowerBound) / (range.upperBound - range.lowerBound)
return geometry.size.width * CGFloat(percent)
}
private func updateValue(_ x: CGFloat, in geometry: GeometryProxy) {
let percent = Double(x / geometry.size.width)
let clamped = min(max(percent, 0), 1)
value = range.lowerBound + (range.upperBound - range.lowerBound) * clamped
}
}
Pull to Refresh
struct RefreshableList: View {
@State private var items: [Item] = []
var body: some View {
List(items) { item in
ItemRow(item: item)
}
.refreshable {
// Async refresh
await loadItems()
}
}
func loadItems() async {
// Fetch data
items = try? await api.fetchItems() ?? []
}
}
Scroll Position Detection
struct ScrollPositionView: View {
@State private var scrollOffset: CGFloat = 0
var body: some View {
ScrollView {
VStack {
ForEach(0..<50) { index in
Text("Row \(index)")
.frame(maxWidth: .infinity)
.padding()
}
}
.background(
GeometryReader { geometry in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named("scroll")).minY
)
}
)
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
scrollOffset = value
}
.overlay(alignment: .top) {
// Shrinking header based on scroll
Header()
.scaleEffect(max(0.8, min(1, 1 + scrollOffset / 200)))
}
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Accessibility
Accessible Gestures
struct AccessibleCard: View {
var body: some View {
CardContent()
.onTapGesture {
selectCard()
}
.accessibilityAction {
selectCard() // Triggered by VoiceOver activation
}
.accessibilityHint("Double tap to select")
}
}
// Custom accessibility actions
struct MultiActionView: View {
var body: some View {
ItemRow()
.accessibilityAction(named: "Delete") {
deleteItem()
}
.accessibilityAction(named: "Favorite") {
toggleFavorite()
}
}
}
Best Practices
- Provide visual feedback - Show state changes during gestures
- Use haptics sparingly - Reinforce key interactions only
- Support accessibility - Add
.accessibilityActionfor custom gestures - Test on device - Simulator doesn't capture gesture nuances
- Consider gesture conflicts - Use
.highPriorityGestureor.simultaneousGesture - Prepare haptic generators - Call
.prepare()before time-sensitive feedback
Related Skills
- swiftui-components: UI elements
- swiftui-architecture: State management for gestures
- swiftui-testing: Testing gesture interactions