📘

React NativeでBottom Sheetを作る方法

2023/08/17に公開

はじめに

こんにちは!
犬専用の音楽アプリ オトとりっぷでエンジニアしています、足立です!

https://www.oto-trip.com/

この記事では、React Native Reanimatedを使ったBottom Sheetの作り方を紹介します。
元ネタは以下の動画です。動画内の指示通りに作業してBottom Sheetを作ってみます。

https://www.youtube.com/watch?v=lYYiuXcTnnE

途中はどうでもいいから、結果だけ知りたいんだよっというせっかちさんのために、完成品をこちらに置いておきます。
コード類は長いので、基本的にトグル表示にしています。

App.tsx Final
App.tsx
import React, {useState} from 'react';
import {Button, Pressable, StyleSheet, View} from 'react-native';
import 'react-native-gesture-handler';
import {
  Gesture,
  GestureDetector,
  GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
  FadeIn,
  FadeOut,
  SlideInDown,
  SlideOutDown,
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from 'react-native-reanimated';
import {SafeAreaProvider} from 'react-native-safe-area-context';

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const OVERDRAG = 20;
const HEIGH = 220;

function App() {
  const offset = useSharedValue(0);
  const [isOpen, setOpen] = useState(false);

  const toggleSheet = () => {
    setOpen(prev => !prev);
    offset.value = 0;
  };

  const pan = Gesture.Pan()
    .onChange(event => {
      const offDelta = event.changeY + offset.value;
      const clamp = Math.min(-OVERDRAG, offDelta);

      offset.value = offDelta > 0 ? offDelta : withSpring(clamp);
    })
    .onFinalize(() => {
      if (offset.value < HEIGH / 3) {
        offset.value = withSpring(0);
      } else {
        offset.value = withTiming(HEIGH, {}, () => {
          runOnJS(toggleSheet)();
        });
      }
    });

  const translateY = useAnimatedStyle(() => ({
    transform: [{translateY: offset.value}],
  }));

  return (
    <GestureHandlerRootView style={styles.container}>
      <SafeAreaProvider>
        <View style={styles.contents}>
          <Button title="Open" onPress={toggleSheet} />
        </View>

        {isOpen && (
          <>
            <AnimatedPressable
              entering={FadeIn}
              exiting={FadeOut}
              style={styles.backdrop}
              onPress={toggleSheet}
            />
            <GestureDetector gesture={pan}>
              <Animated.View
                style={[styles.sheet, translateY]}
                entering={SlideInDown}
                exiting={SlideOutDown}>
                <Button title="Close" onPress={toggleSheet} />
              </Animated.View>
            </GestureDetector>
          </>
        )}
      </SafeAreaProvider>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  contents: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  sheet: {
    backgroundColor: 'white',
    padding: 16,
    height: HEIGH,
    width: '100%',
    position: 'absolute',
    bottom: -OVERDRAG * 1.1,
    borderTopRightRadius: 20,
    borderTopLeftRadius: 20,
    zIndex: 1,
  },
  backdrop: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: 'rgba(0, 0, 0, 0.3)',
    zIndex: 1,
  },
});

export default App;

目次

  • Bottom Sheetとは?
  • 導入方法

Bottom Sheetとは?

まずはともあれ、完成品をご覧ください。

Bottom Sheetとは、画面下部からニョキっと登場するモーダルみたいなやつのことです。
「設定変更」の様なUIに用いられるタイプのモーダルですね。

このようなUIを実装する場合なにかしらのModalライブラリ(後述しますが)の力を借りるのが一般的だと思います。一方で、React Native Reanimated開発元のSoftware Mansionさんが提供してるライブラリを使用すると、自由度の高いUIを自らの手で実装することが可能です。

導入方法

下準備

まずはReact Nativeをinitし、必要なライブラリを導入します。

$ npx react-native@latest init AwesomeProject
$ cd AwesomeProject
$ yarn add react-native-reanimated react-native-gesture-handler react-native-safe-area-context
$ npx pod-install

babel.config.jsをちょこっと書き換えます。

babel.config.js
module.exports = {
  presets: ['module:metro-react-native-babel-preset'],
+  plugins: ['react-native-reanimated/plugin'],
};

最後にApp.tsxを以下の通りに書き換えます。

App.tsx init
App.tsx
import React, {useState} from 'react';
import {Button, Pressable, StyleSheet, View} from 'react-native';
import 'react-native-gesture-handler';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {SafeAreaProvider} from 'react-native-safe-area-context';

const OVERDRAG = 20;
const HEIGH = 220;

