DEV Community

Wern Ancheta
Wern Ancheta

Posted on • Originally published at blog.pusher.com

Adding animations to your React Native app - Part 3: Gesture animations

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:

Final output implementing pan, swipe, and pinch gestures (Image credit: pokeapi.co, pokemondb.net)

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:

Drag and drop gesture

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:

  1. Update the Card component to use the React Native’s PanResponder module. This makes it draggable across the screen.
  2. 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.
  3. Update the main screen to include the DropArea. Then create the functions to be passed as props to the CardList, Card, and DropArea 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:

  1. When the user touches the card and drags it around, we want it to scale it down and lower its opacity.
  2. 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.
  3. When the user drags the card over to a target area of the DropArea, we want to change the border and text color of the DropArea to green. This indicates that the user in on-target and they can drop the card there.
  4. 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 supplied true, 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 specified true, but if you need to check whether a specific condition returns true, 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 the DropArea 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:

Swipe gesture

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:

  1. Create a Swiper component where the swiping gesture will be added. These are the items that you see in the above demo.
  2. Create a Trash screen. This is where the Swiper component will be used to display the items that were dropped in the drop area.
  3. 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:

Pinch gesture for zooming object in and out

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:

  1. Install and link React Native Gesture Handler to the project.
  2. Create a PinchableImage component. This is where we will use the gesture handler library to implement a pinch-to-zoom gesture.
  3. Update the BigCard component to replace the original image with the PinchableImage 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 to 1 so that if it becomes larger than 1 then the user is making the image larger. If it’s less than 1 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 the baseScale.
  • 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)