【React Native】Tinderのカードスワイプアニメーションを実装

#はじめに
React Nativeアニメーションの再入門ということで、スワイプするごとに重なったカードが続々と表示されていく機能を実装してみました。Classコンポーネントを使った古い書き方で実装している個人記事がほとんどだったので、これからReact Nativeのアニメーションを勉強する方の参考になればと思い、functionalコンポーネント縛りで実装しました。

以下が完成品のGIFです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/c03aa3db-c1d4-2924-8258-08e8530414f6.gif" width="400px" />

#実装
今回はReact Native + Expo + TypeScriptの構成で実装しています。Expoの導入手順については<a href="https://docs.expo.io/">公式Docs</a>を参照いただければと思います。

また、説明の都合上、importやstyleの部分をほとんど省略して記載しています。ソースコードの詳細を知りたい方や説明なんていらんという方は、<a href="https://github.com/suzuki0430/swipe-animation-typescript">GitHub</a>をご確認ください。

##1. 1つのデータを表示する
今回はアニメーションの動作を確認することが目的だったので、模擬データ(DATA)をローカルで用意しました。

まずは、Expoを導入した際にルートとなるApp内のDeckコンポーネントでDATAを読み込みます。

Deckコンポーネント内に機能を実装していきます。

```javascript
const DATA = [
  {
    id: 1,
    name: '浜辺　美波',
    uri: minami,
    goal: '若手No.1女優',
  },
];

export default function App() {
  return (
    <View style={styles.container}>
      <Deck data={DATA} />
    </View>
  );
}
```
###スワイプしてカードを動かす
React Nativeアニメーションの基本として、カードを指で動かす(スワイプさせる)機能を実装します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/2e2745b9-dec5-ebdf-80c3-5c4eca0183c9.png" width="400px" />

Deck.tsx内でpositionとpanResponderを定義します。

- position: アニメーションを行いたい(スワイプさせたい)コンポーネントの位置
- panResponder: コンポーネントをジェスチャーさせたとき(スワイプやリリースなど)の詳細を設定

```javascript
  const position = useRef(new Animated.ValueXY()).current;
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true, // 押したとき
      onPanResponderMove: (event, gesture) => {
        console.log(gesture);
      }, // スワイプしたとき
    })
  ).current;
```

positionとpanResponderを対象のViewコンポーネントにアタッチします。これらをアタッチする際に、Viewは**Animated.View**に変えてあげる必要があります。

```javascript
<Animated.View
  key={item.id}
  style={[
    {
      ...position.getLayout(),
    },
    styles.cardStyle,
  ]}
  {...panResponder.panHandlers}
>
  <Card key={item.id}>
    <Card.Title style={styles.titleStyle}>{item.name}</Card.Title>
    <Card.Divider />
    <Card.Image source={item.uri} />
    <Card.Divider />
    <Text style={styles.textStyle}>目標：{item.goal}</Text>
    <Button
      buttonStyle={styles.buttonStyle}
      title="プロフィールはこちら"
    />
  </Card>
</Animated.View>

```

position.getLayout()を...で展開していますが、これはleftとtopのstyleを示しています(初期値はどちらも0)。

```
"left": 0,
"top": 0,
```

また、{...panResponder.panHandlers}の部分で、panReponderで設定したonStartShouldSetPanResponderとonPanResponderMoveを展開しています。

onPanResponderMoveのgestureをconsole.logで出力すると、中身は以下のようになっています。

```
"dx": -60.333343505859375, // x方向のスワイプ量
"dy": -139.3333282470703, // y方向のスワイプ量
"moveX": 161.66665649414062, // スワイプ後のx位置
"moveY": 261.3333282470703, // スワイプ後のy位置
"numberActiveTouches": 1, // タッチしている指の本数
"stateID": 0.6153677286479471, 
"vx": 0.05691986245881074, // x方向のスワイプ速度(dx/タッチ時間)
"vy": -0.05691986245881074, // y方向のスワイプ速度(dy/タッチ時間)
"x0": 222, // スワイプ前のx位置
"y0": 400.6666564941406, // スワイプ前のy位置
```

**スワイプ量dx, dyをpositionに反映させることができれば、コンポーネントを動かせそうですね。**
そんなわけで、onPanResponderMove内でpositionのx, yをdx, dyに設定すると、コンポーネントを自由に動かすことができます。

```javascript
onPanResponderMove: (event, gesture) => {
  position.setValue({ x: gesture.dx, y: gesture.dy });
}
```

###スワイプしたカードを回転させる
スワイプさせるだけだと味気ないので、スワイプと同時にカードを回転させるような機能もつけます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/5a07f713-6118-ef5b-6f10-e92ec83bd248.png" width="400px" />

この機能を実装するにあたって大事なのはInterporation(線形補間)という考え方です。
これは、**カードの移動量に対して回転する角度を指定する**というものです。