function App() {
  const [isOpen, setOpen] = useState(false);

  const toggleSheet = () => {
    setOpen(prev => !prev);
  };

  return (
    <GestureHandlerRootView style={styles.container}>
      <SafeAreaProvider>
        <View style={styles.contents}>
          <Button title="Open" onPress={toggleSheet} />
        </View>

        {isOpen && (
          <>
            <Pressable style={styles.backdrop} onPress={toggleSheet} />
            <View style={styles.sheet}>
              <Button title="Close" onPress={toggleSheet} />
            </View>
          </>
        )}
      </SafeAreaProvider>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  contents: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  sheet: {
    backgroundColor: 'white',
    padding: 16,
    height: HEIGH,
    width: '100%',
    position: 'absolute',
    bottom: -OVERDRAG * 1.1,
    borderTopRightRadius: 20,
    borderTopLeftRadius: 20,
    zIndex: 1,
  },
  backdrop: {
    ...StyleSheet.absoluteFillObject,
    backgroundColor: 'rgba(0, 0, 0, 0.3)',
    zIndex: 1,
  },
});

export default App;

まずは、BottomにSheetがあるだけの状態ができました。

アニメーションの追加

次にアニメーションを足していきます。
アニメーションにはreact-native-reanimatedを使用します。
(Animatedはreact-nativeからはimportしないように気をつけましょう。)

App.tsx + Animation
App.tsx
import React, {useState} from 'react';
import {Button, Pressable, StyleSheet, View} from 'react-native';
import 'react-native-gesture-handler';
+import Animated, {
+  FadeIn,
+  FadeOut,
+  SlideInDown,
+  SlideOutDown,
+} from 'react-native-reanimated';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {SafeAreaProvider} from 'react-native-safe-area-context';

+const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const OVERDRAG = 20;
const HEIGH = 220;

function App() {
  const [isOpen, setOpen] = useState(false);

  const toggleSheet = () => {
    setOpen(prev => !prev);
  };

  return (
    <GestureHandlerRootView style={styles.container}>
      <SafeAreaProvider>
        <View style={styles.contents}>
          <Button title="Open" onPress={toggleSheet} />
        </View>

        {isOpen && (
          <>
-            <Pressable style={styles.backdrop} onPress={toggleSheet} />
-            <View style={styles.sheet}>
-              <Button title="Close" onPress={toggleSheet} />
-            </View>
+            <AnimatedPressable
+              entering={FadeIn}
+              exiting={FadeOut}
+              style={styles.backdrop}
+              onPress={toggleSheet}
+            />
+            <Animated.View
+              style={styles.sheet}
+              entering={SlideInDown}
+              exiting={SlideOutDown}>
+              <Button title="Close" onPress={toggleSheet} />
+            </Animated.View>
          </>
        )}
      </SafeAreaProvider>
    </GestureHandlerRootView>
  );
}

これでアニメーション付きでSheetが登場するようになりました。

ジェスチャーの追加

次に、Bottom Sheetにジェスチャーを追加します。
ジェスチャー関連は、その名の通りreact-native-gesture-handlerからimportします。

App.tsx + Gesture
App.tsx
import React, {useState} from 'react';
import {Button, Pressable, StyleSheet, View} from 'react-native';
import 'react-native-gesture-handler';
+import {
+  Gesture,
+  GestureDetector,
+  GestureHandlerRootView,
+} from 'react-native-gesture-handler';
import Animated, {
  FadeIn,
  FadeOut,
  SlideInDown,
  SlideOutDown,
+  useAnimatedStyle,
+  useSharedValue,
} from 'react-native-reanimated';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {SafeAreaProvider} from 'react-native-safe-area-context';

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const OVERDRAG = 20;
const HEIGH = 220;

function App() {
+  const offset = useSharedValue(0);
  const [isOpen, setOpen] = useState(false);

  const toggleSheet = () => {
    setOpen(prev => !prev);
  };

+  const pan = Gesture.Pan().onChange(event => {
+    offset.value += event.changeY;
+  });

+  const translateY = useAnimatedStyle(() => ({
+    transform: [{translateY: offset.value}],
+  }));

  return (
    <GestureHandlerRootView style={styles.container}>
      <SafeAreaProvider>
        <View style={styles.contents}>
          <Button title="Open" onPress={toggleSheet} />
        </View>

        {isOpen && (
          <>
            <AnimatedPressable
              entering={FadeIn}
              exiting={FadeOut}
              style={styles.backdrop}
              onPress={toggleSheet}
            />
+            <GestureDetector gesture={pan}>
              <Animated.View
+                style={[styles.sheet, translateY]}
                entering={SlideInDown}
                exiting={SlideOutDown}>
                <Button title="Close" onPress={toggleSheet} />
              </Animated.View>
+            </GestureDetector>
          </>
        )}
      </SafeAreaProvider>
    </GestureHandlerRootView>
  );
}

ジェスチャーを追加することができましたが、Sheetがどこ行くねん状態なので修正が必要です。

Sheetの固定

次に、Bottom Sheetの可動域を固定します。
Gesture.Pan()の挙動を変更します。

App.tsx + Fix
App.tsx
import React, {useState} from 'react';
import {Button, Pressable, StyleSheet, View} from 'react-native';
import 'react-native-gesture-handler';
import {
  Gesture,
  GestureDetector,
  GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
  FadeIn,
  FadeOut,
  SlideInDown,
  SlideOutDown,
  useAnimatedStyle,
  useSharedValue,
+  withSpring,
} from 'react-native-reanimated';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {SafeAreaProvider} from 'react-native-safe-area-context';

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const OVERDRAG = 20;
const HEIGH = 220;

