React NativeでBottom Sheetを作る方法
はじめに
こんにちは!
犬専用の音楽アプリ オトとりっぷでエンジニアしています、足立です!
この記事では、React Native Reanimatedを使ったBottom Sheetの作り方を紹介します。
元ネタは以下の動画です。動画内の指示通りに作業してBottom Sheetを作ってみます。
途中はどうでもいいから、結果だけ知りたいんだよ
っというせっかちさんのために、完成品をこちらに置いておきます。
コード類は長いので、基本的にトグル表示にしています。
App.tsx Final
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
をちょこっと書き換えます。
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
+ plugins: ['react-native-reanimated/plugin'],
};
最後にApp.tsx
を以下の通りに書き換えます。
App.tsx init
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
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
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
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
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というライブラリを導入することで簡単に実装可能です。
動画中でも「本格的に使用する場合はライブラリの導入をお勧めします」と言っていました。
とはいえ、簡単なBottom Sheetの場合にライブラリの力を借りたくないなーという場合には自作するのもありかもですね!
オトとりっぷのUIも、もっとリッチにしていきたいです。
もし犬専用の音楽アプリに興味を持っていただけたら、ぜひダウンロードしてみてください!
Discussion