Welcome to the final part of a three-part series on adding animations to your React Native app. In this part, we’ll be taking a look at how you can respond to the user’s gestures and animate the components involved in the interaction.
Prerequisites
To follow this tutorial, you need to know the basics of React and React Native.
Going through the previous parts of this series will be helpful but not required. Though this tutorial assumes that you know how to implement basic animations in React Native. Things like scale, rotation, and sequence animations. We will be applying those same concepts when implementing gesture animations.
What you’ll be building
In this part of the series, we’ll be looking at how to implement different kinds of gestures and how to animate them.
Here’s what you’ll be building:
You can find the full source code for this tutorial on its GitHub repo.
Setting up the project
To follow along, you first need to clone the repo:
git clone https://github.com/anchetaWern/RNRealworldAnimations.git
After that, switch to the part2
branch and install all the dependencies:
cd RNRealworldAnimations
git checkout part2
npm install
Next, initialize the android
and ios
folders and then link the native modules:
react-native upgrade
react-native link
Once that’s done, you should be able to run the app on your device or emulator:
react-native run-android
react-native run-ios
The part2
branch contains the final output of the second part of this series. That’s where we want to start with, as each part is simply building upon the previous part.
Drag and drop
The first gesture that we’re going to implement is drag-and-drop, and looks like this:
Removing the animated header
Before we start implementing this gesture, we first have to remove the animated header that we did on part two of the series. This is because that animation gets in the way when the user begins to drag the cards around.
To remove the animated header, open src/screens/Main.js
and remove these lines:
import AnimatedHeader from "../components/AnimatedHeader";
<AnimatedHeader
title={"Poke-Gallery"}
nativeScrollY={nativeScrollY}
onPress={this.shuffleData}
/>;
You can check the specific commit diff for the change above here. Note that there are other changes in there as well, but mind only the changes that have to do with removing the AnimatedHeader
component for now. Once that’s done, you should be able to delete the src/components/AnimatedHeader.js
file as well.
Next, update src/components/CardList.js
and convert the Animated.ScrollView
into a regular one. Since we’ll no longer be using the AnimatedHeader
, there’s no point in keeping the code for animating it on scroll:
<ScrollView>
<View style={[styles.scroll_container]}>
<FlatList ... />
</View>
</ScrollView>
You can check the specific commit diff here. Note that I’ve added a few props as well, but we’ll get to that later.
Implementing the gesture
The gesture that we’re trying to implement is called “panning”. It is when a user drags their thumb (or any finger) across the screen in order to move things around. In this case, we want the user to be able to drag the cards around so they could drop it inside a specific drop area.
Here’s a break down of what we’ll need to do in order to implement the drag and drop:
- Update the
Card
component to use the React Native’s PanResponder module. This makes it draggable across the screen. - Create a
DropArea
component which will be used as a dropping point for the cards. The styles of this component will be updated based on whether a card is currently being dragged and whether it’s within the drop area. - Update the main screen to include the
DropArea
. Then create the functions to be passed as props to theCardList
,Card
, andDropArea
components. This allows us to control them from the main screen.
Before we proceed, let’s take a moment to break down what we’re trying to accomplish:
- When the user touches the card and drags it around, we want it to scale it down and lower its opacity.
- While the user is holding down a card, we want to hide all the other cards to keep the focus on the card that they’re holding. The only components that need to show are the
DropArea
and the card. - When the user drags the card over to a target area of the
DropArea
, we want to change the border and text color of theDropArea
to green. This indicates that the user in on-target and they can drop the card there. - If the user lets go of the card outside of the drop area, we bring it back its original position, hide the
DropArea
, and show all the cards that were hidden.
Now that that’s clear, the first step is to convert the Card
component from a functional to a class-based component. We won’t really be using state in this component, so this change is only to organize the code better. While you’re at it, also remove the code for animating the card onPressIn
and onPressOut
. We don’t really want them to get in the way of the gesture that we’re trying to implement. Here’s what the new code structure will look like after the change. You can check the diff here:
// src/components/Card.js
// imports here
// next: import PanResponder
export default class Card extends Component<Props> {
constructor() {}
render() {
// destructure the props here
// the return code here
}
}
Next, we need to import PanResponder
module from react-native
:
import {
// ..previously imported moodules here..
PanResponder
} from "react-native";
The PanResponder
module is React Native’s way of responding to gestures such as panning, swiping, long press, and pinching.
Next, update the constructor
and initialize the animated values that we’re going to use. In this case, we want a scale and opacity animation. We’re also using a new Animated.ValueXY
, this is the animated value for controlling the card’s position on the screen:
// src/components/Card.js
constructor(props) {
super(props);
this.pan = new Animated.ValueXY(); // for animating the card's X and Y position
this.scaleValue = new Animated.Value(0); // for scaling the card while the user drags it
this.opacityValue = new Animated.Value(2); // for making the card translucent while the user drags it
this.cardScale = this.scaleValue.interpolate({
inputRange: [0, 0.5, 1], // animate to 0.5 while user is dragging, then to 1 or 0 once they let go
outputRange: [1, 0.5, 1]
});
this.cardOpacity = this.opacityValue.interpolate({
inputRange: [0, 1, 2], // default value is 2, so we'll animate backwards
outputRange: [0, 0.5, 1]
});
}
Next, inside componentWillMount
, right below the prop destructuring, create a new PanResponder:
componentWillMount() {
const {
// existing props here..
} = this.props;
// add the following:
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (e, gestureState) => {
// next: add code for onPanResponderGrant
},
onPanResponderMove: (e, gesture) => {
},
onPanResponderRelease: (e, gesture) => {
}
});
}
Breaking down the code above:
-
onStartShouldSetPanResponder
- used for specifying whether to allow the PanResponder to respond to touch feedback. Note that we’ve simply suppliedtrue
, but ideally, you’ll want to supply a function which contains a condition for checking whether you want to initiate the gesture or not. For example, if you have some sort of “lock” functionality that the user needs to unlock before they can move things around. -
onMoveShouldSetPanResponder
- for checking whether to respond to the dragging of the component where the PanResponder will be attached later on. Again, we’ve just specifiedtrue
, but if you need to check whether a specific condition returnstrue
, this is where you want to put it.
Oddly though, even if you supply false
to all of the above or don’t supply it at all, it still works on the iOS simulator. But even if this is the case, it’s a good practice to still specify it because you never know how different devices behave in the real world.
-
onPanResponderGrant
- the function you supply here is executed when the PanResponder starts to recognize the user’s action as a gesture. So when the user has started to drag the card even just a little bit, the function will be executed. This is where we want to animate the card so it becomes smaller and translucent. -
onPanResponderMove
- executed while the user drags their finger across the screen. This is where you want to execute the animation for changing the card’s position. This is also where you want to check whether the card is within the drop area or not so you can animate the styling of theDropArea
component. -
onPanResponderRelease
- executed when the user finishes the gesture. This is when their finger lets go of the screen. Here, you also need to check whether the card is within the drop area or not so you can animate it accordingly.
In my opinion, those are the most common functions that you will want to supply. But there are others available.
Now, let’s add the code for each of the gesture lifecycle functions. For onPanResponderGrant
, we’ll animate the scaleValue
and opacityValue
at the same time. Below it, we have a function called toggleDropArea
which is used to update the state so it hides all the other cards and shows the DropArea
component. We’ll be supplying this function later from the Main screen, then down to the CardList, and finally to each of the Card components. For now, don’t forget to destructure it from this.props
:
// src/components/Card.js
onPanResponderGrant: (e, gestureState) => {
Animated.parallel([
Animated.timing(this.scaleValue, {
toValue: 0.5, // scale it down to half its original size
duration: 250,
easing: Easing.linear,
useNativeDriver: true
}),
Animated.timing(this.opacityValue, {
toValue: 1,
duration: 250,
easing: Easing.linear,
useNativeDriver: true
})
]).start();
toggleDropArea(true, item); // supplied from the src/screens/Main.js down to src/components/CardList and then here. Accepts the drop area's visibility and the item to exclude from being hidden
};
// next: add code for onPanResponderMove
Next is onPanResponderMove
. Here, we do the same thing we did on part two of this series when we implemented the animated header. That is, to use Animated.event
to map the card’s current position to this.pan
. This allows us to animate the card’s position as the user drags it around. dx
and dy
are the accumulated distance in the X and Y position since the gesture started. We use those as the values for this.pan.x
and this.pan.y
:
onPanResponderMove: (e, gesture) => {
Animated.event([null, { dx: this.pan.x, dy: this.pan.y }])(e, gesture);
if (isDropArea(gesture)) {
targetDropArea(true);
} else {
targetDropArea(false);
}
},
// next: add code for onPanResponderRelease
Note that Animated.event
returns a function, that’s why we’re supplying e
(event), and gesture
as arguments to the function it returns.
On onPanResponderRelease
, we check whether the card is within the drop area by using the isDropArea
function. Again, this function will be passed as props from the Main screen just like the toggleDropArea
function. If the card is within the drop area, we set its opacity down to zero so it’s no longer visible. Then we call the removePokemon
function (passed as props from the Main screen) to remove it from the state:
onPanResponderRelease: (e, gesture) => {
toggleDropArea(false, item); // hide the drop area and show the hidden cards
if (isDropArea(gesture)) {
Animated.timing(this.opacityValue, {
toValue: 0,
duration: 500,
useNativeDriver: true
}).start(() => {});
removePokemon(item); // remove the pokemon from the state
} else {
// next: add code for animating the card back to its original position
}
};
Next, animate the card to its original position. Here, we simply do the animation we declared on onPanResponderGrant
in reverse. But the most important part here is animating this.pan
. Since we’re working with a vector value, we specify an object containing the separate values for the x
and y
position. It’s a good thing, we can simply specify 0
as the value for both to bring them back to their original position. No need to calculate the card’s position or anything complex like that:
// src/components/Card.js
Animated.parallel([
Animated.timing(this.scaleValue, {
toValue: 1,
duration: 250,
easing: Easing.linear,
useNativeDriver: true
}),
Animated.timing(this.opacityValue, {
toValue: 2,
duration: 250,
easing: Easing.linear,
useNativeDriver: true
}),
Animated.spring(this.pan, {
toValue: { x: 0, y: 0 },
friction: 5,
useNativeDriver: true
})
]).start();
Next, let’s take a look at the render
method. Here, everything else is still the same except for the animated styles we supply and the Animated.View
in place of the TouchableWithoutFeedback
that we previously had. The most important part that allows the component to be moved is the transform
styles which correspond to x
and y
values of this.pan
, and the supplying of the PanResponder
props ({...this.panResponder.panHandlers}
):
// src/components/Card.js
render() {
const {
item,
cardAction,
viewAction,
bookmarkAction,
shareAction
} = this.props;
let [translateX, translateY] = [this.pan.x, this.pan.y];
let transformStyle = {
...styles.card,
opacity: item.isVisible ? this.cardOpacity : 0,
transform: [{ translateX }, { translateY }, { scale: this.cardScale }]
};
return (
<Animated.View style={transformStyle} {...this.panResponder.panHandlers}>
...
</Animated.View>
);
}
Moreover, we’re also expecting an isVisible
property for each item
. It will be used to hide or show the card. This property is what we update when the user starts or stops the gesture.
Next, we can now move on to creating the DropArea
component. If you’ve seen the demo earlier, this is the little box that shows up in the top-right corner when the user starts the gesture. Here is the code:
// src/components/DropArea.js
import React, { Component } from "react";
import { View, Text, Dimensions } from "react-native";
const DropArea = ({ dropAreaIsVisible, setDropAreaLayout, isTargeted }) => {
const right = dropAreaIsVisible ? 20 : -200;
const color = isTargeted ? "#5fba7d" : "#333";
return (
<View
style={[styles.dropArea, { right }, { borderColor: color }]}
onLayout={setDropAreaLayout}
>
<Text style={[styles.dropAreaText, { color }]}>Drop here</Text>
</View>
);
};
const styles = {
dropArea: {
position: "absolute",
top: 20,
right: 20,
width: 100,
height: 100,
backgroundColor: "#eaeaea",
borderWidth: 3,
borderStyle: "dashed", // note: doesn't work on android: https://github.com/facebook/react-native/issues/17251
alignItems: "center",
justifyContent: "center"
},
dropAreaText: {
fontSize: 15
}
};
export default DropArea;
If you inspect the code above, no animation will actually happen. All we’re doing is changing the styles based on the following boolean values coming from the props:
-
dropAreaIsVisible
- whether the component is visible or not. -
isTargeted
- whether a card is within its area.
setDropAreaLayout
is a function that allows us to update the Main screen’s state of the position of the drop area in the screen. We’ll be needing that information to determine whether a card is within the drop area or not.
Next, update the Main screen. First, add the isVisible
property to each of the Pokemon:
// src/screens/Main.js
const animationConfig = {...}
// add this:
let updated_pokemon = pokemon.map(item => {
item.isVisible = true;
return item;
});
Inside the component, since we’re no longer using the animated header, you now need to add a value for the headerTitle
:
// src/screens/Main.js inside navigationOptions
headerTitle: "Poke-Gallery",
Initialize the state:
state = {
pokemon: updated_pokemon, // change pokemon to updated_pokemon
isDropAreaVisible: false
};
Inside the render
method, we render the DropArea
before the CardList
so that it has lower z-index
than any of the other cards. This way, the cards are always on top of the card list when the user drags it. Don’t forget to pass the props that we’re expecting from the DropArea
component. We’ll declare those later:
// src/screens/Main.js
import DropArea from "../components/DropArea"; // add this somewhere below the react-native imports
return (
<View style={styles.container}>
<DropArea
dropAreaIsVisible={this.state.isDropAreaVisible}
setDropAreaLayout={this.setDropAreaLayout}
isTargeted={this.state.isDropAreaTargeted}
/>
..previously rendered components here ..next: update CardList
</View>
);
Update the CardList
so it has the props that we’re expecting:
<CardList
...previous props here
scrollEnabled={!this.state.isDropAreaVisible}
toggleDropArea={this.toggleDropArea}
dropAreaIsVisible={this.state.isDropAreaVisible}
isDropArea={this.isDropArea}
targetDropArea={this.targetDropArea}
removePokemon={this.removePokemon}
/>
Next, we can now add the code for the functions that we’re passing. First is the toggleDropArea
. As you’ve seen earlier, this function is called every time the user initiates and finishes the gesture. All it does is flip the isVisible
property of each item based on the isVisible
argument passed to it. When the user initiates the gesture, isVisible
is true
. This means that the DropArea
is visible, but the rest of the cards are hidden. The item
argument passed to this function is the object containing the details of the card being dragged. So we use it to find the index of the item to be excluded from the hiding. We then update the state with the new data:
// src/screens/Main.js
toggleDropArea = (isVisible, item) => {
if (item) {
let pokemon_data = [...this.state.pokemon];
let new_pokemon_data = pokemon_data.map(item => {
item.isVisible = !isVisible;
return item;
});
let index = new_pokemon_data.findIndex(itm => itm.name == item.name);
if (isVisible) {
new_pokemon_data[index].isVisible = true;
}
this.setState({
isDropAreaVisible: isVisible,
pokemon: new_pokemon_data
});
}
};
setDropAreaLayout
's job is to update the dropAreaLayout
to be used by the isDropArea
function:
// src/screens/Main.js
setDropAreaLayout = event => {
this.setState({
dropAreaLayout: event.nativeEvent.layout
});
};
isDropArea
is where the logic for changing the styles of the drop area is. It’s also used to determine whether the card will be brought back to its original position or dropped in the drop area. Note that the condition below isn’t 100% fool-proof. gesture.moveX
and gesture.moveY
aren’t actually the position of the card in the screen’s context. Instead, these are the latest screen coordinates of the recently-moved touch. Which means that the position is based on where the user touched. So if the user held on to the lower portion of the card then they have to raise the card much higher than the drop area in order to target it. The same is true with holding on to the left side of the card. For that, they’ll have to move the card further right of the drop area in order to target it. So the safest place to touch is in the middle, right, or top:
// src/screens/Main.js
isDropArea = gesture => {
let dropbox = this.state.dropAreaLayout;
return (
gesture.moveY > dropbox.y + DROPAREA_MARGIN &&
gesture.moveY < dropbox.y + dropbox.height + DROPAREA_MARGIN &&
gesture.moveX > dropbox.x + DROPAREA_MARGIN &&
gesture.moveX < dropbox.x + dropbox.width + DROPAREA_MARGIN
);
};
The function above compares the position of the touch to the position of the drop area. We’re adding a DROPAREA_MARGIN
to account for the top and right margin added to the DropArea
component.
targetDropArea
updates the state so that the text and border color of the drop area is changed:
// src/screens/Main.js
targetDropArea = isTargeted => {
this.setState({
isDropAreaTargeted: isTargeted
});
};
removePokemon
removes the dropped item from the state. This also uses LayoutAnimation to animate the rest of the cards with a spring animation after a card has been dropped:
// src/screens/Main.js
removePokemon = item => {
let pokemon_data = [...this.state.pokemon];
let index = pokemon_data.findIndex(itm => itm.name == item.name);
pokemon_data.splice(index, 1);
LayoutAnimation.configureNext(animationConfig);
this.setState({
pokemon: pokemon_data
});
};
Next, update the CardList
component to accept all the props that need to be passed to the Card
component. We also need to optionally enable the ScrollView’s scrolling (scrollEnabled
prop) because it gets in the way when the user begins to drag a card. Lastly, the FlatList
also enables scrolling by default, so we disable that as well, and just rely on the ScrollView
for the scroll:
// src/components/CardList.js
const CardList = ({
// previous props here..
scrollEnabled,
toggleDropArea,
isDropArea,
targetDropArea,
removePokemon
}) => {
return (
<ScrollView scrollEnabled={scrollEnabled}>
<View style={[styles.scroll_container]}>
<FlatList
scrollEnabled={false}
...previous props here
renderItem={({ item }) => {
<Card
...previous props here
toggleDropArea={toggleDropArea}
isDropArea={isDropArea}
targetDropArea={targetDropArea}
removePokemon={removePokemon}
/>
}}
/>
</View>
</ScrollView>
);
});
Also update the Card
component to accept the props passed to it by the CardList
component:
// src/components/Card.js
componentWillMount() {
const {
/* previously accepted props here */
toggleDropArea,
isDropArea,
targetDropArea,
removePokemon
} = this.props;
}
Next, update the layout settings file to export the DROPAREA_MARGIN
:
// src/settings/layout.js
const DROPAREA_MARGIN = 20;
export { DROPAREA_MARGIN };
Lastly, update the Main screen so it uses the DROPAREA_MARGIN
:
// src/screens/Main.js
import { DROPAREA_MARGIN } from "../settings/layout";
Once that’s done, you should achieve a similar output to the demo from earlier.
Solving the FlatList drag-and-drop issue on Android
As you might already know, there are implementation differences between Android and iOS. And if you’ve worked with both platforms before, then you also know that React Native for Android is behind iOS. This means there are more Android-specific issues and gotchas that you might encounter when working with React Native.
This section is specifically for dealing with Android related issue when it comes to the FlatList
component. The problem with it is that changing the opacity and z-index of the Card
components while the user is dragging a card doesn’t achieve the same effect as in iOS. On Android, each row has some sort of a wrapper which wraps each of the Card
components. This wrapper cannot be influenced by declaring a z-index for the Card
component. And even though the Card
components are currently hidden while the user is dragging, the card can’t actually be dragged outside its row because it becomes hidden as soon as you do so.
After days of looking for a solution, I gave up and decided to stick with a custom list instead.
Open, src/components/CardList.js
and convert it into a class-based component:
export default class CardList extends Component<Props> {
render() {
const { data, scrollEnabled } = this.props;
return (
<ScrollView scrollEnabled={scrollEnabled}>
<View style={[styles.scroll_container]}>{this.renderPairs(data)}</View>
</ScrollView>
);
}
// next: add renderPairs method
}
The renderPairs
function returns each individual row, and it uses the renderCards
function to render the cards:
renderPairs = data => {
let pairs = getPairsArray(data);
return pairs.map((pair, index) => {
return (
<View style={styles.pair} key={index}>
{this.renderCards(pair)}
</View>
);
});
};
// next: add renderCards function
Next, add the renderCards
method. This is responsible for rendering the individual Card
components:
renderCards = pair => {
const {
cardAction,
viewAction,
bookmarkAction,
shareAction,
toggleDropArea,
isDropArea,
targetDropArea,
removePokemon
} = this.props;
return pair.map(item => {
return (
<Card
key={item.name}
item={item}
cardAction={cardAction}
viewAction={viewAction}
bookmarkAction={bookmarkAction}
shareAction={shareAction}
toggleDropArea={toggleDropArea}
isDropArea={isDropArea}
targetDropArea={targetDropArea}
removePokemon={removePokemon}
/>
);
});
};
Outside the class, right before the styles
declaration, create a function called getPairsArray
. This function will accept an array of items and restructures it so that each row will have one pair of item:
// src/components/CardList.js
const getPairsArray = items => {
var pairs_r = [];
var pairs = [];
var count = 0;
items.forEach(item => {
count += 1;
pairs.push(item);
if (count == 2) {
pairs_r.push(pairs);
count = 0;
pairs = [];
}
});
return pairs_r;
};
const styles = { ... } // next: update styles
Don’t forget to add the styles for each pair:
const styles = {
/* previously added styles here */
pair: {
flexDirection: "row"
}
};
Once that’s done, you should now be able to drag and drop the cards in the drop area.
If you’re having problems getting it to work, you can check the specific commit here.
Swipe
The next gesture that we’re going to implement is swipe, and it looks like this:
This part builds up from the drag-and-drop gesture. So the list of Pokemon you see above are the one’s that were dropped. Swiping the item to the right removes it entirely.
Let’s see what needs to be done in order to implement this:
- Create a
Swiper
component where the swiping gesture will be added. These are the items that you see in the above demo. - Create a Trash screen. This is where the
Swiper
component will be used to display the items that were dropped in the drop area. - Update the Main screen to add a button for navigating to the Trash screen. The
pokemon
state (the array containing the list of Pokemon) also needs to be updated so the dropped Pokemon will be removed and copied into another state (removed_pokemon
) which contains an array of Pokemon that were dropped. This is then used as the data source for the Trash screen.
Create a Swiper component
Let’s proceed with creating the Swiper
component. First, import all the things that we’ll need:
// src/components/Swiper.js
import React, { Component } from "react";
import {
View,
Image,
Text,
Dimensions,
Animated,
PanResponder
} from "react-native";
const width = Dimensions.get("window").width; // for calculating the distance swiped
Next, create the component. When the user swipes an item to the right, it removes that item from the removed_pokemon
state so we need to accept the item
to be removed. We also need to pass the dismissAction
which will remove the item from the state:
const Swiper = ({ item, dismissAction }) => {
// next: add animated values
};
Next, we initialize the animated values that we will be using. We want the items to be moved to the left or to the right (translateX
), and make it translucent (opacityValue
) as they move it:
let translateX = new Animated.Value(0);
let opacityValue = new Animated.Value(0);
let cardOpacity = opacityValue.interpolate({
inputRange: [0, 1],
outputRange: [1, 0.5]
});
// next: add PanResponder code
Next, create the PanResponder:
let panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderMove: (e, gesture) => {
// next: add code for onPanResponderMove
},
onPanResponderRelease: (e, { vx, dx }) => {}
});
When the user begins the gesture, we map the value of dx
to the animated value translateX
. We did the same thing with the Card
component earlier. The only difference is that this time, we’re only translating the X
position. Below that, we also animate the opacityValue
. We’ve used a really low duration
value so the component will immediately turn translucent as soon as the gesture is started:
onPanResponderMove: (e, gesture) => {
Animated.event([null, { dx: translateX }])(e, gesture);
Animated.timing(opacityValue, {
toValue: 1,
duration: 50,
useNativeDriver: true
}).start();
};
// next: add code for onPanResponderRelease
When the user ends the gesture, we determine whether to bring the component back to its original position or animate the remaining distance all the way to the right. Luckily, the way we implement it is pretty straightforward. We compare the absolute value of dx
(the accumulated distance of the gesture in the X
axis) to half of the screen’s total width. So if the user managed to drag the component to greater than half of the screen then it means that they want to remove the item. We’re also checking if dx
is not a negative number. We only consider the gesture as valid if the user is swiping to the right. This works because dx
will have a negative value if the user swipes to the left.
As for the animated value, we simply use the screen’s width as the final value. If dx
is greater than 0
then it means they’re swiping to the right so we have a positive width
value. Otherwise, we use the negative width
:
onPanResponderRelease: (e, { vx, dx }) => {
if (Math.abs(dx) >= 0.5 * width && Math.sign(dx) != -1) {
dismissAction(item);
Animated.timing(translateX, {
toValue: dx > 0 ? width : -width,
duration: 200,
useNativeDriver: true
}).start();
} else {
// next: add code for bringing back the component to its original position
}
};
If the condition failed, we spring back the component to its original position and bring back its original opacity:
Animated.parallel([
Animated.spring(translateX, {
toValue: 0,
bounciness: 20,
useNativeDriver: true
}),
Animated.timing(opacityValue, {
toValue: 0,
duration: 5,
useNativeDriver: true
})
]).start();
Next, we render the actual component:
// src/components/Swiper.js
return (
<Animated.View
style={{
transform: [{ translateX }],
opacity: cardOpacity,
...styles.bar
}}
{...panResponder.panHandlers}
>
<Image source={item.pic} style={styles.thumbnail} resizeMode="contain" />
<Text style={styles.name}>{item.name}</Text>
</Animated.View>
);
Here are the styles:
const styles = {
bar: {
height: 50,
flexDirection: "row",
borderWidth: 1,
borderColor: "#ccc",
alignItems: "center",
marginTop: 2,
marginBottom: 2,
paddingLeft: 5
},
thumbnail: {
width: 35,
height: 35
}
};
export default Swiper;
Create the Trash screen
The Trash screen is where the Swiper
component will be displayed. All the items that were dropped in the drop area will be displayed on this page:
// src/screens/Trash.js
import React, { Component } from "react";
import { View, FlatList, LayoutAnimation } from "react-native";
import Swiper from "../components/Swiper";
type Props = {};
export default class Trash extends Component<Props> {
static navigationOptions = ({ navigation }) => {
return {
headerTitle: "Trash",
headerStyle: {
elevation: 0,
shadowOpacity: 0,
backgroundColor: "#B4A608"
},
headerTitleStyle: {
color: "#FFF"
}
};
};
state = {
removed_pokemon: [] // array containing the items that were dropped from the Main screen
};
// next: add componentDidMount
}
When the component is mounted, we call fillRemovedPokemon
. This will simply copy the contents of the removed_pokemon
that was passed as a navigation param from the Main screen over to the removed_pokemon
state of this screen. That way, this screen has its own state that we could manipulate:
componentDidMount() {
this.fillRemovedPokemon(this.props.navigation.state.params.removed_pokemon);
}
Here’s the fillRemovedPokemon
method:
fillRemovedPokemon = removed_pokemon => {
this.setState({
removed_pokemon
});
};
Next, we render the actual list:
render() {
return (
<View style={styles.container}>
{this.state.removed_pokemon && (
<FlatList
data={this.state.removed_pokemon}
renderItem={({ item }) => (
<Swiper item={item} dismissAction={this.dismissAction} />
)}
keyExtractor={item => item.id.toString()}
/>
)}
</View>
);
}
The dismissAction
is the one responsible for removing the items that the user wants to remove. Here, we’re using LayoutAnimation to easily update the UI once an item has been removed. In this case, we’re using it to automatically fill the empty space left by the removed item:
// src/components/Swiper.js
dismissAction = item => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
let removed_pokemon = [...this.state.removed_pokemon];
let index = removed_pokemon.findIndex(itm => itm.name == item.name);
removed_pokemon.splice(index, 1);
this.setState({
removed_pokemon
});
};
Here are the styles:
const styles = {
container: {
flex: 1,
backgroundColor: "#fff"
}
};
Update the Main screen
Next, update the src/screens/Main.js
file. First, include the IconButton
component. We’ll be using it to display a button inside the header:
import IconButton from "../components/IconButton";
Update the navigationOptions
to include headerRight
. This allows us to render a component on the right side of the header. The navigateToTrash
method is passed as a navigation param as you’ll see later:
static navigationOptions = ({ navigation }) => {
const { params } = navigation.state; // add this
return {
headerTitle: "Poke-Gallery",
// add this:
headerRight: (
<IconButton
icon="trash"
onPress={() => {
params.navigateToTrash();
}}
/>
),
// previous header config here...
};
};
Next, update the state initialization code to include a default value for removed_pokemon
:
state = {
pokemon: updated_pokemon,
removed_pokemon: [], // add this
isDropAreaVisible: false
};
After that, we set the navigateToTrash
function as a navigation param:
constructor(props) {
/* previously added constructor code here */
this.props.navigation.setParams({
navigateToTrash: this.navigateToTrash
});
}
Here’s the navigateToTrash
method. We can’t really use this screen’s state from inside the navigationOptions
, that’s why we’re setting this function as a navigation param:
navigateToTrash = () => {
this.props.navigation.navigate("Trash", {
removed_pokemon: this.state.removed_pokemon
});
};
Next, update the removePokemon
method to include manipulation of removed_pokemon
:
removePokemon = item => {
let pokemon_data = [...this.state.pokemon];
let removed_pokemon_data = [...this.state.removed_pokemon]; // add this
let index = pokemon_data.findIndex(itm => itm.name == item.name);
let removed_pokemon = pokemon_data.splice(index, 1); // add 'let removed_pokemon' to this line
removed_pokemon_data.push(removed_pokemon[0]); // add this
LayoutAnimation.configureNext(animationConfig);
this.setState({
pokemon: pokemon_data,
removed_pokemon: removed_pokemon_data // add this
});
};
Lastly, update the Root.js
file to include the Trash screen as one of the screens of the app:
import TrashScreen from "./src/screens/Trash";
const transitionConfig = () => {
// previous transition config here...
};
const MainStack = createStackNavigator(
{
// previous screens here..
Trash: {
screen: TrashScreen
}
}
// previous config
);
Once that’s done, you should be able to test the swiping gesture.
Pinch
The final gesture that we’re going to implement is pinch gesture, and it looks like this:
In the above demo, the user uses a pinch gesture to zoom the image in and out. In my opinion, this gesture is one of the most complex ones that you can implement. So instead of implementing it with the PanResponder, we’ll use the React Native Gesture Handler library to make it easier.
Before we proceed, let’s take a look at the work involved in implementing this:
- Install and link React Native Gesture Handler to the project.
- Create a
PinchableImage
component. This is where we will use the gesture handler library to implement a pinch-to-zoom gesture. - Update the
BigCard
component to replace the original image with thePinchableImage
component.
Installing react-native-gesture-handler
You can install the gesture handler with the following command:
npm install --save react-native-gesture-handler
At the time of writing this tutorial, react-native link
doesn’t really link the native modules to the project. So we’ll have to use Cocoapods instead. This also means that you’ll have to do the manual install for Android if you’re planning to test on both iOS and Android.
If you only need to test on Android, you should be able to get away with using react-native link
to link the native module. We’ll get to the Android installation shortly, first let’s get into iOS.
iOS configuration
cd
into the ios
directory and initialize a new Podfile
:
cd ios
pod init
Next, replace its contents with the following. Here we’re adding React Native modules as pods, then below it (pod 'RNGestureHandler
'
) is where we include the actual module for the gesture handler:
target 'RNRealworldAnimations' do
rn_path = '../node_modules/react-native'
pod 'yoga', path: "#{rn_path}/ReactCommon/yoga/yoga.podspec"
pod 'React', path: rn_path, subspecs: [
'Core',
'CxxBridge',
'DevSupport',
'RCTActionSheet',
'RCTAnimation',
'RCTGeolocation',
'RCTImage',
'RCTLinkingIOS',
'RCTNetwork',
'RCTSettings',
'RCTText',
'RCTVibration',
'RCTWebSocket',
]
pod 'DoubleConversion', :podspec => "#{rn_path}/third-party-podspecs/DoubleConversion.podspec"
pod 'glog', :podspec => "#{rn_path}/third-party-podspecs/glog.podspec"
pod 'Folly', :podspec => "#{rn_path}/third-party-podspecs/Folly.podspec"
pod 'RNGestureHandler', :path => '../node_modules/react-native-gesture-handler/ios'
end
post_install do |installer|
installer.pods_project.targets.each do |target|
if target.name == "React"
target.remove_from_project
end
end
end
After that, install all the pods:
pod install
Once that’s done, you can now run your project:
cd ..
react-native run-ios
Android configuration
To configure it on Android, open the android/settings.gradle
file and add the following right before the include ':app'
:
include ':react-native-gesture-handler'
project(':react-native-gesture-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-gesture-handler/android')
Next, open android/app/build.gradle
and include react-native-gesture-handler
right after react-native-vector-icons
:
dependencies {
compile project(':react-native-vector-icons')
compile project(':react-native-gesture-handler')
// ...other dependencies here
}
Next, open android/app/src/main/java/com/rnrealworldanimations/MainApplication.java
and import the corresponding gesture handler library:
// other previously imported libraries here
import com.facebook.soloader.SoLoader; // previously imported library
import com.swmansion.gesturehandler.react.RNGestureHandlerPackage; // add this
Then use it inside getPackages
:
protected List<ReactPackage> getPackages() {
return Arrays.<ReactPackage>asList(
new MainReactPackage(),
new VectorIconsPackage(),
new RNGestureHandlerPackage() // add this
);
}
Lastly, open android/app/src/main/java/com/rnrealworldanimations/MainActivity.java
and import the gesture handler libraries:
package com.rnrealworldanimations;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate; // add this
import com.facebook.react.ReactRootView; // add this
import com.swmansion.gesturehandler.react.RNGestureHandlerEnabledRootView; // add this
Inside the class, use ReactActivityDelegate
to enable the gesture handler on the root view:
public class MainActivity extends ReactActivity {
@Override
protected String getMainComponentName() {
return "RNRealworldAnimations";
}
// add everything below this line
@Override
protected ReactActivityDelegate createReactActivityDelegate() {
return new ReactActivityDelegate(this, getMainComponentName()) {
@Override
protected ReactRootView createRootView() {
return new RNGestureHandlerEnabledRootView(MainActivity.this);
}
};
}
}
Once that’s done, execute react-native run-android
from your terminal to rebuild and run the project.
Creating the PinchableImage component
Create a src/components/PinchableImage.js
file and add the following. React Native Gesture Handler is only responsible for handling the gesture, you still have to write the code for the animations to be applied when those gestures are performed by the user. That’s why we still need to include the Animated
module:
import React, { Component } from "react";
import { Animated, View, Dimensions } from "react-native";
import { PinchGestureHandler, State } from "react-native-gesture-handler";
const { width, height } = Dimensions.get("window"); // used for dealing with image dimensions
export default class PinchableImage extends Component {
// next: add constructor
}
Most of the time, all you will need to extract from the gesture handler module is the gesture you want to handle, and the State
. In this case, we want to use the PinchGestureHandler
. But you can also use handlers like FlingGestureHandler
to implement the swiping gesture that we did earlier, or the PanGestureHandler
to implement a drag-and-drop. Best of all, you can combine these gesture handlers together to create more complex interactions.
Next, in the constructor
, we initialize the animated values that we will be using:
-
baseScale
- the default scale value of the image. Here, we’ve set it to1
so that if it becomes larger than1
then the user is making the image larger. If it’s less than1
then the user is making it smaller. -
pinchScale
- the scale value for the pinch gesture. -
scale
- the actual scale value to be used for the transform scale. -
lastScale
- the scale value to be used for updating thebaseScale
. -
imageScale
- the current scale value of the image.
Here’s the code:
// src/components/PinchableImage.js
constructor(props) {
super(props);
this.baseScale = new Animated.Value(1);
this.pinchScale = new Animated.Value(1);
this.scale = Animated.multiply(this.baseScale, this.pinchScale);
this.lastScale = 1;
this.imageScale = 0;
}
Throughout the lifecycle of the whole gesture, the _onPinchHandlerStateChange
method will be executed. Previously, we’ve used onPanResponderGrant
, onPanResponderMove
and onPanResponderRelease
. But when using the gesture handler, it’s consolidated into a single method. Though this time, there’s the handler state which is used to respond to different states of the gesture. Here’s how it maps to the PanResponder methods:
-
onPanResponderGrant
-State.BEGAN
-
onPanResponderMove
-State.ACTIVE
-
onPanResponderRelease
-State.END
Additionally, there’s also State.UNDETERMINED
, State.CANCELLED
, and State.FAILED
.
Since we don’t really need to perform anything when the gesture is started or ended, we wrap everything in State.ACTIVE
condition. Inside it, we calculate the newImageScale
. We do that by first determining whether the user’s fingers have traveled away from each other or near each other. If event.nativeEvent.scale
is negative, then it means the user is trying to zoom it out. Otherwise (larger than 0
) the user is zooming it in. We then use it to determine whether to add or subtract the currentScale
from the imageScale
. Here’s the code:
// src/components/PinchableImage.js
_onPinchHandlerStateChange = event => {
if (event.nativeEvent.oldState === State.ACTIVE) {
let currentScale = event.nativeEvent.scale; // the distance travelled by the user's two fingers
this.lastScale *= event.nativeEvent.scale; // the value we will animated the baseScale to
this.pinchScale.setValue(1); // everytime the handler is triggered, it's considered as 1 scale value
// the image's new scale value
let newImageScale =
currentScale > 0
? this.imageScale + currentScale
: this.imageScale - currentScale;
// next: add code for checking if scale is within range
}
};
Next, we determine if the newImageScale
is within range. If it is, then we use a spring animation to scale the image:
if (this.isScaleWithinRange(newImageScale)) {
Animated.spring(this.baseScale, {
toValue: this.lastScale,
bounciness: 20,
useNativeDriver: true
}).start();
this.imageScale = newImageScale; // don't forget to update the imageScale
} else {
// next: add code for animating the component if it's not within range
}
If it’s not within range, then we scale the image back to its original size:
this.lastScale = 1;
this.imageScale = 0;
Animated.spring(this.baseScale, {
toValue: 1,
bounciness: 20,
useNativeDriver: true
}).start();
The isScaleWithinRange
function is used to determine whether the scale is still within a specific limit. In this case, we don’t want the image to become smaller than half its size (0.5
), and we also don’t want it to become five times (5
) larger than its original size:
// src/components/PinchableImage.js
isScaleWithinRange = newImageScale => {
if (newImageScale >= 0.5 && newImageScale <= 5) {
return true;
}
return false;
};
In order for the component to respond to pinch gesture, we wrap everything inside the PinchGestureHandler
component. This handler only requires you to pass the onHandlerStateChange
prop which gets executed throughout the lifecycle of the gesture. Inside it are the components you want to animate as the gesture is being performed by the user. Here’s the render
method:
render() {
return (
<PinchGestureHandler
onHandlerStateChange={this._onPinchHandlerStateChange}
>
<View style={styles.container}>
<View style={styles.imageContainer}>
<Animated.Image
resizeMode={"contain"}
source={this.props.image}
style={[
styles.pinchableImage,
{
transform: [{ scale: this.scale }]
}
]}
/>
</View>
</View>
</PinchGestureHandler>
);
}
You can also combine multiple gestures by wrapping the components inside multiple gesture handlers.
Next, add the styles:
const styles = {
container: {
flex: 1
},
imageContainer: {
width: width,
overflow: "visible",
alignItems: "center"
},
pinchableImage: {
width: 250,
height: height / 2 - 100
}
};
Update the BigCard component
Next, we update the BigCard
component to use the PinchableImage
in place of the Animated.Image
that we currently have:
// src/components/BigCard.js
import PinchableImage from "./PinchableImage";
Inside the component, we update the final output range of titleMoveY
to be equal to half of the screen’s size:
import { /*previously imported modules*/ Dimensions } from "react-native";
const { height } = Dimensions.get("window");
export default class BigCard extends Component<Props> {
const titleMoveY = this.titleTranslateYValue.interpolate({
inputRange: [0, 1],
outputRange: [0, height / 2]
});
}
All the other animated values (titleScale
stays the same). Note that we’ve also removed the animated value for the image opacity. This means that image will no longer be animated when the user views the Details screen. The only animation it’s going to perform is when the user uses the pinch gesture.
Next, replace Animated.Image
with PinchableImage
:
return (
<View style={styles.container}>
<View style={styles.imageContainer}>
<View style={styles.mainContainer}>
<PinchableImage image={image} />
...
</View>
</View>
</View>
);
Once that’s done, you should be able to use a pinch gesture on the image to make it larger or smaller.
Conclusion
That’s it! In this tutorial, you’ve learned how to implement drag-and-drop, swiping, and pinch gestures. You’ve also learned how implementing gesture animations can be quite code-heavy. Good thing there are libraries like the React Native Gesture Handler which makes it easy to respond to user gestures.
The full source code for this tutorial is available on this GitHub repo. Be sure to switch to the part3
branch if you want the final output for this part of the series.
Originally published on the Pusher blog
Top comments (0)