React Native で Liquid Swipe を実装するための4つの技術

この記事は [React Native Advent Calendar 2021](https://qiita.com/advent-calendar/2021/react-native) の21日目の記事です。

少し前に Twitter で Liquid Swipe の映像を見て興味を持ったのですが、 React Native での実装を YouTube で紹介してくださっている方がいらっしゃいました。

https://www.youtube.com/watch?v=6jxy5wfNpk0

この動画を見ながら実装すれば、 Liquid Swipe を実現できるのですが、仕組みがあまりわかリませんでした。
この記事では Liquid Swipe の実装に使用されている技術について、紹介しようと思います。

ちなみに動画を見ながら実装したものがこちらになります

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186009/9b7a1b2e-7eed-9200-204d-04cd93bdd26d.gif" width=200px>

## 採用されている技術
### React Native SVG
https://github.com/react-native-svg/react-native-svg

この Liquid Swipe では、 SVG の Paths が使用されています。
SVG の Paths を使えば、直線、曲線などを表現し、図形を作成することができます。
以下のコードで、画面上に正方形を表示することができます。

```react
import Svg, {Path} from 'react-native-svg';
import {StyleSheet} from 'react-native';

const Example = () => {
  return (
    <Svg style={StyleSheet.absoluteFill}>
      <Path d="M 0 0 H 200 V 200 H 0 L 0 0" fill="#55C500" />
    </Svg>
  );
};
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186009/8da94f59-3475-cb70-1d48-1f5e396a7fb1.png" width=200px>

Dimensions を使用すれば、図形を画面いっぱいに表示することができます。

```react
import Svg, {Path} from 'react-native-svg';
import {Dimensions, StyleSheet} from 'react-native';

const Example = () => {
  const {width, height} = Dimensions.get('screen');

  return (
    <Svg style={StyleSheet.absoluteFill}>
      <Path d={`M 0 0 H ${width} V ${height} H 0 L 0 0`} fill="#55C500" />
    </Svg>
  );
};
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186009/fe744b82-48d1-6250-4973-9cb9928e3ab5.png" width=200px>

Liquid Swipe ではベジェ曲線を使用しています。
MDN web docs にベジェ曲線の使い方など詳しく書かれているので、ご参考ください。

https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Paths

### React Native MaskedView
https://github.com/react-native-masked-view/masked-view

この Liquid Swipe では、 SVG を使用してマスクする実装がなされています。
README のサンプルコードにもありますが、背景色が設定されている画面を、文字列が書かれた画面でマスクすることで、背景に設定した色で文字列が浮かび上がります。

```react
import {StyleSheet, Text, View} from 'react-native';
import MaskedView from '@react-native-masked-view/masked-view';

const Example = () => {
  const maskElement = (
    <View
      style={
        (StyleSheet.absoluteFill,
        {flex: 1, alignItems: 'center', justifyContent: 'center'})
      }>
      <Text style={{fontSize: 156, fontWeight: 'bold'}}>Qiita</Text>
    </View>
  );

  return (
    <MaskedView style={StyleSheet.absoluteFill} maskElement={maskElement}>
      <View style={[StyleSheet.absoluteFill, {backgroundColor: '#55C500'}]} />
    </MaskedView>
  );
};
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186009/a556c057-bcc1-c488-36b8-10fc5790c3d4.png" width=500px>

maskElement には SVG で作成した図形も設定することができます。
以下のコードで、下にある画像を半分だけマスクして、別の画像を表示させることができます。

```react
import Svg, {Path} from 'react-native-svg';
import {Dimensions, Image, StyleSheet} from 'react-native';
import MaskedView from '@react-native-masked-view/masked-view';

const Example = () => {
  const {width, height} = Dimensions.get('screen');

  const maskElement = (
    <Svg style={StyleSheet.absoluteFill}>
      <Path d={`M 0 0 H ${width / 2} V ${height} H 0 L 0 0`} fill="black" />
    </Svg>
  );

  return (
    <>
      <Image
        style={{width: '100%'}}
        source={require('../assets/2.png')}
        resizeMode="contain"
      />
      <MaskedView style={StyleSheet.absoluteFill} maskElement={maskElement}>
        <Image
          style={{width: '100%'}}
          source={require('../assets/1.png')}
          resizeMode="contain"
        />
      </MaskedView>
    </>
  );
};
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186009/7d8d7079-51ce-5a42-cb6d-a44c8a2b0cdb.png" width=500px>

### React Native Reanimated
https://github.com/software-mansion/react-native-reanimated

この Liquid Swipe では、 SVG の Path に Reanimated が使用されていて、マスク範囲を動的に変更しています。
以下のコードで、 Button を押すたびに Path の横幅を動的に変更できます。

```react
import Svg, {Path} from 'react-native-svg';
import {Button, Dimensions, Image, StyleSheet, View} from 'react-native';
import MaskedView from '@react-native-masked-view/masked-view';
import Animated, {
  useAnimatedProps,
  useSharedValue,
} from 'react-native-reanimated';

const Example = () => {
  const {width, height} = Dimensions.get('screen');
  const randomWidth = useSharedValue(100);
  const AnimatedPath = Animated.createAnimatedComponent(Path);
  const animatedProps = useAnimatedProps(() => {
    return {
      d: `M 0 0 H ${randomWidth.value} V ${height} H 0 L 0 0`,
    };
  });
  const maskElement = (
    <Svg style={StyleSheet.absoluteFill}>
      <AnimatedPath fill="black" animatedProps={animatedProps} />
    </Svg>
  );

  return (
    <>
      <Image
        resizeMode="contain"
        source={require('../assets/2.png')}
        style={{width: '100%'}}
      />
      <MaskedView style={StyleSheet.absoluteFill} maskElement={maskElement}>
        <Image
          resizeMode="contain"
          source={require('../assets/1.png')}
          style={{width: '100%'}}
        />
      </MaskedView>
      <View
        style={{
          position: 'absolute',
          top: 600,
          right: 0,
          bottom: 0,
          left: 0,
          justifyContent: 'center',
          alignItems: 'center',
        }}>
        <View style={{backgroundColor: '#55C500', borderRadius: 4, width: 200}}>
          <Button
            color="white"
            onPress={() => {
              randomWidth.value = Math.random() * width;
            }}
            title="Button"
          />
        </View>
      </View>
    </>
  );
};
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186009/4352c897-9f8e-2a12-d551-bf7db9afc7ec.gif" width=200px>

### React Native Gesture Handler
https://github.com/software-mansion/react-native-gesture-handler

この Liquid Swipe では、 Gesture Handler をフックして、 Reanimated でラップした Path を動的に変更しています。
以下のコードで、スクロールした分だけマスク範囲を広げたり、狭めたりすることができます。

```react
import Svg, {Path} from 'react-native-svg';
import {Dimensions, Image, StyleSheet} from 'react-native';
import MaskedView from '@react-native-masked-view/masked-view';
import Animated, {
  useAnimatedGestureHandler,
  useAnimatedProps,
  useSharedValue,
} from 'react-native-reanimated';
import {PanGestureHandler} from 'react-native-gesture-handler';

const Example = () => {
  const {height} = Dimensions.get('screen');
  const maskedWidth = useSharedValue(10);

  const AnimatedPath = Animated.createAnimatedComponent(Path);
  const animatedProps = useAnimatedProps(() => {
    return {
      d: `M 0 0 H ${maskedWidth.value} V ${height} H 0 L 0 0`,
    };
  });

  const onGestureEvent = useAnimatedGestureHandler({
    onStart: ({x}) => {
      maskedWidth.value = x;
    },
    onActive: ({x}) => {
      maskedWidth.value = x;
    },
    onEnd: ({x}) => {
      maskedWidth.value = x;
    },
  });

  const maskElement = (
    <Svg style={StyleSheet.absoluteFill}>
      <AnimatedPath fill="black" animatedProps={animatedProps} />
    </Svg>
  );

  return (
    <PanGestureHandler onGestureEvent={onGestureEvent}>
      <Animated.View style={StyleSheet.absoluteFill}>
        <Image
          resizeMode="contain"
          source={require('../assets/2.png')}
          style={{width: '100%'}}
        />
        <MaskedView style={StyleSheet.absoluteFill} maskElement={maskElement}>
          <Image
            resizeMode="contain"
            source={require('../assets/1.png')}
            style={{width: '100%'}}
          />
        </MaskedView>
      </Animated.View>
    </PanGestureHandler>
  );
};
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186009/90d08d0d-3f90-ab6b-88e4-bc46a1574263.gif" width=200px>

## 最後に
基本的には以上4つの技術を使用して、 Liquid Swipe が実装されます。
アニメーションの効果を調整したり、 SVG の Path を曲線で表現したりすることで、 Liquid Swipe が実現します。

自分は上記の4つの技術を理解して、もう一度実装したことで理解が深まりました。
React Native で Liquid Swipe を実装するときに、ぜひ4つの技術について見てみてください。