Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本) の次に、2 画面以上の UI を組み始めると早めに必要になるのが画面の切り替え方です。ここで止まりやすいのは Widget 名より、「ホームと設定は下タブにするのに、一覧から詳細へ進む流れまで同じ感覚で書き始める」ところにあります。この記事では、BottomNavigationBar TabBar Navigator を役割で整理し、複数画面をどう分けるかの最初の基準をまとめます。
1. ゴールと非対象
対象読者
- Flutter の環境構築、Dart 入門、基本レイアウト、一覧 UI、確認 UI の基本までは終わった人
BottomNavigationBarとTabBarを見たことはあるが、どちらを置くかまだ曖昧な人- 2 画面以上になったときに
Navigatorとタブの違いで止まりやすい人
この記事で到達する状態
BottomNavigationBarで主要セクションを切り替えられるTabBar/TabBarViewで同一画面内の分類を切り替えられるNavigatorが向く場面との違いを説明できる- 「画面が複数ある」だけでタブを増やさない判断基準を持てる
非対象
go_routerや named route の導入- Material 3 の
NavigationBarとの比較 - ネストした Navigator や高度な状態保持
- 認証導線、deep link、画面復元の設計
この記事で押さえたいのは、ルーティング全体ではありません。どの UI がどの粒度の切り替えに向くかを分けることです。ここが整理できると、後で REST API 通信や CRUD 画面へ進んだときも、画面構成の整理がしやすくなります。
2. 先に 3 つの役割を分ける
最初に、BottomNavigationBar TabBar Navigator の役割を分けます。
| UI | 役割 | まず見るポイント | よくある場面 |
|---|---|---|---|
BottomNavigationBar | アプリの主要セクションを切り替える | currentIndex / onTap | ホーム、履歴、設定 |
TabBar / TabBarView | 同じ画面の近い分類を切り替える | DefaultTabController / length | 受注、出荷、返品の分類 |
Navigator | 一覧から詳細へ進み、戻る流れを作る | push / pop | 商品一覧 -> 商品詳細 |
判断を先に図で置くと、次のようになります。
flowchart TD
A[切り替えたい対象は何か] --> B{アプリの主要セクションか}
B -->|はい| C[BottomNavigationBar]
B -->|いいえ| D{同じ画面の近い分類か}
D -->|はい| E[TabBar / TabBarView]
D -->|いいえ| F{次の画面へ進んで戻る流れか}
F -->|はい| G[Navigator]
F -->|いいえ| H[画面構成を再整理]
ここで見たいのは「画面数」ではなく「切り替えの関係」です。
- 主要導線を並列に行き来するなら
BottomNavigationBar - 同じ画面の近い分類を横に切り替えるなら
TabBar - 一覧から詳細、設定から編集のように前後へ進むなら
Navigator
以降のコード例はすべて lib/main.dart にそのまま貼って flutter run で確認できます。外部パッケージ不要のため、DartPad でも試せます。
3. BottomNavigationBar は主要セクションを切り替える
BottomNavigationBar が向くのは、アプリの中で頻繁に行き来する同格のセクションです。ホーム、履歴、設定のように「どれも入口になり得る画面」を切り替えるときに置きます。
次の lib/main.dart は、選択中の index を body と下部ナビゲーションで共有する最小構成です。どこで画面を切り替え、どこで選択状態を表示しているかを追うと読みやすくなります。
lib/main.dart は次の内容で作成します。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const BottomNavigationSamplePage(),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
);
}
}
class BottomNavigationSamplePage extends StatefulWidget {
const BottomNavigationSamplePage({super.key});
@override
State<BottomNavigationSamplePage> createState() =>
_BottomNavigationSamplePageState();
}
class _BottomNavigationSamplePageState
extends State<BottomNavigationSamplePage> {
int currentIndex = 0;
final pages = const [
_SectionView(
title: 'ホーム',
icon: Icons.home_outlined,
description: '今日の未処理件数や通知を最初に確認する画面です。',
items: ['未出荷 12 件', '保留 3 件', '注意アラート 1 件'],
),
_SectionView(
title: '在庫',
icon: Icons.inventory_2_outlined,
description: '棚卸や入出庫の状態を確認する画面です。',
items: ['入荷待ち 8 件', '引当待ち 5 件', '在庫差異 2 件'],
),
_SectionView(
title: '設定',
icon: Icons.settings_outlined,
description: '端末設定や利用者設定を確認する画面です。',
items: ['プリンタ接続', 'API 接続先', 'ログアウト'],
),
];
final titles = const ['ホーム', '在庫', '設定'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(titles[currentIndex])),
body: pages[currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
onTap: (index) {
setState(() {
currentIndex = index;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
label: 'ホーム',
),
BottomNavigationBarItem(
icon: Icon(Icons.inventory_2_outlined),
label: '在庫',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
label: '設定',
),
],
),
);
}
}
class _SectionView extends StatelessWidget {
const _SectionView({
required this.title,
required this.icon,
required this.description,
required this.items,
});
final String title;
final IconData icon;
final String description;
final List<String> items;
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 40),
const SizedBox(height: 12),
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(description),
],
),
),
),
const SizedBox(height: 16),
...items.map(
(item) => Card(
child: ListTile(
leading: const Icon(Icons.chevron_right),
title: Text(item),
),
),
),
],
);
}
}
コードのポイント
① index を 1 つ持ち、表示中の画面と下部ナビゲーションをそろえる
int currentIndex = 0;
body: pages[currentIndex],
currentIndex: currentIndex,
currentIndex を 1 つ持つだけで、body に出す画面と BottomNavigationBar の選択状態を同じ基準で切り替えられます。ここが分かれていると、見た目と内部状態がずれやすくなります。
② タップ時の更新は onTap から setState へ寄せる
onTap: (index) {
setState(() {
currentIndex = index;
});
},
タップで受け取った index をそのまま state に戻すだけなので、切り替えの入口が明確です。最初はロジックを増やさず、この1か所で選択変更が完結する形にしておくと追いやすくなります。
③ pages と BottomNavigationBarItem の数をそろえておく
final pages = const [
_SectionView(
title: 'ホーム',
icon: Icons.home_outlined,
description: '今日の未処理件数や通知を最初に確認する画面です。',
items: ['未出荷 12 件', '保留 3 件', '注意アラート 1 件'],
),
];
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
label: 'ホーム',
),
],
表示側と選択肢側の件数が一致している前提で pages[currentIndex] が成り立ちます。セクションを増減するときは、この2か所をセットで見る必要があります。
BottomNavigationBar が向くのは、アプリ全体で何度も往復する同格の入口です。商品一覧から商品詳細へ進む流れまでここへ置くと、「戻る」ではなく「別セクションへ飛ぶ」形になり、画面の関係が読みにくくなります。
4. TabBar / TabBarView は同一画面内の分類を切り替える
TabBar が向くのは、同じ文脈の中で近い内容を並べて見せたい場面です。受注、出荷、返品のように「どれも同じ一覧の分類」と言えるなら、画面を深く分けるよりタブで切り替えるほうが整理しやすくなります。
次の lib/main.dart は、1 画面の中で分類だけを切り替える構成です。DefaultTabController、TabBar、TabBarView の数がどう対応しているかを見ます。
lib/main.dart は次の内容で作成します。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const OrderTabsPage(),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
);
}
}
class OrderTabsPage extends StatelessWidget {
const OrderTabsPage({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('出荷管理'),
bottom: const TabBar(
tabs: [
Tab(text: '受注'),
Tab(text: '出荷'),
Tab(text: '返品'),
],
),
),
body: const TabBarView(
children: [
_StatusList(
title: '受注一覧',
color: Color(0xFFE3F2FD),
items: ['受注 #1001', '受注 #1002', '受注 #1003'],
),
_StatusList(
title: '出荷一覧',
color: Color(0xFFE8F5E9),
items: ['出荷 #2001', '出荷 #2002', '出荷 #2003'],
),
_StatusList(
title: '返品一覧',
color: Color(0xFFFFF3E0),
items: ['返品 #3001', '返品 #3002'],
),
],
),
),
);
}
}
class _StatusList extends StatelessWidget {
const _StatusList({
required this.title,
required this.color,
required this.items,
});
final String title;
final Color color;
final List<String> items;
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(16),
),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
),
const SizedBox(height: 16),
...items.map(
(item) => Card(
child: ListTile(
title: Text(item),
subtitle: const Text('同じ画面の分類を切り替えています'),
),
),
),
],
);
}
}
コードのポイント
① タブ数は DefaultTabController で先に固定する
return DefaultTabController(
length: 3,
child: Scaffold(
この length が TabBar と TabBarView 全体の基準になります。最初に件数を明示しておくと、あとからタブを追加したときの不整合に気づきやすくなります。
② TabBar と TabBarView は同じ順序でそろえる
bottom: const TabBar(
tabs: [
Tab(text: '受注'),
Tab(text: '出荷'),
Tab(text: '返品'),
],
),
body: const TabBarView(
children: [
_StatusList(
title: '受注一覧',
color: Color(0xFFE3F2FD),
items: ['受注 #1001', '受注 #1002', '受注 #1003'],
),
],
),
タブのラベル順と children の順がそのまま対応します。ここがずれると、見えているタブ名と実際の内容が食い違うため、同じ順序で並べるのが基本です。
③ 同一画面内の分類切り替えなので、各タブの部品は似た形でそろえる
class _StatusList extends StatelessWidget {
const _StatusList({
required this.title,
required this.color,
required this.items,
});
3 つのタブで使う見た目を _StatusList に寄せているため、違うのはタイトルや色、一覧データだけです。分類の切り替えに集中したい場面では、共通部品にして差分を減らすほうが読みやすくなります。
TabBar は「別の世界へ移る」UI ではなく、「同じ画面の見方を切り替える」UI です。アプリ全体のホーム、在庫、設定まで TabBar に載せると、主要導線がどこにあるか読みにくくなります。
5. Navigator との使い分け
ここまでの 2 つは「並列に切り替える UI」です。Navigator はそこが違います。一覧から詳細、設定から編集のように、次の画面へ進んで戻る流れを作るときに使います。
| 場面 | 向く UI | 理由 |
|---|---|---|
| ホーム、在庫、設定を行き来する | BottomNavigationBar | どれもアプリの主要入口で、同格に往復するから |
| 受注、出荷、返品を同じ画面で見比べる | TabBar | 同じ文脈の分類を切り替えるから |
| 商品一覧から商品詳細へ進む | Navigator | 前後の階層があり、戻る流れが自然だから |
| 設定画面から接続先編集へ進む | Navigator | 作業を終えたら前の画面へ戻る構造だから |
判断に迷ったときは、次の 2 つで見ると整理しやすくなります。
- 主要導線を横に並べて行き来したいなら
BottomNavigationBar - 同じ画面の近い分類を見比べたいなら
TabBar - 次の画面へ進み、戻る操作が自然なら
Navigator
たとえば「商品一覧」「商品詳細」の 2 画面があるからといって、下タブを 2 個置く必要はありません。この 2 つは同格ではなく、一覧を起点に詳細へ進む関係だからです。ここを整理しておくと、後で CRUD 画面やログイン後導線を追加するときの迷いが減ります。
6. 最初に詰まりやすい点
最後に、初学者が止まりやすい点を整理します。
BottomNavigationBarItem の数と body 側の数をそろえる
タブだけ 3 個あって pages が 2 個しかないと、index ずれで切り替えに失敗します。まずは items と pages を同じ順番、同じ件数でそろえます。
DefaultTabController.length を忘れない
TabBar と TabBarView を追加しても、DefaultTabController がないと切り替えを制御できません。length はタブ数と一致させます。
「2 画面あるからタブ」と考えない
画面数は判断基準の一部にすぎません。大事なのは、同格の切り替えなのか、次へ進む遷移なのかです。ここを先に決めると、Widget 選びで迷いにくくなります。
BottomNavigationBar は主要導線だけに絞る
詳細画面、編集画面、確認画面まで下タブへ載せると、戻る流れが崩れます。主要セクションだけを下タブに置き、その先は Navigator で進む形が基本です。
7. まとめ
BottomNavigationBar はアプリの主要セクションを切り替える UI、TabBar は同じ画面の分類を切り替える UI、Navigator は次の画面へ進んで戻る UI です。複数画面になったときは、まず「同格に行き来するのか」「前後に進むのか」を見てから選ぶと、画面構成が整理しやすくなります。
次は、Widget をどう分割して再利用するかを押さえると、画面数が増えても 1 ファイルへ詰め込みにくくなります。