Implementing touch interactions, gestures, and haptic feedback in SwiftUI for engaging, responsive user experiences.
Implements SwiftUI gestures, haptic feedback, and interactive components for responsive iOS apps.
npx claudepluginhub arustydev/aiThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Implementing touch interactions, gestures, and haptic feedback in SwiftUI for engaging, responsive user experiences.
// 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)")
}
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
}
}
}
}
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
}
}
}
)
}
}
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
}
}
}
)
}
}
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
}
)
}
}
// 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 }
)
)
}
}
// 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
}
}
)
)
}
}
// Only one gesture recognized
struct ExclusiveGestureView: View {
var body: some View {
Rectangle()
.gesture(
TapGesture(count: 2)
.onEnded { handleDoubleTap() }
.exclusively(before:
TapGesture()
.onEnded { handleSingleTap() }
)
)
}
}
// 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") }
)
}
}
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
}
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:)
// 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()
}
}
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
}
}
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() ?? []
}
}
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()
}
}
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()
}
}
}
.accessibilityAction for custom gestures.highPriorityGesture or .simultaneousGesture.prepare() before time-sensitive feedbackActivates when the user asks about AI prompts, needs prompt templates, wants to search for prompts, or mentions prompts.chat. Use for discovering, retrieving, and improving prompts.
Search, retrieve, and install Agent Skills from the prompts.chat registry using MCP tools. Use when the user asks to find skills, browse skill catalogs, install a skill for Claude, or extend Claude's capabilities with reusable AI agent components.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.