🦔

Expo Router で Bottom Sheet を表示する

に公開

React Native で Bottom Sheet を表示するときは、以前は react-native-bottom-sheet を使っていたのですが、最近 Expo Router の機能で Bottom Sheet を表示できるようになりました。

Evan Bacon さんのポストを参考に、以下のように実装してみたので、手順を紹介します。

app/_layoout.tsx に Stack を作成する

app/_layout.tsx
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 を開きます。

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 を作成します。

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