useGesture
A composable for handling touch and mouse gestures across iOS, Android, and macOS.
Overview
useGesture provides a unified API for handling common gestures like pan, pinch, rotate, swipe, and more. It works with template refs or node IDs and automatically manages event listener lifecycle.
Basic Usage
<script setup>
import { ref } from 'vue'
import { useGesture, VView, VText } from '@thelacanians/vue-native-runtime'
const viewRef = ref()
const { pan, isGesturing } = useGesture(viewRef, {
pan: true,
pinch: true,
})
// pan.value = { translationX, translationY, velocityX, velocityY, state }
// isGesturing.value = true while gesture is active
</script>
<template>
<VView
ref="viewRef"
:style="{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f0f0f0'
}"
>
<VText v-if="pan">
Pan: {{ pan.translationX.toFixed(0) }}, {{ pan.translationY.toFixed(0) }}
</VText>
<VText v-else>Start panning...</VText>
</VView>
</template>
Supported Gestures
| Gesture | Platform | Description |
|---|---|---|
pan | iOS, Android, macOS | Drag/finger movement tracking |
pinch | iOS, Android, macOS | Two-finger pinch to zoom |
rotate | iOS, Android, macOS | Two-finger rotation gesture |
swipeLeft | iOS, Android | Fast swipe left |
swipeRight | iOS, Android | Fast swipe right |
swipeUp | iOS, Android | Fast swipe up |
swipeDown | iOS, Android | Fast swipe down |
press | iOS, Android, macOS | Single tap |
longPress | iOS, Android, macOS | Long press (hold) |
doubleTap | iOS, Android, macOS | Double tap |
forceTouch | iOS, macOS | 3D Touch / Force Touch pressure |
hover | iOS (13+), macOS | Mouse/touch hover tracking |
Gesture State Types
PanGestureState
interface PanGestureState {
translationX: number
translationY: number
velocityX: number
velocityY: number
state: 'began' | 'changed' | 'ended' | 'cancelled'
}
PinchGestureState
interface PinchGestureState {
scale: number // 1.0 = no scale
velocity: number
state: 'began' | 'changed' | 'ended' | 'cancelled'
}
RotateGestureState
interface RotateGestureState {
rotation: number // radians
velocity: number
state: 'began' | 'changed' | 'ended' | 'cancelled'
}
SwipeGestureState
interface SwipeGestureState {
direction: 'left' | 'right' | 'up' | 'down'
locationX: number
locationY: number
}
TapGestureState
interface TapGestureState {
locationX: number
locationY: number
tapCount: number
}
ForceTouchState
interface ForceTouchState {
force: number // 0.0 - 1.0+ (normalized)
locationX: number
locationY: number
stage: number // 0 = no touch, 1+ = pressure levels
}
HoverState
interface HoverState {
locationX: number
locationY: number
state: 'entered' | 'moved' | 'exited'
}
Return Values
interface UseGestureReturn {
// Gesture state refs
pan: Ref<PanGestureState | null>
pinch: Ref<PinchGestureState | null>
rotate: Ref<RotateGestureState | null>
swipeLeft: Ref<SwipeGestureState | null>
swipeRight: Ref<SwipeGestureState | null>
swipeUp: Ref<SwipeGestureState | null>
swipeDown: Ref<SwipeGestureState | null>
press: Ref<TapGestureState | null>
longPress: Ref<TapGestureState | null>
doubleTap: Ref<TapGestureState | null>
forceTouch: Ref<ForceTouchState | null>
hover: Ref<HoverState | null>
// Computed refs
gestureState: Ref<GestureState | null> // Currently active gesture
activeGesture: Ref<string | null> // Name of active gesture
isGesturing: Ref<boolean> // True while gesture is active
// Methods
attach: (target: GestureTarget) => void // Manually attach to a view
detach: () => void // Remove all listeners
on: (event, callback) => () => void // Manual event binding
}
Options
interface UseGestureOptions {
pan?: boolean | GestureConfig
pinch?: boolean | GestureConfig
rotate?: boolean | GestureConfig
swipeLeft?: boolean | GestureConfig
swipeRight?: boolean | GestureConfig
swipeUp?: boolean | GestureConfig
swipeDown?: boolean | GestureConfig
press?: boolean | GestureConfig
longPress?: boolean | GestureConfig
doubleTap?: boolean | GestureConfig
forceTouch?: boolean | GestureConfig
hover?: boolean | GestureConfig
}
Example: Draggable View
<script setup>
import { ref, computed } from 'vue'
import { useGesture, VView, VText } from '@thelacanians/vue-native-runtime'
const viewRef = ref()
const offsetX = ref(0)
const offsetY = ref(0)
const { pan, isGesturing } = useGesture(viewRef, { pan: true })
// Update position on pan
const style = computed(() => ({
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: isGesturing.value ? '#007AFF' : '#666',
transform: [
{ translateX: offsetX.value + (pan.value?.translationX ?? 0) },
{ translateY: offsetY.value + (pan.value?.translationY ?? 0) },
],
}))
function onPanEnd() {
if (pan.value?.state === 'ended') {
offsetX.value += pan.value.translationX
offsetY.value += pan.value.translationY
}
}
</script>
<template>
<VView :style="{ flex: 1, backgroundColor: '#f5f5f5' }">
<VView ref="viewRef" :style="style">
<VText :style="{ color: '#fff' }">
{{ isGesturing ? 'Dragging...' : 'Drag me!' }}
</VText>
</VView>
</VView>
</template>
Native-driven gestures (smooth dragging)
By default, a pan gesture reports each frame to JavaScript and you apply the transform in a computed style (as above). That round-trip per frame can jank on complex screens. Set nativeDrive: true on the pan config and the native side applies the translation directly to the view's transform on the UI thread — no JS round-trip per frame:
<script setup>
import { ref } from 'vue'
import { useGesture, VView, VText } from '@thelacanians/vue-native-runtime'
const viewRef = ref()
// The view follows the finger natively; the callback still fires for state/`ended`.
const { pan } = useGesture(viewRef, { pan: { nativeDrive: true } })
function onPanEnd() {
if (pan.value?.state === 'ended') {
// Commit or snap the final position via a style update here.
}
}
</script>
<template>
<VView ref="viewRef" :style="{ width: 120, height: 120, backgroundColor: '#007AFF' }" />
</template>
Tips
With nativeDrive, the view's transform is updated natively during the gesture. The pan ref still updates (for state, velocity, and ended handling), but you don't need to drive the transform from a computed style for smooth motion. Reset or commit the final position in your ended handler.
Example: Pinch to Zoom
<script setup>
import { ref, computed } from 'vue'
import { useGesture, VView, VText, VImage } from '@thelacanians/vue-native-runtime'
const imageRef = ref()
const scale = ref(1)
const { pinch } = useGesture(imageRef, { pinch: true })
const imageStyle = computed(() => ({
width: 300,
height: 300,
transform: [{ scale: scale.value * (pinch.value?.scale ?? 1) }],
}))
function onPinchEnd() {
if (pinch.value?.state === 'ended') {
scale.value *= pinch.value.scale
}
}
</script>
<template>
<VView :style="{ flex: 1, justifyContent: 'center', alignItems: 'center' }">
<VImage
ref="imageRef"
:source="{ uri: 'https://example.com/image.jpg' }"
:style="imageStyle"
/>
<VText :style="{ marginTop: 16 }">
Scale: {{ (scale.value * (pinch.value?.scale ?? 1)).toFixed(2) }}
</VText>
</VView>
</template>
Example: Swipe Gallery
<script setup>
import { ref } from 'vue'
import { useGesture, VView, VText, VImage } from '@thelacanians/vue-native-runtime'
const images = [
'https://example.com/image1.jpg',
'https://example.com/image2.jpg',
'https://example.com/image3.jpg',
]
const currentIndex = ref(0)
const containerRef = ref()
const { swipeLeft, swipeRight } = useGesture(containerRef, {
swipeLeft: true,
swipeRight: true,
})
// React to swipes
import { watch } from 'vue'
watch(swipeLeft, (state) => {
if (state) {
currentIndex.value = Math.min(currentIndex.value + 1, images.length - 1)
}
})
watch(swipeRight, (state) => {
if (state) {
currentIndex.value = Math.max(currentIndex.value - 1, 0)
}
})
</script>
<template>
<VView ref="containerRef" :style="{ flex: 1, justifyContent: 'center', alignItems: 'center' }">
<VImage
:source="{ uri: images[currentIndex] }"
:style="{ width: 300, height: 300 }"
/>
<VText :style="{ marginTop: 16 }">
{{ currentIndex + 1 }} / {{ images.length }} - Swipe to navigate
</VText>
</VView>
</template>
useComposedGestures
For handling multiple simultaneous gestures with combined state:
<script setup>
import { ref } from 'vue'
import { useComposedGestures, VView, VText } from '@thelacanians/vue-native-runtime'
const viewRef = ref()
const {
pan,
pinch,
rotate,
isGesturing,
isPinchingAndRotating,
isPanningAndPinching
} = useComposedGestures(viewRef)
</script>
<template>
<VView ref="viewRef" :style="{ flex: 1, justifyContent: 'center', alignItems: 'center' }">
<VText v-if="isPinchingAndRotating">Pinching + Rotating!</VText>
<VText v-else-if="isPanningAndPinching">Panning + Pinching!</VText>
<VText v-else-if="isGesturing">Gesture active</VText>
<VText v-else>Use two fingers to transform</VText>
</VView>
</template>
Manual Event Binding
For advanced use cases, use the on() method:
<script setup>
import { ref, onMounted } from 'vue'
import { useGesture, VView } from '@thelacanians/vue-native-runtime'
const viewRef = ref()
const { attach, on, detach } = useGesture()
onMounted(() => {
attach(viewRef)
// Manual event binding
const disposePress = on('press', (state) => {
console.log('Pressed at', state.locationX, state.locationY)
})
const disposePan = on('pan', (state) => {
if (state.state === 'ended') {
console.log('Pan ended with velocity:', state.velocityX, state.velocityY)
}
})
// Cleanup on unmount (optional - onUnmounted handles this)
// disposePress()
// disposePan()
})
</script>
<template>
<VView ref="viewRef" :style="{ flex: 1 }">
<!-- content -->
</VView>
</template>
Platform Notes
iOS
- Uses native
UIGestureRecognizersubclasses forceTouchuses 3D Touch on supported deviceshoverrequires iOS 13+ (usesUIHoverGestureRecognizer)
Android
- Uses
GestureDetectorandScaleGestureDetector forceTouchis emulated based on touch area size
macOS
- Uses
NSGestureRecognizersubclasses forceTouchuses Force Touch trackpad pressurehoverusesNSTrackingAreafor mouse tracking
See Also
- VView - Basic container component
- VPressable - Pressable container with feedback
- useAnimation - Animation API