Intro
I have always read that the Animated API was only good for simple cases because it has to send the animations through the bridge. That makes sense because by definition the JS thread runs asynchronously, and it can get very busy when handling all the app logic, states and user events.
To get that smooth, 60fps+ feel we need to rely on native UI threads. It's also known that Reanimated is the go to package for this because it executes the animations inside native UI threads.
But by reading the Animated API docs we see that we can use the useNativeDriver property, so I was thinking: can React Native's built in libraries be used to create a swipeable list item component that runs on 60fps?
Answer: Yes, but with some limitations...
What limitations?
There are two main aspects of a swipe action: gesture and animations.
A limitation on the animation is that useNativeDriver: true doesn't support changing the View's width and height. And we need that in this experiment to: modify the delete button width for the full swipe, and change the component height to 0 before deleting, to give a nice polished effect.
Another obstacle is that PanResponder only communicates through the bridge — the API doesn't have a similar useNativeDriver property. So if the JS thread is under heavy load the View won't follow your fingers in real-time.
Besides that, we can go very far. So let's see how we can create this:
Foundational concepts:
Animated API: React Native's built in API to deliver animations on devices.
PanResponder: React Native's built in API to capture finger movements.
useAnimatedValue (same as new Animated.Value()): variable used to reflect the changes on Views used in the style prop.
Implementation
Let's build this step by step:
Step 1: Make a component that moves based on gestures
For simplicity, we start creating a simple View that we can use gestures to move it on the X axis. Let's also follow this best practice from the official docs:
Feedback/Highlighting: show the user what component is handling the touch.
Check the commit with this change. Here's what is happening:
isAnimating state: Used for the highlighting effect.onMoveShouldSetPanResponder: alwaystrue, for now we want the component to respond to all touches.onPanResponderGrant: used to activate the "highlighting" effect while the finger is on the component.onPanResponderMove: used to capture the movement coordinates, allowing us to change the component position.onPanResponderRelease: called when the finger goes of the screen, we remove the highlighting effect and reset the component back to its original position.gestureState.dx: the delta of the movement (accumulated distance since the touch started).
This is what we got on screen:
Step 2: Snap the component to the "open" position when the swipe crosses a threshold.
Check the commit with this change.
+ const { width: SCREEN_WIDTH } = Dimensions.get('window')
+ const SNAP_THRESHOLD = -(SCREEN_WIDTH * 0.3)
const MemoryListItem = ({ item }: MemoryListItemProps) => {
// ...
onPanResponderRelease: (_, gestureState) => {
setIsAnimating(false)
Animated.spring(translateX, {
+ toValue: gestureState.dx > SNAP_THRESHOLD ? 0 : SNAP_THRESHOLD,
useNativeDriver: true,
}).start()
},
// ...
Changes:
- Determine a
SNAP_THRESHOLDvariable using the Dimensions API (different devices have different widths). Here the component will snap if the swipe passes 1/3 of the screen. - Add the
snapbehavior insideonPanResponderRelease.
At first glance this works but if you test it you will see an odd behavior:
Bug: animation jump after 1st swipe
The current code only works on the first gesture! For subsequent ones we have to account for the traveled distance, this is because dx always starts at 0. Check out this diagram illustrating the issue:
So, if we only use gestureState.dx, the component will reset to the initial position before moving again, causing a jump on the animation. Here's the bug in action:
The fix: We have to add the last traveled distance to the delta of the gesture, this way we get the complete movement.
const MemoryListItem = ({ item }: MemoryListItemProps) => {
// ...
+ const lastDistance = useRef(0)
onPanResponderMove: (_, gestureState) => {
+ const totalDistance = gestureState.dx + lastDistance.current
+ translateX.setValue(totalDistance)
},
onPanResponderRelease: (_, gestureState) => {
setIsAnimating(false)
+ const totalDistance = gestureState.dx + lastDistance.current
+ const endPosition = totalDistance > SNAP_THRESHOLD ? 0 : SNAP_THRESHOLD
+ lastDistance.current = endPosition
Animated.spring(translateX, {
+ toValue: endPosition,
useNativeDriver: true,
}).start()
},
// ...
This is the commit with this fix.
With lastDistance we are now able to compute the total distance. We won't use useState for this variable because we don't want React to trigger re-renders when it changes. So we persist the value with useRef.
Here is a visual explanation of the fix:
Now everything is working as intended:
Step 3: Add a FlatList with multiple items
Until now we've been using a single plain View for the list item. The next step is to add a FlatList with multiple entries. Check the commit here.
By adding the FlatList we end up exposing two behaviors that disturbs the user experience:
1: Scrolling up or down while swiping causes the component to stop mid-gesture. That's because the FlatList takes control over the list item's responder.
2: The horizontal sensitivity is too high, so we end up activating the swipe when we just want to scroll down the list.
| 1 | 2 |
|---|---|
![]() |
![]() |
The fixes are quite simple:
1: Add onPanResponderTerminationRequest: () => false to panResponder. This prevents the list item to stop being a responder while the FlatList is scrolling (commit).
2: Improve onMoveShouldSetPanResponder by only allowing the view to become a responder when the gesture is horizontal (e.g. abs(dx) > abs(dy)) (commit).
| 1 | 2 |
|---|---|
![]() |
![]() |
This is enough to fix those obnoxious behaviors. But let's take one step further to get the best experience, just like the iOS Mail app: stop the vertical scroll while the swipe is in action.
On the list item component we can reuse the isAnimating state, the one that updates the background color when the swipe is active. This will give us the exact behavior we need:
interface MemoryListItemProps {
item: Memory
+ setIsSwiping: (state: boolean) => void
}
// ...
+ const MemoryListItem = ({ item, setIsSwiping }: MemoryListItemProps) => {
const [isAnimating, setIsAnimating] = useState(false)
+ useEffect(() => {
+ setIsSwiping(isAnimating)
+ }, [isAnimating])
const translateX = useAnimatedValue(0)
const lastDistance = useRef(0)
// ...
And on the FlatList we modify the scrollEnabled prop.
const App = () => {
+ const [isSwiping, setIsSwiping] = useState(false)
// ...
+ <FlatList
+ scrollEnabled={!isSwiping}
data={memories}
+ renderItem={({ item }) => (
+ <MemoryListItem item={item} setIsSwiping={setIsSwiping} />
+ )}
contentContainerStyle={{ marginHorizontal: 16 }}
/>
// ...
You can check the complete commit here. This is the final result on this step:
Step 4: Add delete action when swipe is "open"
So far we have an Animated.View wrapping 3 Text's in the list item. Let's update the layout by adding the delete button as well as a container View that will group everything. This diagram shows what I am talking about:
The delete button will be hidden behind the Animated.View, when we move our fingers to the left the button will appear. That's the swipe in action 👌.
Check the new views placement here.
Let's make things prettier and more polished:
In addition to replacing the "Delete" text with a trash icon, let's use interpolation to implement a nice fade and scale in effect:
Interpolation will use
translateXas input to derive values on a range that we can use to style the delete button.
const deleteOpacity = translateX.interpolate({
inputRange: [SNAP_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: 'clamp',
})
const deleteScale = translateX.interpolate({
inputRange: [SNAP_THRESHOLD, 0],
outputRange: [1, 0.3],
extrapolate: 'clamp',
})
This means that when the list item is "closed" (translateX = 0), the delete button has opacity 0 (hidden) and scale 0.3 (reduced size).
As the list item opens (translateX approaches SNAP_THRESHOLD), the opacity increases to 1 (fully visible) and scale to 1 (full size).
The result we have is the nice fade/scale effect.
The last thing we need is to add these variables to the delete button style:
// ...
<Animated.View
style={[
styles.deleteContainer,
+ { opacity: deleteOpacity, transform: [{ scale: deleteScale }] },
]}
>
<TouchableOpacity activeOpacity={0.6}>
<Ionicons name="trash-outline" size={28} color="white" />
</TouchableOpacity>
</Animated.View>
// ...
And here is what we have so far:
Intermission
We could end here by finishing the delete implementation. As we still have useNativeDrivers: true, the animations will run smoothly on the native UI threads. But gestures can still freeze if the JS thread gets busy.
Taking to the next level 🆙
I prefer the full swipe experience to trigger deletion. Also, an animation to remove the item from the list, instead of just deleting it, gives a much nicer experience. Take a look at the showcase at the start of this post and you will see what I am talking about.
Step 5: Add a full swipe gesture
We will show the delete Alert when the swipe passes a new SNAP_DELETE threshold. First, define the snap point:
// ...
interface MemoryListItemProps {
item: Memory
setIsSwiping: (state: boolean) => void
onDelete: (id: number) => void
}
// animation constraints
const { width: SCREEN_WIDTH } = Dimensions.get('window')
+ const SNAP_OPEN = -(SCREEN_WIDTH * 0.3) // Show delete button
+ const SNAP_DELETE = -(SCREEN_WIDTH * 0.5) // Trigger delete action
// ...
Next, update onPanResponderRelease to handle the delete trigger:
// ...
onPanResponderRelease: (_, gestureState) => {
setIsAnimating(false)
const totalDistance = gestureState.dx + lastPosition.current
+ let endPosition
+ if (totalDistance < SNAP_DELETE) {
+ endPosition = -SCREEN_WIDTH
+ handleDelete()
+ } else if (totalDistance < SNAP_OPEN) {
+ endPosition = SNAP_OPEN
+ } else {
+ endPosition = 0
+ }
Animated.spring(translateX, {
toValue: endPosition,
useNativeDriver: false,
bounciness: 0,
}).start()
lastPosition.current = endPosition
}
// ...
The result:
One missing detail: grow the delete button width to indicate the swipe will trigger the delete action.
Here we reach a big useNativeDrivers limitation. When trying to update the width of an Animated.View style we get the following error:
Style property 'width' is not supported by native animated module.
This means we won't be able to keep using useNativeDrivers: true, so the animations will stop running on the native UI threads.
But, let's continue. To implement this, use onPanResponderMove to verify if the dragged distance passes the SNAP_DELETE threshold:
// ...
+ const deleteWidthAnim = useAnimatedValue(DELETE_BUTTON_WIDTH)
// ...
+ const animDeleteWidth = (position: number) =>
+ Animated.spring(deleteWidthAnim, {
+ toValue: position,
+ useNativeDriver: false,
+ bounciness: 0,
+ }).start()
// ...
onPanResponderMove: (_, gestureState) => {
const totalDistance = gestureState.dx + lastPosition.current
translateX.setValue(totalDistance)
+ if (totalDistance < SNAP_DELETE) {
+ animDeleteWidth(-SNAP_DELETE)
+ } else {
+ animDeleteWidth(DELETE_BUTTON_WIDTH)
+ }
},
// ...
<Animated.View
style={[
styles.deleteContainer,
+ {
+ opacity: deleteOpacity,
+ transform: [{ scale: deleteScale }],
+ width: deleteWidthAnim,
+ },
]}
// ...
Check these commits for the complete diff: full swipe and width animation. This is what we have on screen:
Step 6: Shorten list item height before deletion
Right now if you delete the item from the list the component will just pop out in a single frame. A good way to improve this is to reduce the view height to 0 just before removing it from the list state.
The strategy is to capture the view's initial height once, with the onLayout prop, and then animate it to 0 after the user confirms the deletion alert.
Check this commit with the complete change.
And here is the before and after. I think the "after" is much more polished.
| 1 | 2 |
|---|---|
![]() |
![]() |
Conclusion
The Animated API can get us very far, specially with useNativeDrivers: true. We have spring and timing animations, interpolation of values and listeners (not used in this experiment). The PanResponder is easy to use and can be combined easily with animations to give a better UX.
The only problem you may find is that Animated may not support using native UI threads, like when updating the width and height of an Animated.View. PanResponder doesn't even have this option. So if you have to do heavy stuff on the JS thread, you may not have a great, smooth, 60 fps experience.
In addition, the GitHub repo has the complete code, videos comparing the performance while on a stress test and simple haptic feedback.




















Top comments (0)