Dimensionsで端末の画面幅を取得し、初期位置(x=0)のときの回転角度を0°、カードを端末の端っこ(x=SCREEN_WIDTH)まで移動させたときの回転角度を120°と設定(線形補間)しています。

```javascript
const SCREEN_WIDTH = Dimensions.get('window').width;

const rotate = position.x.interpolate({
  inputRange: [-SCREEN_WIDTH, 0, SCREEN_WIDTH],
  outputRange: ['-120deg', '0deg', '120deg'],
});
```

以上で定義したrotateをstyleにアタッチしてあげることで、実装は完了です。

```javascript
style={{
  ...position.getLayout(),
  transform: [{ rotate }],
}}
```

###一定の位置でカードの挙動を変える
カードを動かしたときに一定の位置を超えた場合はカードを画面外に強制スワイプさせ、超えない場合は元の位置に戻すような機能を実装します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/1e5f2beb-420e-73a3-2dc4-5d308e2138e9.png" width="400px" />

####強制スワイプ
"強制スワイプ"と"元の位置に戻す"の2つの挙動を切り替える境目(しきい値)をSWIPE_THRESHOLDとして設定します。

```javascript
const SWIPE_THRESHOLD = 0.25 * SCREEN_WIDTH;
```

onPanResponderReleaseではカードから指を離したときの処理を記述することができます。以下ではしきい値ごとにコールバックする関数を切り替えており、forceSwipe()が強制スワイプの処理を行う関数です。

```javascript
onPanResponderRelease: (event, gesture) => {
  if (gesture.dx > SWIPE_THRESHOLD) {
    forceSwipe('right');
  } else if (gesture.dx < -SWIPE_THRESHOLD) {
    forceSwipe('left');
  } else {
    resetPosition();
  }
},
```
Animated.timing内のtoValueでしきい値を超えた後のカードの位置を設定しています。durationではアニメーションにかかる時間を指定しています。

```javascript
const forceSwipe = (direction: string) => {
  const x = direction === 'right' ? SCREEN_WIDTH : -SCREEN_WIDTH;
  Animated.timing(position, {
    toValue: { x, y: 0 },
    duration: SWIPE_OUT_DURATION,
    useNativeDriver: false,
  });
};
```

####元の位置に戻す
resetPosition()がカードを元の位置に戻す関数です。Animated.springで元の位置(x: 0, y: 0)に戻しています。

```javascript
const resetPosition = () => {
  Animated.spring(position, {
    toValue: { x: 0, y: 0 },
    useNativeDriver: false,
  }).start();
};

```

forceSwipe()で使用したAnimated.timingに比べ、Animated.springではゆっくりした挙動になります。挙動の違いについては以下のGIFをご覧ください。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/facc03ce-e412-b6f1-855a-c5658751b0d4.gif" width="400px" />

##2. 複数のデータを表示する
カードスワイプのアニメーションを実装できたので、今度は複数のデータを用意して目的の動作に近づけていきます。

###データをまとめて表示する
読み込むデータを増やすことで、以下のようにカードが並んで表示されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/05cd234b-d093-ff2d-579e-52e89785781b.png" width="400px" />

###データを重ねて表示する
データを重ねて表示するために、Animated.ViewのcardStyleに以下を加えます。

```javascript
position: 'absolute'
```

###スワイプしたら後ろのカードが表示される
この機能を実装するために、indexというstateを用意します。

```javascript
const [index, setIndex] = useState(0);
```
**カードをスワイプするごとにindexを1ずつ増やしていき、表示データ(配列)のインデックスと同じカードのみアニメーションを適用する。**というのが実装のイメージとなります。

強制スワイプ後にindexを増やすonSwipeComplete()という処理を追加し、Animate.timing()の後に.start(() => onSwipeComplete(direction))で呼び出します。

```javascript
  const forceSwipe = (direction: string) => {
    const x = direction === 'right' ? SCREEN_WIDTH : -SCREEN_WIDTH;
    Animated.timing(position, {
      toValue: { x, y: 0 },
      duration: SWIPE_OUT_DURATION,
      useNativeDriver: false,
    }).start(() => onSwipeComplete(direction));
  };

  const onSwipeComplete = (direction: string) => {
    direction === 'right' ? onSwipeRight() : onSwipeLeft();
    position.setValue({ x: 0, y: 0 });
    setIndex((prevIndex) => prevIndex + 1);
  };

  const onSwipeRight = () => {};
  const onSwipeLeft = () => {};
```
indexの値で各カードの表示およびアニメーションの適用有無を判定します。

```javascript
return (
  {data
    .map((item, i) => {
      if (i < index) {
        return null; // スワイプ後のカード(表示しない)
      }

      if (i === index) {
        return (
          // 一番上のカード(アニメーションを適用する)
        );
      }

      return (
        // 下に重なっているカード(アニメーションを適用しない)
      );
    })
    .reverse()}
)
```
また、以下を記述することで、カードをスワイプしたときに、下のカードが浮き上がってくるようなアニメーションも加えることができます。

