OpenAIExpoJul 11, 2026, 2:30 PM

Building balanced mobile layouts: nesting tabs and drawers with Expo Router

A condensed section focused on the key takeaways first.

Original Post

Quick Digest

Summary

A condensed section focused on the key takeaways first.

openaienmodel: gpt-5-mini-2025-08-07

Balanced mobile layouts: nesting drawer and tab navigation with Expo Router

Key Points

  • Wrap tabs with an outer (drawer) route group
  • Limit swipeEdgeWidth to avoid gesture conflicts
  • Use design tokens to prevent FOUC on theme switch

Summary

This post demonstrates a practical pattern for combining a global side drawer with a persistent bottom tab bar using Expo Router's file-system routing. The recommended architecture places the drawer as an outer layout group and the tabs inside it (e.g. app/(drawer)/(tabs)/_layout.tsx), letting native navigation components manage state and animation while keeping your code modular and file-driven.

Key Points

  • Directory-first routing: mirror your UI tree in the app folder ((drawer) wraps (tabs)); avoid a central routing file so nested state stays unified.
  • Root setup: render GestureHandlerRootView, SafeAreaProvider, and your ThemeProvider in app/_layout.tsx; sequence the splash screen to prevent flashes before theme and state are ready.
  • Drawer config: use drawerType: 'front' to overlay content, set swipeEdgeWidth (e.g. 40) to reduce swipe conflicts, define drawerStyle.width and overlayColor for consistent visuals.
  • Tabs config: keep tab-specific UI in app/(drawer)/(tabs)/_layout.tsx, tune tabBarStyle (height, padding, shadow), and supply icons via lightweight components to minimize re-renders.
  • Gesture handling: restrict drawer swipe edge and prefer programmatic open/close for areas with horizontal gestures (carousels, swipe-to-delete) to avoid conflicts.
  • State sharing: if a drawer route needs to pass shared state into nested tabs, use scoped context providers placed above both layouts or an external store (e.g. Zustand/Context + careful scoping) to avoid prop drilling and reset bugs.
  • Micro-interactions: keep animations on the UI thread (React Native Reanimated shared values) so tab icon animations don't force parent re-renders.
  • Theming: use design tokens and a synchronous theme provider to eliminate flashes of unstyled content when the app boots or switches theme modes.

Quick checklist for implementation

  • Map folder groups: app/(drawer)/app/(drawer)/(tabs)/ → screen files
  • Initialize gesture handler + safe area + theme at root and hide the splash until ready
  • Configure drawer with drawerType: 'front', swipeEdgeWidth, and a custom drawerContent
  • Configure Tabs with a dedicated _layout.tsx and lightweight animated icons using Reanimated
  • Use scoped context or external store for shared global state (settings, workspace, user)
  • Test horizontal gestures inside tabs and reduce swipeEdgeWidth or disable edge swipe where necessary

Resources

  • Use the Expo Router docs for route groups and drawer options, and React Navigation docs for advanced tab customization.

Full Translation

Translations

A translation section that keeps the flow of the original article.

openaijamodel: gpt-5-mini-2025-08-07

均衡の取れたモバイルレイアウトを構築する:Expo Routerでタブとドロワーをネストする

概要

ボトムタブバーは既に動いている。そこにハンバーガーメニュー(タブの上にスライドして現れるドロワー):ワークスペース切替、設定、サポートへのリンクが入ったモックが降ってきた。つまり、ドロワーとタブという二つのナビゲーションパターンを同時に持ち、しかもドロワーのスワイプが画像カルーセルと競合せず、メニューを開くたびにタブの状態がリセットされないようにする必要がある。

永続的なボトムタブとグローバルなサイドバー(ドロワー)を組み合わせるパターンはクロスプラットフォームアプリでよく使われる。しかしナビゲーションツリーを雑にネストすると、パフォーマンスのオーバーヘッドやジェスチャーの競合を招く。Expo Routerでは、これらのフローをファイルシステム上のディレクトリに直接マッピングできるため、コードはモジュール化され保守しやすく、ネイティブのナビゲーションコンポーネントが下で処理をしてくれる。

