This documentation is available as Markdown for AI agents and LLMs. See the full Markdown index or append .md to any documentation URL.

Add gestures

Edit page

In this tutorial, learn how to add double tap and drag gestures with React Native Gesture Handler and animate them with Reanimated in a React Native app.


Gestures are a great way to provide an intuitive user experience in an app. The React Native Gesture Handler library provides built-in native components that can handle gestures. It recognizes pan, tap, rotation, and other gestures using the platform's native touch handling system. In this chapter, we'll add two different gestures using this library:

  • Double tap to scale the size of the emoji sticker and reduce the scale when double tapped again.
  • Pan to move the emoji sticker so that the user can place the sticker anywhere on the image. The sticker stays inside the image.

We'll also use the Reanimated library to animate between gesture states. The default template already includes both libraries.

Watch: Adding gestures to your universal Expo app
Watch: Adding gestures to your universal Expo app

Add double tap and pan gestures to the emoji sticker using React Native Gesture Handler and Reanimated.


1

Add GestureHandlerRootView

To get gesture interactions to work in the app, we'll render <GestureHandlerRootView> from react-native-gesture-handler at the root of the app. Gestures are recognized only inside this view. In src/app/_layout.tsx, wrap the <Stack> with <GestureHandlerRootView>:

src/app/_layout.tsx
import { Stack } from 'expo-router'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; export default function RootLayout() { return ( <GestureHandlerRootView> <Stack> <Stack.Screen name="(tabs)" options={{ headerShown: false }} /> </Stack> </GestureHandlerRootView> ); }

By default, <GestureHandlerRootView> has the flex: 1 style, so it fills the screen.

2

Use animated components

An Animated component looks at the style prop of the component and determines which values to animate and apply updates to create an animation. Reanimated exports animated components such as <Animated.View>, <Animated.Text>, or <Animated.ScrollView>. We'll use <Animated.View> to animate the emoji sticker.

The sticker must also stay on the image. In the previous chapter, a negative top value placed the sticker on the image. Let's position the sticker relative to the image instead:

  1. In src/app/(tabs)/index.tsx, wrap the ImageViewer and EmojiSticker components with a <View>. This <View> has the same size as the image.
  2. Open the emoji-sticker.tsx file in the src/components directory. Inside it, import Animated from the react-native-reanimated library to use animated components.
  3. Replace the <View> component with <Animated.View>, and place it at the top-left corner of the image with position: 'absolute'. The Image fills the <Animated.View>.
