The swipe deck is a novel and intuitive way for a user to quickly interact with and sort through a long series of items. For those of you unfamiliar with Tinder, I envy you, and the concept is simple: a user is presented with one card at a time. They can swipe left to reject the card or swipe right to accept it. The card then animates off the screen, and the next card is shown.
This is an outright satanic way to perceive and interact with other human beings, but I recently worked on a project where I thought the swipe deck would be a good (and strictly ethical) fit. I built a swipe deck in Expo and React Native, and I want to share my approach with you.
We'll start by creating a new Expo project, and following the instructions to reset the project to a blank state.
npx create-expo-app@latest
cd my-new-project
npm run reset-project
Expo recommends a package called react-native-reanimated
for high-performance animations, and that package integrates tightly with react-native-gesture-handler
. We'll install both of these packages now.
npx expo install react-native-reanimated react-native-gesture-handler
We'll start by creating a new component called SwipeDeck
. This component will be responsible for rendering the cards and handling the swipe gestures.
import { useState } from "react";
import { View } from "react-native";
import { Card } from "./Card";
export function SwipeDeck() {
const [cards, setCards] = useState([
"Card 1",
"Card 2",
"Card 3",
"Card 4",
"Card 5",
]);
return (
<View style={{ flex: 1 }}>
{cards.map((card) => (
<Card key={card} title={card} />
))}
</View>
);
}
And a component called Card
. This component will draw the card and handle the animations.
import { View, Text, StyleSheet } from "react-native";
export function Card({ title }: { title: string }) {
return (
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
</View>
)
}
const styles = StyleSheet.create({
card: {
width: '100%',
height: '100%',
padding: 20,
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: 'white',
},
title: {
fontSize: 24,
},
});
Since this component is only responsible for presentation, we're passing the data (in our case, the title
) as a prop and simply rendering it in a View
.
The other important part of the Card
component is the position: 'absolute'
style and the position properties. This ensures the cards are stacked on top of each other and can be animated off the screen.
Those of you who are coming from a web background will be used to rearranging the stacking order of elements with the z-index
property. When building cross-platform mobile apps with React Native, there's a bit of a gotcha: Android's native rendering engine has a competing concept called elevation.
Combining z-index
and elevation can lead to unexpected bugs. In our case, since reversing the order of the cards in the array is trivial, we'll use that approach instead.
Here's our updated SwipeDeck
component:
export function SwipeDeck() {
const [cards, setCards] = useState([
"Card 1",
"Card 2",
"Card 3",
"Card 4",
"Card 5",
]);
const cardEls = cards.map((card) => (
<Card key={card} title={card} />
)).reverse();
return (
<View style={{ flex: 1 }}>
{cardEls}
</View>
);
}
As I mentioned in the introduction, we're using react-native-gesture-handler
to handle the swipe gestures and react-native-reanimated
to animate the cards off the screen.
The first thing we'll need to do is add the GestureHandlerRootView
component to our root component. If you're working from a blank project, this will be your _layout.tsx
file.
import { Stack } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="index" />
</Stack>
</GestureHandlerRootView>
);
}
The next thing we'll need to do is add a sharedValue
to track the position of the card. If you have experience with Framer Motion, this is similar to a motionValue
. It doesn't cause React re-renders when it's updated, and it accepts a variety of types to animate.
It's called a "shared" value not because it's shared between components, but because it's shared between the UI thread and the JavaScript thread.
We're also going to add replace our card's view with an animated view (Animated.View
) and create an animated style (useAnimatedStyle
) to handle the animation.
import { Text, StyleSheet } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue
} from "react-native-reanimated";
export function Card({ title }: { title: string }) {
const offset = useSharedValue(0);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}))
return (
<Animated.View
style={[styles.card, animatedStyle]}
>
<Text style={styles.title}>{title}</Text>
</Animated.View>
)
}
Next, we'll add our gesture handler.
const pan = Gesture.Pan()
.onChange((event) => {
offset.value = event.translationX;
})
.onFinalize((event) => {
if (event.translationX < -50) {
offset.value = withSpring(-500);
} else if (event.translationX > 50) {
offset.value = withSpring(500);
} else {
offset.value = withSpring(0);
}
})
There are a few things going on here:
- In the onChange event, we're setting the offset's value directly—a very straightforward way to update the position of the card
- In the onFinalize event, we're measuring the distance of the pan to determine whether the user has swiped left or right and we're also returning the card to its starting position if they didn't swipe far enough in either direction.
- We're using withSpring to do the legwork of the animation. This will ensure the card snaps to its destination with a nice, smooth, kinetic animation.
Let's give it a try!
This is already something we can work with. We can swipe the cards left and right and they smoothly animate off the screen.
However, that's all they do. They'll pile up off the screen and stay there until something bad happens. We need to add some logic to remove the cards from the array when they're swiped off the screen.
At the same time, we'll add logic for limiting the deck to two cards (as that's as many as you can see at once) and for adding new cards to the deck when the user swipes the top card.
As is the React way, we'll pass a function to the Card
component that will handle the swipe gestures.
const handleSwipe = useCallback(() => {
setCards((prev) => prev.slice(1));
}, [])
const cardEls = cards
.slice(0, 2)
.map((card) => (
<Card key={card} title={card} handleSwipe={handleSwipe} />
))
.reverse();
There are two valid approaches here: handling the swipe gesture in the Card
component or handling it in the SwipeDeck
component.
Handling it within the SwipeDeck
component addresses separation of concerns somewhat better (the cards are only responsible for rendering themselves), but in modern React it's pretty likely that your business logic methods for interacting with a specific Card will be handled within a hook in the Card component.
What's ultimately preferable is up to you and your project's needs.
Either way, we need to react to swipes within the SwipeDeck
component to manage the state of the deck. All we're doing here is removing the top card from the deck after it's been swiped.
Next, we'll add a handleSwipe
prop to the Card
component and call it from our gesture handler.
Here's the new gesture handler:
const pan = Gesture.Pan()
.onChange((event) => {
offset.value = event.translationX;
})
.onFinalize((event) => {
if (event.translationX < -50) {
offset.value = withSpring(-500, springConfig, () => {
runOnJS(handleSwipe)();
});
} else if (event.translationX > 50) {
offset.value = withSpring(500, springConfig, () => {
runOnJS(handleSwipe)();
});
} else {
offset.value = withSpring(0);
}
})
Notice the use of a new method, runOnJS
. react-native-gesture-handler
offloads gesture handling to the native thread, to keep the UI smooth and responsive. React, however, lives in the JavaScript thread. If you try to directly call your common React functions from the gesture handler, the app will crash.
runOnJS
, imported from react-native-gesture-handler
, allows us to call our JavaScript functions from the native thread.
We're calling the handleSwipe
function from inside an anonymous function passed to the withSpring
function. This third argument is a callback that will be called when the animation is complete.
In conjunction with adding configuration to our spring animation, this ensures that the card is removed from the deck only after the animation is complete—and that it doesn't hang around too long, either.