Expo Router で Bottom Sheet を表示する
React Native で Bottom Sheet を表示するときは、以前は react-native-bottom-sheet を使っていたのですが、最近 Expo Router の機能で Bottom Sheet を表示できるようになりました。
Evan Bacon さんのポストを参考に、以下のように実装してみたので、手順を紹介します。

app/_layoout.tsx に Stack を作成する
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack, useRouter } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react';
import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/useColorScheme';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Ionicons } from '@expo/vector-icons';
import { TouchableOpacity } from 'react-native';
import { NativeStackHeaderRightProps } from '@react-navigation/native-stack/src/types';
import { useThemedColor } from '@/shared/hooks/useThemedColor';
import { translate } from '@/shared/i18n/i18n';
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const colorScheme = useColorScheme();
const router = useRouter();
const backgroundColor = useThemedColor('background.primary');
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
const handleAddFriendModalClose = (navigation: NativeStackHeaderRightProps) => {
if (navigation.canGoBack) {
router.back();
}
};
return (
<GestureHandlerRootView
style={{
flex: 1,
}}
>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="add-friend-modal"
options={{
contentStyle: {
flex: 1,
backgroundColor: backgroundColor,
},
presentation: 'formSheet',
gestureDirection: 'vertical',
animation: 'slide_from_bottom',
sheetGrabberVisible: false,
sheetInitialDetentIndex: 0,
sheetAllowedDetents: [1.0],
headerTitle: 'Title',
headerRight: (navigation) => (
<TouchableOpacity
onPress={() => handleAddFriendModalClose(navigation)}
style={{ padding: 8 }}
>
<Ionicons name="close" size={24} />
</TouchableOpacity>
),
gestureEnabled: false,
}}
/>
</Stack>
<StatusBar hidden={true} />
</ThemeProvider>
</GestureHandlerRootView>
);
}
追加したのは以下の部分です。
<Stack.Screen
name="add-friend-modal"
options={{
contentStyle: {
flex: 1,
backgroundColor: backgroundColor,
},
presentation: 'formSheet',
gestureDirection: 'vertical',
animation: 'slide_from_bottom',
sheetGrabberVisible: false,
sheetInitialDetentIndex: 0,
sheetAllowedDetents: [1.0],
headerTitle: 'ここにヘッダーのタイトルを設定する',
headerRight: (navigation) => (
<TouchableOpacity
onPress={() => handleAddFriendModalClose(navigation)}
style={{ padding: 8 }}
>
<Ionicons name="close" size={24} />
</TouchableOpacity>
),
gestureEnabled: false,
}}
/>
上の部分が、モーダルのページの設定となります。
name="add-friend-modal" で登録したのを覚えておきましょう。
sheetAllowedDetents: [1.0] の部分は、一気に100%上部まで開く設定です。
sheetAllowedDetents: [0.5, 1.0] のようにすると、半分開いて、引っ張ったらさらに上に動く、みたいな動きも設定できます。
gestureEnabled: false とすると、引っ張って閉じる動作を無効にできます。
ただし、LINEとかを見てもわかりますが、他のアプリを研究すると、引っ張って閉じる動作は有効であることの方が多いです。
ヘッダーアイコンタップでモーダルを開く設定
次に、モーダルを表示するための設定です。
app/(tabs)/_layout.tsx を開きます。
import { Stack, Tabs } from 'expo-router';
import React from 'react';
import { Platform, View, StyleSheet, TouchableOpacity } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HapticTab } from '@/components/HapticTab';
import TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
import { translate } from '@/shared/i18n/i18n';
import { FontAwesome5, Ionicons } from '@expo/vector-icons';
import { ThemedText } from '@/shared/components/ThemedText';
import { ThemedView } from '@/shared/components/ThemedView';
import { useThemedColor } from '@/shared/hooks/useThemedColor';
export default function TabLayout() {
const colorScheme = useColorScheme();
const insets = useSafeAreaInsets();
const textColor = useThemedColor('text.primary');
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
tabBarButton: HapticTab,
tabBarBackground: TabBarBackground,
tabBarStyle: Platform.select({
ios: {
// Use a transparent background on iOS to show the blur effect
position: 'absolute',
},
default: {},
}),
}}
>
<Tabs.Screen
name="index"
options={{
title: translate('bottomTab.label.home'),
tabBarIcon: ({ color }) => (
<Ionicons name="chatbox-ellipses-outline" size={24} color={color} />
),
}}
/>
<Tabs.Screen
name="friends"
options={{
title: translate('bottomTab.label.friends'),
tabBarIcon: ({ color }) => <FontAwesome5 name="user-friends" size={24} color={color} />,
headerShown: true, // ヘッダーを有効にする
header: ({ navigation }) => {
return (
<ThemedView
// `paddingTop: 60` ではなく、SafeAreaInsets 上部を足す
style={[
styles.header,
{ paddingTop: insets.top + 10, paddingBottom: 16 }, // 余白を適宜調整
]}
>
{/* 左側スペース */}
<ThemedView style={styles.headerLeft} />
{/* 中央タイトル */}
<ThemedView style={styles.titleContainer}>
<ThemedText style={styles.title}>{translate('header.friends.title')}</ThemedText>
</ThemedView>
{/* 右側追加ボタン */}
<ThemedView style={styles.headerRight}>
<TouchableOpacity
style={styles.addButton}
onPress={() => {
navigation.navigate('add-friend-modal');
}}
>
<Ionicons name="add" size={24} color={textColor} />
<ThemedText style={styles.addButtonText}>{translate('label.add')}</ThemedText>
</TouchableOpacity>
</ThemedView>
</ThemedView>
);
},
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
headerLeft: {
flex: 1,
},
titleContainer: {
flex: 2,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 18,
fontWeight: 'bold',
},
headerRight: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center',
},
addButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
addButtonText: {
fontSize: 16,
},
});
ポイントとなるのは以下の部分です。
{/* 右側追加ボタン */}
<ThemedView style={styles.headerRight}>
<TouchableOpacity
style={styles.addButton}
onPress={() => {
navigation.navigate('add-friend-modal');
}}
>
<Ionicons name="add" size={24} color={textColor} />
<ThemedText style={styles.addButtonText}>{translate('label.add')}</ThemedText>
</TouchableOpacity>
</ThemedView>
navigation.navigate('add-friend-modal'); で add-friend-modal を開いています。
これだけでアイコンタップでモーダルが開くようになります。
モーダルの中身のページ
app/add-friend-modal.tsx を作成します。
import { View, Text } from 'react-native';
export default function AddFriendModalScreen() {
// return <AddFriendModalPage />;
return (
<View>
<Text>これはサンプルのページです</Text>
</View>
);
}
これだけで BottomSheet が実装できました。
2024年に Expo Router が登場してから React Native でのモバイルアプリ開発が加速度的に便利になっています。パフォーマンス面でも特に困ることはなく、React の知識をそのまま使えるので、本当に助かっています。
Discussion