概要
ボトムタブバーは既に動いている。そこにハンバーガーメニュー(タブの上にスライドして現れるドロワー):ワークスペース切替、設定、サポートへのリンクが入ったモックが降ってきた。つまり、ドロワーとタブという二つのナビゲーションパターンを同時に持ち、しかもドロワーのスワイプが画像カルーセルと競合せず、メニューを開くたびにタブの状態がリセットされないようにする必要がある。
永続的なボトムタブとグローバルなサイドバー(ドロワー)を組み合わせるパターンはクロスプラットフォームアプリでよく使われる。しかしナビゲーションツリーを雑にネストすると、パフォーマンスのオーバーヘッドやジェスチャーの競合を招く。Expo Routerでは、これらのフローをファイルシステム上のディレクトリに直接マッピングできるため、コードはモジュール化され保守しやすく、ネイティブのナビゲーションコンポーネントが下で処理をしてくれる。
この投稿を読み終える頃には、ドロワーでラップされたタブナビゲーターと、全体を「ジャンク(ぎこちなく)感じさせない」ためのテーマとジェスチャー処理を備えた実装が得られるはずだ。
アーキテクチャ適合:いつドロワーとタブをネストすべきか
ネストしたナビゲーションは強力だが、デフォルトで使うべきではない。コードを書く前に、アプリのUXが二重ナビゲーション階層から実際に恩恵を受けるかを検討する。大まかに3つのケース:
- フラットなナビゲーション(高レベル画面が3〜5で経路が独立している):純粋なボトムタブを使う。サイドドロワーは認知負荷と視覚的ノイズを増やすだけ。
- コンテキスト隔離(マルチタブのワークスペースで、組織切替やサポートなどの常時利用ユーティリティが必要):タブナビゲーターを外側のドロワーコンテナにネストする。
- 深いリンクワークフロー(トランザクション中心のダッシュボードなどでユーザーが深いサブルートを行き来する):ネイティブのスタックナビゲーションとコンテキスト駆動の表示切替を検討する。
アーキテクチャのトレードオフと制約
ファイルシステムによる明確なレイアウトは構造的な複雑さを和らげるが、いくつか注意点がある:
- 状態同期:独立したドロワールート(例:settings.tsx)からネストされたタブコンテキストへグローバル状態やアクティブユーザー情報を渡すには、意図的なコンテキストスコーピング、もしくは外部ストアが必要。
- プラットフォームのジェスチャー競合:サイドスワイプによるドロワーのジェスチャーは、タブ内の水平要素(スワイプ可能なカルーセルやスワイプで削除するリスト)と競合する。swipeEdgeWidthを制限してこれらのインタラクションを予測可能にする。
コアアーキテクチャ
ナビゲーション状態を統一するため、プロジェクトのディレクトリはレイアウトツリーを反映する。集中化したルーティング設定ファイルの代わりに、Expo Routerは( drawer )や( tabs )のようなグループを使ってビュー階層を宣言できる。
以下はファイルツリー例:
app/
├── (drawer)/
│ ├── _layout.tsx
│ ├── (tabs)/
│ │ ├── _layout.tsx
│ │ ├── index.tsx
│ │ ├── explore.tsx
│ │ ├── notifications.tsx
│ │ └── profile.tsx
│ └── settings.tsx
└── _layout.tsx
1. ルートオーケストレーション (app/_layout.tsx)
ルートファイルはアプリのライフサイクルが始まる場所。ネイティブのスプラッシュスクリーン管理とグローバルなコンテキストプロバイダーをセットアップし、子ルートがレンダリングされる前にランタイムの設定を確実に行う。
import 'react-native-gesture-handler';
import React, { useState } from 'react';
import { Slot } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { StyleSheet } from 'react-native';
import * as ExpoSplashScreen from 'expo-splash-screen';
import { ThemeProvider } from '../src/context/ThemeContext';
import SplashScreenView from '../src/screens/SplashScreen';
ExpoSplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [appIsReady, setAppIsReady] = useState(false);
const handleSplashFinish = async () => {
setAppIsReady(true);
await ExpoSplashScreen.hideAsync();
};
return (
<GestureHandlerRootView style={styles.root}>
<SafeAreaProvider>
<ThemeProvider>
{/* Render layout contents once splash sequencing is complete */}
{appIsReady && <Slot />}
<SplashScreenView onFinish={handleSplashFinish} />
</ThemeProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
root: { flex: 1 },
});
2. グローバルサイドドロワー実装 (app/(drawer)/_layout.tsx)
サイドドロワーは最外層のレイアウト境界。expo-router/drawerモジュールを使って設定する。drawerTypeを'front'にすると、ドロワーパネルがアクティブな画面上にオーバーレイされ、画面を押し出さない動作になる。
import React from 'react';
import { Drawer } from 'expo-router/drawer';
import { useTheme } from '@/src/hooks/useTheme';
import DrawerContent from '@/src/components/DrawerContent';
export default function DrawerLayout() {
const { theme } = useTheme();
const c = theme.colors;
return (
<Drawer
drawerContent={(props) => <DrawerContent {...props} />}
screenOptions={{
headerShown: false,
drawerType: 'front',
drawerStyle: {
width: 270,
backgroundColor: c.drawerBackground,
},
overlayColor: 'rgba(0,0,0,0.6)',
swipeEdgeWidth: 40,
}}
>
{/* Target internal tab group layout matching file path semantics */}
<Drawer.Screen name="(tabs)" options={{ drawerLabel: 'Dashboard' }} />
{/* Standalone layout entry outside of the core bottom tab bar */}
<Drawer.Screen name="settings" options={{ drawerLabel: 'Settings' }} />
</Drawer>
);
}
3. ネストされたボトムタブレイアウト (app/(drawer)/(tabs)/_layout.tsx)
タブレイアウトフォルダを (drawer) パス内にネストすると、子ルートは自動的にドロワーコンテキストを継承する。マイクロインタラクションはカスタムコンポーネントに閉じ込め、レイアウトファイルはルーティングに集中させる。
import React from 'react';
import { Tabs } from 'expo-router';
import { Platform } from 'react-native';
import { useTheme } from '@/src/hooks/useTheme';
import { DrawerSceneWrapper } from '@/src/components/DrawerSceneWrapper';
import AnimatedTabIcon from '@/src/components/AnimatedTabIcon';
const TABS = [
{ name: 'index', title: 'Home', icon: 'home-outline', activeIcon: 'home' },
{ name: 'explore', title: 'Explore', icon: 'compass-outline', activeIcon: 'compass' },
{ name: 'notifications', title: 'Notifications', icon: 'notifications-outline', activeIcon: 'notifications', badge: true },
{ name: 'profile', title: 'Profile', icon: 'person-outline', activeIcon: 'person' },
];
export default function TabsLayout() {
const { theme, isDark } = useTheme();
const c = theme.colors;
const tabBarBg = isDark ? 'rgba(11,11,19,0.98)' : 'rgba(255,255,255,0.98)';
const tabBarBorder = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)';
return (
<DrawerSceneWrapper>
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: tabBarBg,
borderTopWidth: 0.5,
borderTopColor: tabBarBorder,
height: Platform.OS === 'ios' ? 84 : 66,
paddingBottom: Platform.OS === 'ios' ? 24 : 8,
paddingTop: 10,
elevation: 0,
shadowColor: isDark ? '#000' : '#6060aa',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: isDark ? 0.3 : 0.06,
shadowRadius: 12,
},
tabBarActiveTintColor: c.tabBarActive,
tabBarInactiveTintColor: c.tabBarInactive,
tabBarLabelStyle: {
fontSize: 10,
fontWeight: '400',
marginTop: 0,
letterSpacing: 0.1,
},
}}
>
{TABS.map((tab) => (
<Tabs.Screen
key={tab.name}
name={tab.name}
options={{
title: tab.title,
tabBarIcon: ({ focused, color }) => (
<AnimatedTabIcon focused={focused} icon={tab.icon} activeIcon={tab.activeIcon} badge={tab.badge} color={color} activeColor={c.tabBarActive} />
),
}}
/>
))}
</Tabs>
</DrawerSceneWrapper>
);
}
4. UIレイヤーのマイクロインタラクション (src/components/AnimatedTabIcon.tsx)
アニメーションは必要な箇所だけで実行する。React Native Reanimatedのshared valuesを使えば、transformやopacityの変更はUIスレッド上で走る。マイクロインタラクションを分離することで、タブのアニメーションが親レイアウト全体の再レンダリングを誘発しないようにする。
import React, { useEffect } from 'react'
import { StyleSheet, View } from 'react-native'
import { Ionicons } from '@expo/vector-icons'
import Animated, { useSharedValue, useAnimatedStyle, withTiming, withSequence, Easing, } from 'react-native-reanimated'
interface TabIconProps {
focused: boolean
icon: string
activeIcon: string
badge?: boolean
color: string
activeColor: string
}
export default function AnimatedTabIcon({ focused, icon, activeIcon, badge, color, activeColor, }: TabIconProps) {
const scale = useSharedValue(1)
const dotOpacity = useSharedValue(focused ? 1 : 0)
const dotScale = useSharedValue(focused ? 1 : 0)
useEffect(() => {
const ease = { duration: 200, easing: Easing.out(Easing.cubic) }
if (focused) {
scale.value = withSequence(
withTiming(1.15, { duration: 100, easing: Easing.out(Easing.quad) }),
withTiming(1, { duration: 150, easing: Easing.out(Easing.cubic) }),
)
dotOpacity.value = withTiming(1, ease)
dotScale.value = withTiming(1, ease)
} else {
scale.value = withTiming(1, { duration: 160, easing: Easing.out(Easing.cubic) })
dotOpacity.value = withTiming(0, { duration: 150, easing: Easing.out(Easing.quad) })
dotScale.value = withTiming(0, { duration: 150, easing: Easing.out(Easing.quad) })
}
}, [focused])
const iconStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], }))
const dotStyle = useAnimatedStyle(() => ({ opacity: dotOpacity.value, transform: [{ scale: dotScale.value }], }))
return (
<View style={styles.iconWrap}>
<Animated.View style={iconStyle}>
<Ionicons name={(focused ? activeIcon : icon) as any} size={22} color={color} />
</Animated.View>
<Animated.View style={[styles.activeDot, dotStyle, { backgroundColor: activeColor }]} />
{badge && !focused && <View style={[styles.badgeDot, { borderColor: 'transparent' }]} />}
</View>
)
}
const styles = StyleSheet.create({
iconWrap: { width: 44, height: 28, alignItems: 'center', justifyContent: 'center', gap: 4, },
activeDot: { width: 4, height: 4, borderRadius: 2, },
badgeDot: { position: 'absolute', top: 0, right: 4, width: 6, height: 6, borderRadius: 3, backgroundColor: '
})
5. スケーラブルなテーマアーキテクチャ:未スタイル表示のフラッシュをなくす
ネスト境界にまたがってテーマ変更を適用し、未スタイルのチラつきを防ぐためにデザイントークンを使う。プリミティブ値をセマンティックなテーマ型にマッピングすることで、コンポーネント、ナビゲーションスタイル、レイアウトが同じスキーマから同期的に値を取得する。
export const palette = {
indigo50: '#eef2ff',
indigo100: '#e0e7ff',
indigo400: '#818cf8',
indigo500: '#6366f1',
indigo600: '#4f46e5',
indigo700: '#4338ca',
neutral50: '#fafafa',
neutral900: '#171717',
white: '#ffffff',
black: '#000000',
};
export type Theme = typeof lightTheme;
export const lightTheme = {
dark: false,
colors: {
background: '#f8f8fc',
surface: '#ffffff',
primary: palette.indigo500,
tabBarActive: palette.indigo500,
tabBarInactive: '#b0b0c8',
drawerBackground: '#ffffff',
text: '#1a1a2e',
statusBar: 'dark-content' as 'dark-content' | 'light-content',
},
};
export const darkTheme: Theme = {
dark: true,
colors: {
background: '#0f0f1a',
surface: '#1a1a2e',
primary: palette.indigo400,
tabBarActive: palette.indigo400,
tabBarInactive: '#4a4a6a',
drawerBackground: '#13131f',
text: '#eeeeff',
statusBar: 'light-content' as 'dark-content' | 'light-content',
},
};
まとめと参考ドキュメント
このようにナビゲーションをネストする設計は規律の問題が大きい。ルーティング設定をファイルシステムに押し込むことで、レイアウトは規模に応じてクリーンに拡張でき、巨大な設定ファイルを管理する負担を避けられる。
さらに詳しい設定やスタイリングオプションは公式ドキュメントを参照すること:
- Expo Routerのディレクトリグルーピング:ルートグループがどのようにネストされるか(Expo Router introduction)
- ドロワーのカスタマイズ:Expo Router drawer guide内のオプション参照
- React Navigation tab bar options:タブバーのオプションとカスタマイズ
これらの原則を守ることで、ドロワーを持つ永続タブバーを遅延やジェスチャー競合なしに提供できるはずだ。