function App() {
  const offset = useSharedValue(0);
  const [isOpen, setOpen] = useState(false);

  const toggleSheet = () => {
    setOpen(prev => !prev);
  };

  const pan = Gesture.Pan()
    .onChange(event => {
-      offset.value += event.changeY;
+      const offDelta = event.changeY + offset.value;
+      const clamp = Math.min(-OVERDRAG, offDelta);
+      offset.value = offDelta > 0 ? offDelta : withSpring(clamp);
+    })
+    .onFinalize(() => {
+      offset.value = withSpring(0);
  });

  const translateY = useAnimatedStyle(() => ({
    transform: [{translateY: offset.value}],
  }));

  return (
    <GestureHandlerRootView style={styles.container}>
      <SafeAreaProvider>
        <View style={styles.contents}>
          <Button title="Open" onPress={toggleSheet} />
        </View>

        {isOpen && (
          <>
            <AnimatedPressable
              entering={FadeIn}
              exiting={FadeOut}
              style={styles.backdrop}
              onPress={toggleSheet}
            />
            <GestureDetector gesture={pan}>
              <Animated.View
                style={[styles.sheet, translateY]}
                entering={SlideInDown}
                exiting={SlideOutDown}>
                <Button title="Close" onPress={toggleSheet} />
              </Animated.View>
            </GestureDetector>
          </>
        )}
      </SafeAreaProvider>
    </GestureHandlerRootView>
  );
}

Sheetの可動域を固定することができました。

Sheetの自動Close

最後に、Bottom Sheetのが自動で閉じるように改変します。
Gesture.Pan().onFinalizeの挙動を変更します。
toggleSheetは、Animationスレッド内でJSスレッドを実行するためにrunOnJSで ラップしてあげます。

App.tsx + Close
App.tsx
import React, {useState} from 'react';
import {Button, Pressable, StyleSheet, View} from 'react-native';
import 'react-native-gesture-handler';
import {
  Gesture,
  GestureDetector,
  GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
  FadeIn,
  FadeOut,
  SlideInDown,
  SlideOutDown,
+  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
+  withTiming,
} from 'react-native-reanimated';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {SafeAreaProvider} from 'react-native-safe-area-context';

const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const OVERDRAG = 20;
const HEIGH = 220;

function App() {
  const offset = useSharedValue(0);
  const [isOpen, setOpen] = useState(false);

  const toggleSheet = () => {
    setOpen(prev => !prev);
+    offset.value = 0;
  };

  const pan = Gesture.Pan()
    .onChange(event => {
      const offDelta = event.changeY + offset.value;
      const clamp = Math.min(-OVERDRAG, offDelta);
      offset.value = offDelta > 0 ? offDelta : withSpring(clamp);
    })
    .onFinalize(() => {
-      offset.value = withSpring(0);
+      if (offset.value < HEIGH / 3) {
+        offset.value = withSpring(0);
+      } else {
+        offset.value = withTiming(HEIGH, {}, () => {
+          runOnJS(toggleSheet)();
+        });
      }
  });

  const translateY = useAnimatedStyle(() => ({
    transform: [{translateY: offset.value}],
  }));

  return (
    <GestureHandlerRootView style={styles.container}>
      <SafeAreaProvider>
        <View style={styles.contents}>
          <Button title="Open" onPress={toggleSheet} />
        </View>

        {isOpen && (
          <>
            <AnimatedPressable
              entering={FadeIn}
              exiting={FadeOut}
              style={styles.backdrop}
              onPress={toggleSheet}
            />
            <GestureDetector gesture={pan}>
              <Animated.View
                style={[styles.sheet, translateY]}
                entering={SlideInDown}
                exiting={SlideOutDown}>
                <Button title="Close" onPress={toggleSheet} />
              </Animated.View>
            </GestureDetector>
          </>
        )}
      </SafeAreaProvider>
    </GestureHandlerRootView>
  );
}

一定高さ以下になると、自動で閉じるようになりました。

忘れてはならないのはtoggleSheetの際にoffset.valueを0に戻しておく点です。
そうしないと、次に開いた時にoffset.valueが前の値を保持してしまうので、注意しましょう。

最後に

ここまで読んでいただきありがとうございました。
実はreact-native-bottom-sheetというライブラリを導入することで簡単に実装可能です。
動画中でも「本格的に使用する場合はライブラリの導入をお勧めします」と言っていました。

https://github.com/gorhom/react-native-bottom-sheet

とはいえ、簡単なBottom Sheetの場合にライブラリの力を借りたくないなーという場合には自作するのもありかもですね!
オトとりっぷのUIも、もっとリッチにしていきたいです。

もし犬専用の音楽アプリに興味を持っていただけたら、ぜひダウンロードしてみてください!

https://www.oto-trip.com/

Discussion