src/app/(tabs)/index.tsx
// ...rest of the import statements remain same export default function Index() { // ...rest of the code remains same return ( <SafeAreaView style={styles.container}> <View style={styles.imageContainer}> <View> <ImageViewer imgSource={PlaceholderImage} selectedImage={selectedImage} /> {pickedEmoji && <EmojiSticker imageSize={40} stickerSource={pickedEmoji} />} </View> </View> {/* ...rest of the code remains same */} </SafeAreaView> ); }
src/components/emoji-sticker.tsx
import { StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; import { Image, type ImageSource } from 'expo-image'; type Props = { imageSize: number; stickerSource: ImageSource; }; export default function EmojiSticker({ imageSize, stickerSource }: Props) { return ( <Animated.View style={[styles.sticker, { width: imageSize, height: imageSize }]}> <Image source={stickerSource} style={styles.image} /> </Animated.View> ); } const styles = StyleSheet.create({ sticker: { position: 'absolute', top: 0, left: 0, }, image: { width: '100%', height: '100%', }, });

3

Add a tap gesture

React Native Gesture Handler allows us to add behavior when it detects touch input, like a double tap event.

In the src/components/emoji-sticker.tsx file:

  1. Import GestureDetector and useTapGesture from react-native-gesture-handler.
  2. To recognize the tap on the sticker, import useAnimatedStyle, useSharedValue, and withSpring from react-native-reanimated to animate the style of the <Animated.View>.
  3. Inside the EmojiSticker component, create a reference called scaleImage using the useSharedValue() hook. It will take the value of imageSize as its initial value.
src/components/emoji-sticker.tsx
// ...rest of the import statements remain same import { GestureDetector, useTapGesture } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; export default function EmojiSticker({ imageSize, stickerSource }: Props) { const scaleImage = useSharedValue(imageSize); return ( // ...rest of the code remains same ) }

Creating a shared value using the useSharedValue() hook has many advantages. It helps to mutate data and runs animations based on the current value. We read a shared value with its get() method and change it with its set() method. The default template enables the React Compiler, and with the React Compiler, Reanimated recommends get() and set() instead of the value property.

We'll create a doubleTap gesture with the useTapGesture() hook. The numberOfTaps option sets the number of taps required, and the onActivate callback runs when the gesture is recognized. It doubles the size of the sticker, or returns it to its initial size.

Create the following gesture in the EmojiSticker component:

src/components/emoji-sticker.tsx
const doubleTap = useTapGesture({ numberOfTaps: 2, onActivate: () => { scaleImage.set(scaleImage.get() === imageSize ? imageSize * 2 : imageSize); }, });

To animate the transition, let's use a spring-based animation. This will make it feel alive because it's based on the real-world physics of a spring. We will use the withSpring() function provided by react-native-reanimated.

On the sticker, we'll use the useAnimatedStyle() hook to create a style object. This will help us to update styles using shared values when the animation happens. We'll also scale the size of the sticker by manipulating the width and height properties. The initial values of these properties are set to imageSize.

Create a stickerStyle variable and add it to the EmojiSticker component:

src/components/emoji-sticker.tsx
const stickerStyle = useAnimatedStyle(() => { return { width: withSpring(scaleImage.get()), height: withSpring(scaleImage.get()), }; });

Next, wrap the <Animated.View> component with the <GestureDetector> and replace the size in the style prop on the <Animated.View> with stickerStyle.

src/components/emoji-sticker.tsx
import { StyleSheet } from 'react-native'; import { GestureDetector, useTapGesture } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; import { Image, type ImageSource } from 'expo-image'; type Props = { imageSize: number; stickerSource: ImageSource; }; export default function EmojiSticker({ imageSize, stickerSource }: Props) { const scaleImage = useSharedValue(imageSize); const doubleTap = useTapGesture({ numberOfTaps: 2, onActivate: () => { scaleImage.set(scaleImage.get() === imageSize ? imageSize * 2 : imageSize); }, }); const stickerStyle = useAnimatedStyle(() => { return { width: withSpring(scaleImage.get()), height: withSpring(scaleImage.get()), }; }); return ( <GestureDetector gesture={doubleTap}> <Animated.View style={[styles.sticker, stickerStyle]}> <Image source={stickerSource} style={styles.image} /> </Animated.View> </GestureDetector> ); } const styles = StyleSheet.create({ sticker: { position: 'absolute', top: 0, left: 0, }, image: { width: '100%', height: '100%', }, });

In the above snippet, the gesture prop takes the value of the doubleTap to trigger a gesture when a user double-taps the sticker.

Let's take a look at our app on Android, iOS, and web:

4

Add a pan gesture

To recognize a dragging gesture on the sticker and to track its movement, we'll use a pan gesture. The sticker must stay inside the image, so we'll also limit its position. In the src/components/emoji-sticker.tsx file:

  1. Create two new shared values: translateX and translateY. They hold the position of the sticker. Their initial value of 0 keeps the sticker at the top-left corner of the image.
  2. Create a drag gesture with the usePanGesture() hook. Its onUpdate callback runs while the finger moves. The changeX and changeY properties of the event hold the change in position since the last event.
  3. Pass each new position to the clamp() function from Reanimated. It limits a value between a minimum and a maximum. The image is 320 wide and 440 tall, as set in image-viewer.tsx. The largest position is the image size minus the sticker size.
  4. In the doubleTap gesture, clamp the position after the size changes. A sticker that doubles its size near an edge then stays inside the image.
  5. Combine both gestures with the useSimultaneousGestures() hook, so the sticker recognizes both gestures at the same time.
  6. Add a transform property to stickerStyle to move the sticker with the translateX and translateY values.
  7. Set pointerEvents to 'none' on the Image. On web, the browser starts its own drag when the user presses an image and moves the pointer, which cancels the pan gesture. With this style, the <Animated.View> receives the pointer events instead.
src/components/emoji-sticker.tsx
import { StyleSheet } from 'react-native'; import { GestureDetector, usePanGesture, useSimultaneousGestures, useTapGesture, } from 'react-native-gesture-handler'; import Animated, { clamp, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; import { Image, type ImageSource } from 'expo-image'; type Props = { imageSize: number; stickerSource: ImageSource; }; const IMAGE_WIDTH = 320; const IMAGE_HEIGHT = 440; export default function EmojiSticker({ imageSize, stickerSource }: Props) { const scaleImage = useSharedValue(imageSize); const translateX = useSharedValue(0); const translateY = useSharedValue(0); const doubleTap = useTapGesture({ numberOfTaps: 2, onActivate: () => { const size = scaleImage.get() === imageSize ? imageSize * 2 : imageSize; scaleImage.set(size); translateX.set(clamp(translateX.get(), 0, IMAGE_WIDTH - size)); translateY.set(clamp(translateY.get(), 0, IMAGE_HEIGHT - size)); }, }); const drag = usePanGesture({ onUpdate: event => { translateX.set(clamp(translateX.get() + event.changeX, 0, IMAGE_WIDTH - scaleImage.get())); translateY.set(clamp(translateY.get() + event.changeY, 0, IMAGE_HEIGHT - scaleImage.get())); }, }); const gesture = useSimultaneousGestures(drag, doubleTap); const stickerStyle = useAnimatedStyle(() => { return { width: withSpring(scaleImage.get()), height: withSpring(scaleImage.get()), transform: [{ translateX: translateX.get() }, { translateY: translateY.get() }], }; }); return ( <GestureDetector gesture={gesture}> <Animated.View style={[styles.sticker, stickerStyle]}> <Image source={stickerSource} style={styles.image} /> </Animated.View> </GestureDetector> ); } const styles = StyleSheet.create({ sticker: { position: 'absolute', top: 0, left: 0, }, image: { width: '100%', height: '100%', pointerEvents: 'none', }, });

Let's take a look at our app on Android, iOS, and web:

Summary

Chapter 6: Add gestures

We've successfully implemented pan and tap gestures.

In the next chapter, we'll learn how to take a screenshot of the image and the sticker, and save it on the device's library.

Next: Chapter 7: Take a screenshot