この投稿を読み終える頃には、ドロワーでラップされたタブナビゲーターと、全体を「ジャンク(ぎこちなく)感じさせない」ためのテーマとジェスチャー処理を備えた実装が得られるはずだ。


アーキテクチャ適合:いつドロワーとタブをネストすべきか

ネストしたナビゲーションは強力だが、デフォルトで使うべきではない。コードを書く前に、アプリのUXが二重ナビゲーション階層から実際に恩恵を受けるかを検討する。大まかに3つのケース:

  • フラットなナビゲーション(高レベル画面が3〜5で経路が独立している):純粋なボトムタブを使う。サイドドロワーは認知負荷と視覚的ノイズを増やすだけ。
  • コンテキスト隔離(マルチタブのワークスペースで、組織切替やサポートなどの常時利用ユーティリティが必要):タブナビゲーターを外側のドロワーコンテナにネストする。
  • 深いリンクワークフロー(トランザクション中心のダッシュボードなどでユーザーが深いサブルートを行き来する):ネイティブのスタックナビゲーションとコンテキスト駆動の表示切替を検討する。

アーキテクチャのトレードオフと制約

ファイルシステムによる明確なレイアウトは構造的な複雑さを和らげるが、いくつか注意点がある:

  • 状態同期:独立したドロワールート(例:settings.tsx)からネストされたタブコンテキストへグローバル状態やアクティブユーザー情報を渡すには、意図的なコンテキストスコーピング、もしくは外部ストアが必要。
  • プラットフォームのジェスチャー競合:サイドスワイプによるドロワーのジェスチャーは、タブ内の水平要素(スワイプ可能なカルーセルやスワイプで削除するリスト)と競合する。swipeEdgeWidthを制限してこれらのインタラクションを予測可能にする。

コアアーキテクチャ

ナビゲーション状態を統一するため、プロジェクトのディレクトリはレイアウトツリーを反映する。集中化したルーティング設定ファイルの代わりに、Expo Routerは( drawer )や( tabs )のようなグループを使ってビュー階層を宣言できる。

以下はファイルツリー例:

app/
├── (drawer)/
│   ├── _layout.tsx    # Outermost navigation wrapper (configures side drawer)
│   ├── (tabs)/
│   │   ├── _layout.tsx  # Inner layout (configures persistent bottom tabs)
│   │   ├── index.tsx    # Home dashboard screen
│   │   ├── explore.tsx  # Explore feed screen
│   │   ├── notifications.tsx
│   │   └── profile.tsx  # User profile screen
│   └── settings.tsx     # Standalone global route accessed via drawer
└── _layout.tsx          # Root orchestration and app lifecycle entry point

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';

// Prevent splash screen from auto-hiding before initialization checks finish
ExpoSplashScreen.preventAutoHideAsync();

export default function RootLayout() {
  const [appIsReady, setAppIsReady] = useState(false);

  const handleSplashFinish = async () => {
    setAppIsReady(true);
    // Securely unblock rendering and hide native splash once app asset states are verified
    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: '#f43f5e', borderWidth: 1, },
});

5. スケーラブルなテーマアーキテクチャ:未スタイル表示のフラッシュをなくす

ネスト境界にまたがってテーマ変更を適用し、未スタイルのチラつきを防ぐためにデザイントークンを使う。プリミティブ値をセマンティックなテーマ型にマッピングすることで、コンポーネント、ナビゲーションスタイル、レイアウトが同じスキーマから同期的に値を取得する。

// src/theme/colors.ts
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:タブバーのオプションとカスタマイズ

これらの原則を守ることで、ドロワーを持つ永続タブバーを遅延やジェスチャー競合なしに提供できるはずだ。