```javascript
LayoutAnimation.spring();
```

これでようやく目的の機能が完成しました！

最後に、Deck.tsxの中身も貼っておきます。

```javascript
import React, { useRef, useState } from 'react';
import {
  StyleSheet,
  Text,
  Animated,
  PanResponder,
  Dimensions,
  LayoutAnimation,
  UIManager,
  ImageSourcePropType,
} from 'react-native';
import { Card, Button } from 'react-native-elements';

const SCREEN_WIDTH = Dimensions.get('window').width;
const SWIPE_THRESHOLD = 0.25 * SCREEN_WIDTH;
const SWIPE_OUT_DURATION = 250;

type Props = {
  id: number;
  name: string;
  uri: ImageSourcePropType;
  goal: string;
};

export const Deck = ({ data }: { data: Props[] }) => {
  UIManager.setLayoutAnimationEnabledExperimental &&
    UIManager.setLayoutAnimationEnabledExperimental(true);
  LayoutAnimation.spring();

  const [index, setIndex] = useState(0);

  const position = useRef(new Animated.ValueXY()).current;
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onPanResponderMove: (event, gesture) => {
        position.setValue({ x: gesture.dx, y: gesture.dy });
      },
      onPanResponderRelease: (event, gesture) => {
        if (gesture.dx > SWIPE_THRESHOLD) {
          forceSwipe('right');
        } else if (gesture.dx < -SWIPE_THRESHOLD) {
          forceSwipe('left');
        } else {
          resetPosition();
        }
      },
    })
  ).current;

  const forceSwipe = (direction: string) => {
    const x = direction === 'right' ? SCREEN_WIDTH : -SCREEN_WIDTH;
    Animated.timing(position, {
      toValue: { x, y: 0 },
      duration: SWIPE_OUT_DURATION,
      useNativeDriver: false,
    }).start(() => onSwipeComplete(direction));
  };

  const onSwipeComplete = (direction: string) => {
    const item = data[index];

    direction === 'right' ? onSwipeRight() : onSwipeLeft();
    position.setValue({ x: 0, y: 0 });
    setIndex((prevIndex) => prevIndex + 1);
  };

  const onSwipeRight = () => {};
  const onSwipeLeft = () => {};

  const resetPosition = () => {
    Animated.spring(position, {
      toValue: { x: 0, y: 0 },
      useNativeDriver: false,
    }).start();
  };

  const rotate = position.x.interpolate({
    inputRange: [-SCREEN_WIDTH * 1.5, 0, SCREEN_WIDTH * 1.5],
    outputRange: ['-120deg', '0deg', '120deg'],
  });

  return (
    <>
      {index >= data.length && <RenderNoMoreCards />}
      {data
        .map((item, i) => {
          if (i < index) {
            return null;
          }

          if (i === index) {
            return (
              <Animated.View
                key={item.id}
                style={[
                  {
                    ...position.getLayout(),
                    transform: [{ rotate }],
                  },
                  styles.cardStyle,
                ]}
                {...panResponder.panHandlers}
              >
                <RenderCards item={item} />
              </Animated.View>
            );
          }

          return (
            <Animated.View
              key={item.id}
              style={[styles.cardStyle, { top: 10 * (i - index) }]}
            >
              <RenderCards item={item} />
            </Animated.View>
          );
        })
        .reverse()}
    </>
  );
};

const RenderCards = ({ item }: { item: Props }) => {
  return (
    <Card key={item.id}>
      <Card.Title style={styles.titleStyle}>{item.name}</Card.Title>
      <Card.Divider />
      <Card.Image source={item.uri} />
      <Card.Divider />
      <Text style={styles.textStyle}>目標：{item.goal}</Text>
      <Button buttonStyle={styles.buttonStyle} title="プロフィールはこちら" />
    </Card>
  );
};

const RenderNoMoreCards = () => {
  return (
    <Card>
      <Card.Title style={styles.titleStyle}>終了</Card.Title>
      <Card.Divider />
      <Text style={styles.textStyle}>検索にかかった女優は以上です</Text>
      <Button buttonStyle={styles.buttonStyle} title="もっと女優を探す" />
    </Card>
  );
};

const styles = StyleSheet.create({
  cardStyle: {
    position: 'absolute',
    width: SCREEN_WIDTH,
  },
  titleStyle: { fontSize: 18 },
  textStyle: { marginBottom: 10, fontSize: 16 },
  buttonStyle: {
    borderRadius: 0,
    marginLeft: 0,
    marginRight: 0,
    marginBottom: 0,
  },
});

```

#おわりに
今までなんとなくで使用していたReact Nativeのアニメーションを初めてちゃんと学習しました。学んだ知見をアプリUXの向上に役立てていきたいと思います。