原文:https://medium.com/@andi.gu.ca/exploring-react-natives-panresponder-and-layoutanimation-dde77e7f4cc9
In previous posts, I’ve talked about the Animated API and why I consider it to be so powerful. However, the Animated API can only do so much on its own. Its key limitation: it is incapable of responding gestures beyond the scope of a ScrollView. There’s a lot of things that you can do with just a simple ScrollView, but I would argue that every mobile app is incomplete without gesture handling. It is comparable to using your text editor/IDE without keyboard shortcuts. Functional, yes, but pleasant to use? Probably not.
PanResponder is React Native’s way to allow you to detect and respond to user gestures. The API is quite complex, to say the least. It provides 5 gesture listeners, each called with two parameters that provide information about the position, time, and velocity of the gesture. If you want a full rundown, the docs are quite helpful.
In conjunction with the PanResponder, I will be introducing the experimental LayoutAnimation
to create graceful transitions between different UI states. My goal with this post is not to explore every facet of each API, but just to give a brief introduction to a very simple use case: the ‘swipe-to-dismiss’ pattern. This pattern can be seen everywhere from Google Now to Android’s notifications. The result is shown below:
The Card Component (Gesture Response Mechanism)
The first part of this pattern is the cards themselves. They must respond to user gestures according to the following two rules:
- If the card has been swiped quickly, or it has been displaced from its original position by more than 50% of its width (at the time of the gesture release), it should fly off the screen. The definition of a ‘quick’ swipe is up to you.
- Otherwise, as soon as the user releases the card, it should spring back into its original place.
The entire implementation of this can be fit into just 42 lines.
export class SwipeableCard extends Component {
translateX = new Animated.Value(0);
_panResponder = PanResponder.create({
onMoveShouldSetResponderCapture: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderMove: Animated.event([null, {dx: this.translateX}]),
onPanResponderRelease: (e, {vx, dx}) => {
const screenWidth = Dimensions.get("window").width;
if (Math.abs(vx) >= 0.5 || Math.abs(dx) >= 0.5 * screenWidth) {
Animated.timing(this.translateX, {
toValue: dx > 0 ? screenWidth : -screenWidth,
duration: 200
}).start(this.props.onDismiss);
} else {
Animated.spring(this.translateX, {
toValue: 0,
bounciness: 10
}).start();
}
}
});
render() {
return (
{this.props.title}
You can probably tell that the core is really the PanResponder.create({...})
section. The problem is that the {...}
seems like gibberish at first glance. Let’s break is down:
- The first two ‘shouldCapture’ functions are simply telling the responder to respond (how surprising!) to any gestures it captures . If they were set to return false, the PanResponder would simply ignore all touch and gesture events.
-
onPanResponderMove
is set toAnimated.event([null, {dx: this.translateX}])
. Why? Looking at the docs, we can see that theonPanResponderMove
function is called every time the user’s finger moves. It is called withgestureState
, which containsdx
— this tells us how far the user’s finger has moved since the gesture started. Thus, if we want to achieve a realistic ‘swiping’ sensation, we must tellthis.translateX
to follow the user’s finger. Unfortunately, PanResponder does not supportuseNativeDriver: true
as of RN-0.46.4 — your animations may take a performance hit, but for simple applications like this, speed should not be affected at all.
Core Logic: onPanResponderRelease
The two ‘rules’ that we laid out for the swipeable cards are really concerned with the gesture *release, *rather than the gesture itself.
const screenWidth = Dimensions.get("window").width;
if (Math.abs(vx) >= 0.5 || Math.abs(dx) >= 0.5 * screenWidth) {
Animated.timing(this.translateX, {
toValue: dx > 0 ? screenWidth : -screenWidth,
duration: 200
}).start();
} else {
Animated.spring(this.translateX, {
toValue: 0,
bounciness: 10
}).start();
}
The two parameters vx
and dx
are important to understand:
-
dx
is the horizontal distance that the user’s finger has traveled since the start of the gesture. It is important to note that this is a vector quantity: it is positive for a displacement towards the right and negative for a displacement to the left. -
vx
is the instantaneous velocity of the swipe gesture in the horizontal directionat release. Likedx
,vx
is a vector quantity and follows the same sign scheme.
With these two pieces of data, it becomes easy to follow the two rules laid out previously. If the absolute value of vx
exceeds 0.5, or the absolute value of dx
exceeds half the screen width, the card should fly off the screen. Why absolute value? Remember that both vx
and dx
are vector quantities, but what we are watching for is really speed and distance traveled, rather than velocity and displacement. Thus we apply an absolute value to get the magnitude of each quantity.
This is not to say that the direction of these quantities is entirely irrelevant. If the displacement was to the right at the time of release, it only makes sense that the card fly off to the right, and likewise for the left. Thus, when dx > 0
, the card animates to +screenWidth
, otherwise it animates to -screenWidth
.
With this logic in place, we now have a gesture responsive card component.
UI State Transitions (LayoutAnimation Mechanism)
Now that we have a component, it’s time to put it together form a complete UI. The goal is to render a list of these swipeable cards, but to animate a smooth transition when a card is dismissed. Although this may seem insignificant, it can really make a difference in a user’s experience.
Clearly, when we introduce animations between different UI states, or ‘layouts’, as React Native calls them, the user experience becomes more pleasant and intuitive. Using the LayoutAnimation
API, it becomes easy to configure these transitions.
import React, {Component} from "react";
import {LayoutAnimation, UIManager} from 'react-native';
import {Body, Container, Header, Title, View} from "native-base";
import {SwipeableCard} from "./SwipableCard";
export class PanResponderDemo extends Component {
titles = new Array(10).fill(null).map((_, i) => `Card #${i}`);
state = {
closedIndices: []
};
constructor(props) {
super(props);
UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true);
this.shouldRender = this.shouldRender.bind(this);
}
shouldRender(index) {
return this.state.closedIndices.indexOf(index) === -1
}
render() {
return (
Pan Responder Demo
{this.titles.map((title, i) => this.shouldRender(i) &&
{
if ([...new Array(this.titles.length)].slice(i + 1, this.titles.length).some(this.shouldRender)) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
}
this.setState({
closedIndices: [...this.state.closedIndices, i]
})
}}/> )}
);
}
}
It may strike you as odd that the ‘data source’ for the list, this.titles
, does not change as the cards are swiped. To understand why, it is important to understand how LayoutAnimation works.
In general, the render
method of a component is called every time there is a change to state or props (unless shouldComponentUpdate
returns false). However, the UI changes are applied instantly, which could perhaps be visually jarring. LayoutAnimation
solves this problem — it provides a way animate the transition between two consecutive render
calls. It does notapply to every single re-render, rather, every time it is called, it only animates the very next transition.
What is ‘closedIndices’?
So, back to the initial question — why is the data source for the list not in state, and why is it constant? Because LayoutAnimation
works when views keep the same key between state changes. Since the key in this case is the index of the item, if the array changes, the key of each view will change, and thus the animation will break. To get around this, we keep track of an array of ignored indices, or indices of the data source that should not be rendered. This array needs to go in the state, since changes to it mean the UI should update.
Beyond this, there is one small quirk of LayoutAnimation
that must be addressed. Since the animation applies to **any **changes in the UI, we must be careful to only call it when we know it will result in the type of animation we desire. It is desirable only to animate movement of cards — all other UI changes should not be animated. More specifically, when the dismissed card is the last card in the list, there should not be any animation — if this case is left unaccounted for, there will be a animated fade-out effect. Try it yourself! To fix this, all we need to do is check if there are any cards below the dismissed card that are still rendered, with […new Array(this.titles.length)].slice(i + 1, this.titles.length).some(this.shouldRender)
.
And It’s Done!
Although this was a very simple use case for React Native’s UI APIs, I hope that it has demonstrated how powerful they can be. It is now easier than ever to create a complex yet responsive UI in just a few lines of code.
code