【画面切り替え】BottomNavigationBarでHome・Clips・Search・My Netaflixを切り替える
この節で学ぶこと
前の節では、movies リストに作品データを追加する方法を学びました。
今回の節では、アプリ下部にあるメニューを使って、画面を切り替える仕組みを見ていきます。
今回のアプリには、下の4つのメイン画面があります。
Home
Clips
Search
My Netaflix
スマホアプリでは、このような下部メニューをよく見かけます。
たとえば、動画アプリ、SNS、ニュースアプリ、ショッピングアプリなどでも、画面の下にメニューがあり、タップすると画面が切り替わります。
今回のアプリでは、それを BottomNavigationBar 風のUIとして自作しています。
正確にはFlutter標準の BottomNavigationBar ウィジェットをそのまま使うのではなく、Container、Row、GestureDetector などを使って、NETFLIX風に見える下部ナビゲーションを作っています。
この節では、次の流れで学びます。
現在選ばれているタブ番号を管理する
↓
番号に合わせて表示する画面を変える
↓
下部ナビゲーションを作る
↓
タップされたら番号を変える
↓
画面の変化を見る
まず完成形を見てみよう
今回の画面切り替えは、次の2つのクラスで作られています。
NetflixShellPage
NetflixBottomNav
役割はこのようになります。
| クラス | 役割 |
|---|---|
NetflixShellPage | どの画面を表示するかを管理する |
NetflixBottomNav | 下部ナビゲーションの見た目とタップ処理を担当する |
つまり、全体の流れはこうです。
NetflixShellPage
↓
現在のselectedIndexを見る
↓
Home / Clips / Search / My Netaflix のどれかを表示
↓
下部にNetflixBottomNavを表示
↓
タップされたらselectedIndexを変える
↓
画面が切り替わる
少しずつ見ていきましょう。
NetflixShellPageとは?
NetflixShellPage は、アプリのメイン画面を包んでいる大きな箱のようなものです。
コードはこちらです。
class NetflixShellPage extends StatefulWidget {
const NetflixShellPage({super.key});
@override
State<NetflixShellPage> createState() => _NetflixShellPageState();
}
ここでは StatefulWidget を使っています。
なぜなら、下部ナビゲーションをタップすると、表示する画面が変わるからです。
つまり、状態が変わります。
その状態を持っているのが、次の selectedIndex です。
selectedIndexで現在のタブを管理する
次のコードを見てください。
class _NetflixShellPageState extends State<NetflixShellPage> {
int selectedIndex = 0;
selectedIndex は、現在選ばれているタブの番号です。
今回のアプリでは、次のように考えます。
| selectedIndex | 表示する画面 |
|---|---|
0 | Home |
1 | Clips |
2 | Search |
3 | My Netaflix |
最初は、
int selectedIndex = 0;
なので、アプリを開いたときはHome画面が表示されます。
番号で画面を管理するのは、最初は少し地味に見えます。
でも、アプリ開発ではとてもよく使う考え方です。
表示する画面をリストにまとめる
次に、表示する画面をまとめている部分を見てみましょう。
final pages = [
const NetflixHomePage(),
const ClipsPage(),
const SearchPage(),
const MyNetflixPage(),
];
ここでは、4つの画面を pages というリストに入れています。
イメージとしては、こうです。
pages[0] = Home画面
pages[1] = Clips画面
pages[2] = Search画面
pages[3] = My Netaflix画面
そして、次のコードで現在の画面を表示しています。
body: pages[selectedIndex],
たとえば、selectedIndex が 0 なら、
body: pages[0],
なので、Home画面が表示されます。
selectedIndex が 2 なら、
body: pages[2],
なので、Search画面が表示されます。
Scaffoldで画面全体を作る
NetflixShellPage の中では、Scaffold を使っています。
return Scaffold(
backgroundColor: NetflixColors.black,
body: pages[selectedIndex],
bottomNavigationBar: NetflixBottomNav(
selectedIndex: selectedIndex,
onChanged: (index) {
setState(() {
selectedIndex = index;
});
},
),
);
ここでは、大きく2つの場所を作っています。
| 場所 | 内容 |
|---|---|
body | 現在選ばれている画面 |
bottomNavigationBar | 下部ナビゲーション |
つまり、画面の上側にはHomeやSearchなどのページが入り、画面の下側には常に下部メニューが表示されます。
bottomNavigationBarとは?
bottomNavigationBar は、Scaffold の下部に表示するUIを置く場所です。
今回のコードでは、そこに自作の NetflixBottomNav を入れています。
bottomNavigationBar: NetflixBottomNav(
selectedIndex: selectedIndex,
onChanged: (index) {
setState(() {
selectedIndex = index;
});
},
),
ここで大切なのは、selectedIndex と onChanged を渡していることです。
| 渡しているもの | 役割 |
|---|---|
selectedIndex | 今どのタブが選ばれているかを伝える |
onChanged | タブが押されたときに実行する処理を渡す |
NetflixBottomNav は見た目を作るだけではなく、タップされたときに親の selectedIndex を変えるために onChanged を使います。
setStateで画面を更新する
タブが押されたときは、次のコードが動きます。
onChanged: (index) {
setState(() {
selectedIndex = index;
});
},
setState は、画面を更新するための大切な処理です。
ここでは、
タップされたタブ番号をselectedIndexに入れる
↓
setStateで画面を更新する
↓
body: pages[selectedIndex] が切り替わる
という流れになります。
たとえば、Searchタブをタップすると、index は 2 になります。
すると、
selectedIndex = 2;
となり、pages[2] のSearch画面が表示されます。
NetflixBottomNavを見てみよう
次に、下部ナビゲーション本体のコードを見ていきます。
class NetflixBottomNav extends StatelessWidget {
const NetflixBottomNav({
super.key,
required this.selectedIndex,
required this.onChanged,
});
final int selectedIndex;
final ValueChanged<int> onChanged;
ここでは、2つの値を受け取っています。
| 値 | 内容 |
|---|---|
selectedIndex | 現在選ばれているタブ番号 |
onChanged | タブが押されたときに呼ぶ関数 |
NetflixBottomNav 自体は StatelessWidget です。
なぜなら、タブの状態そのものは NetflixShellPage が持っているからです。
NetflixBottomNav は、受け取った selectedIndex をもとに見た目を変えるだけです。
ValueChangedとは?
この部分を見てください。
final ValueChanged<int> onChanged;
少し難しく見えますが、ざっくり言うと、
int型の値を受け取る関数
です。
今回の場合は、タップされたタブの番号を受け取ります。
たとえば、
onChanged(0);
ならHome。
onChanged(2);
ならSearch。
このように、タップされた番号を親に伝えるために使っています。
BottomNavEntryでタブ情報をまとめる
次に、下部メニューの項目を作っている部分です。
final items = [
const BottomNavEntry(icon: Icons.home_filled, label: 'Home'),
const BottomNavEntry(
icon: Icons.play_circle_fill_rounded,
label: 'Clips',
),
const BottomNavEntry(icon: Icons.search_rounded, label: 'Search'),
const BottomNavEntry(icon: Icons.person_rounded, label: 'My Netaflix'),
];
ここでは、下部ナビゲーションの項目をリストで管理しています。
| 順番 | アイコン | ラベル |
|---|---|---|
| 0 | Homeアイコン | Home |
| 1 | 再生アイコン | Clips |
| 2 | 検索アイコン | Search |
| 3 | 人アイコン | My Netaflix |
この順番はとても大切です。
pages の順番と items の順番が対応しているからです。
pages[0] = NetflixHomePage
items[0] = Home
pages[1] = ClipsPage
items[1] = Clips
pages[2] = SearchPage
items[2] = Search
pages[3] = MyNetflixPage
items[3] = My Netaflix
この順番がずれると、Searchを押したのに別の画面が出る、ということが起きます。
BottomNavEntryとは?
BottomNavEntry は、下部メニュー1つ分の情報を持つクラスです。
class BottomNavEntry {
const BottomNavEntry({
required this.icon,
required this.label,
});
final IconData icon;
final String label;
}
中身はとてもシンプルです。
| 項目 | 内容 |
|---|---|
icon | 表示するアイコン |
label | 表示する文字 |
これを使うことで、下部メニューの項目をきれいに管理できます。
毎回アイコンと文字をバラバラに書くよりも、まとめておく方が見やすくなります。
下部ナビゲーションの見た目を作る
NetflixBottomNav の見た目は、次の Container で作っています。
return Container(
height: 66 + MediaQuery.of(context).padding.bottom,
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
decoration: const BoxDecoration(
color: Color(0xF0000000),
border: Border(
top: BorderSide(color: Color(0xFF151515), width: 0.8),
),
),
child: Row(
children: List.generate(items.length, (index) {
...
}),
),
);
ここでは、下部ナビゲーションの高さ、背景色、上の境界線を決めています。
MediaQueryで下の余白に対応する
この部分を見てください。
height: 66 + MediaQuery.of(context).padding.bottom,
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
スマホによっては、画面の下にホームインジケーターがあります。
iPhoneの下に出る細いバーのような部分です。
その上にメニューが重なると、タップしづらくなります。
そこで、MediaQuery.of(context).padding.bottom を使って、端末ごとの下余白を足しています。
これにより、iPhoneでもAndroidでも、下部メニューが見やすくなります。
Rowでタブを横に並べる
下部メニューは、横並びです。
そのため、Row を使っています。
child: Row(
children: List.generate(items.length, (index) {
...
}),
),
Row は、左から右に並べるWidgetです。
今回の場合は、4つのタブを横に並べています。
Home | Clips | Search | My Netaflix
List.generateで項目を作る
下部ナビゲーションでは、List.generate を使っています。
children: List.generate(items.length, (index) {
final item = items[index];
final selected = selectedIndex == index;
return Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(index),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
item.icon,
color: selected ? NetflixColors.white : NetflixColors.muted,
size: 24,
),
const SizedBox(height: 4),
Text(
item.label,
style: TextStyle(
color: selected ? NetflixColors.white : NetflixColors.muted,
fontSize: 10.5,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
),
),
],
),
),
);
}),
これは、items の数だけタブを自動で作っています。
今回 items.length は4なので、4つのタブが作られます。
selectedで選択中の見た目を変える
この部分を見てください。
final selected = selectedIndex == index;
これは、今作っているタブが選択中かどうかを判断しています。
たとえば、selectedIndex が 2 のとき、Searchタブの index も 2 なので、
selectedIndex == index
は true になります。
その結果、Searchタブは白く表示されます。
一方、他のタブは false なのでグレーになります。
三項演算子で色を切り替える
選択中かどうかで、アイコンの色を変えているのがこの部分です。
color: selected ? NetflixColors.white : NetflixColors.muted,
これは、次の意味です。
selectedがtrueなら白
selectedがfalseならグレー
文字の色も同じように切り替えています。
color: selected ? NetflixColors.white : NetflixColors.muted,
このように、選択中のタブだけ白くすると、今どの画面にいるのか分かりやすくなります。
GestureDetectorでタップできるようにする
タブをタップできるようにしているのが、GestureDetector です。
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(index),
child: Column(
...
),
)
onTap は、タップされたときに実行される処理です。
ここでは、
onChanged(index)
を呼び出しています。
つまり、タップされたタブの番号を親に伝えています。
親である NetflixShellPage は、その番号を受け取って selectedIndex を更新します。
Expandedでタブ幅を均等にする
各タブは、Expanded で包まれています。
return Expanded(
child: GestureDetector(
...
),
);
Expanded を使うと、横幅を均等に分けてくれます。
今回の場合、4つのタブがあるので、画面幅を4等分して表示します。
| Home | Clips | Search | My Netaflix |
これにより、どのタブも同じくらいタップしやすくなります。
全体の流れをもう一度整理しよう
ここまでの画面切り替えの流れをまとめると、こうなります。
1. アプリ起動時、selectedIndexは0
2. pages[0] のHome画面が表示される
3. 下部ナビゲーションのSearchをタップする
4. onChanged(2) が呼ばれる
5. setStateで selectedIndex = 2 になる
6. bodyが pages[2] に変わる
7. Search画面が表示される
8. Searchタブだけ白く表示される
この流れが分かれば、BottomNavigationBarの基本はかなりつかめています。
まずカスタマイズしてみよう
今回は、下部ナビゲーションのラベルを変えてみます。
次のコードを探してください。
const BottomNavEntry(icon: Icons.person_rounded, label: 'My Netaflix'),
これを次のように変えてみましょう。
const BottomNavEntry(icon: Icons.person_rounded, label: 'My Page'),
保存して、画面を確認してください。
下部メニューの右端が My Page に変わっていれば成功です。
まずは、こういう小さなカスタマイズから慣れていきましょう。
アイコンもカスタマイズしてみよう
次に、Searchタブのアイコンを変えてみます。
変更前はこちらです。
const BottomNavEntry(icon: Icons.search_rounded, label: 'Search'),
たとえば、次のように変えてみます。
const BottomNavEntry(icon: Icons.manage_search_rounded, label: 'Search'),
保存して、Searchタブのアイコンが変わるか確認してください。
Flutterには、最初からたくさんのアイコンが用意されています。
Icons. のあとにいろいろな名前を入れることで、アイコンを変えることができます。
最初に表示する画面を変えてみよう
今は、アプリを開いたあとにHome画面が表示されます。
それは、次のように selectedIndex が 0 だからです。
int selectedIndex = 0;
もし最初にSearch画面を表示したい場合は、次のようにします。
int selectedIndex = 2;
これで、アプリ起動後にSearch画面が最初に表示されます。
ただし、普通の動画アプリではHome画面から始まる方が自然なので、基本は 0 のままで大丈夫です。
あくまで仕組みを確認するためのカスタマイズです。
よくあるつまずきポイント
Q. Searchを押したのに別の画面が出ます。
pages の順番と items の順番が合っているか確認してください。
final pages = [
const NetflixHomePage(),
const ClipsPage(),
const SearchPage(),
const MyNetflixPage(),
];
final items = [
const BottomNavEntry(icon: Icons.home_filled, label: 'Home'),
const BottomNavEntry(icon: Icons.play_circle_fill_rounded, label: 'Clips'),
const BottomNavEntry(icon: Icons.search_rounded, label: 'Search'),
const BottomNavEntry(icon: Icons.person_rounded, label: 'My Netaflix'),
];
この2つの順番が対応している必要があります。
Q. タップしても画面が変わりません。
onTap が次のようになっているか確認してください。
onTap: () => onChanged(index),
また、NetflixShellPage 側で setState を使っているか確認します。
onChanged: (index) {
setState(() {
selectedIndex = index;
});
},
setState がないと、値が変わっても画面が更新されません。
Q. 選択中のタブの色が変わりません。
この部分を確認してください。
final selected = selectedIndex == index;
そして、アイコンや文字の色が selected によって切り替わっているか見てください。
color: selected ? NetflixColors.white : NetflixColors.muted,
Q. 下部ナビゲーションが下にくっつきすぎます。
iPhoneなどでは、下にホームインジケーターがあります。
そのため、次のコードが入っているか確認してください。
height: 66 + MediaQuery.of(context).padding.bottom,
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
これがあると、端末ごとの下余白に対応できます。
チャレンジ
チャレンジ1:My NetaflixをMy Pageに変えよう
次のコードを探してください。
const BottomNavEntry(icon: Icons.person_rounded, label: 'My Netaflix'),
これを次のように変更します。
const BottomNavEntry(icon: Icons.person_rounded, label: 'My Page'),
チャレンジ2:Searchタブのアイコンを変えよう
次のコードを探してください。
const BottomNavEntry(icon: Icons.search_rounded, label: 'Search'),
これを次のように変更します。
const BottomNavEntry(icon: Icons.manage_search_rounded, label: 'Search'),
チャレンジ3:最初にSearch画面を表示してみよう
次のコードを探してください。
int selectedIndex = 0;
これを次のように変更します。
int selectedIndex = 2;
アプリ起動後、Search画面が最初に表示されるか確認してください。
チャレンジ4:Clipsタブの文字をShortsに変えよう
次のコードを探してください。
const BottomNavEntry(
icon: Icons.play_circle_fill_rounded,
label: 'Clips',
),
これを次のように変更します。
const BottomNavEntry(
icon: Icons.play_circle_fill_rounded,
label: 'Shorts',
),
チャレンジの答え
チャレンジ1の答え
変更前:
const BottomNavEntry(icon: Icons.person_rounded, label: 'My Netaflix'),
変更後:
const BottomNavEntry(icon: Icons.person_rounded, label: 'My Page'),
下部ナビゲーション右端の文字が変わります。
チャレンジ2の答え
変更前:
const BottomNavEntry(icon: Icons.search_rounded, label: 'Search'),
変更後:
const BottomNavEntry(icon: Icons.manage_search_rounded, label: 'Search'),
Searchタブのアイコンが変わります。
チャレンジ3の答え
変更前:
int selectedIndex = 0;
変更後:
int selectedIndex = 2;
アプリ起動後にSearch画面が表示されます。
確認が終わったら、通常は次のように戻しておきましょう。
int selectedIndex = 0;
チャレンジ4の答え
変更前:
const BottomNavEntry(
icon: Icons.play_circle_fill_rounded,
label: 'Clips',
),
変更後:
const BottomNavEntry(
icon: Icons.play_circle_fill_rounded,
label: 'Shorts',
),
下部ナビゲーションの2つ目の文字が Shorts に変わります。
この節のまとめ
この節では、BottomNavigationBar 風のUIで、Home・Clips・Search・My Netaflixを切り替える仕組みを学びました。
大切なポイントは次の通りです。
NetflixShellPageが、現在表示する画面を管理している。selectedIndexは、今選ばれているタブ番号を表す。pages[selectedIndex]によって、表示する画面を切り替えている。NetflixBottomNavは、下部ナビゲーションの見た目を作っている。BottomNavEntryは、アイコンとラベルをまとめるためのクラス。onChanged(index)で、タップされたタブ番号を親に伝えている。setStateを使うことで、画面が更新される。Expandedを使うと、4つのタブを均等に並べられる。MediaQuery.of(context).padding.bottomを使うと、端末ごとの下余白に対応できる。
次のステップ
次の節では、Home画面の上部にある大きな背景画像、ロゴ、Playボタンを作る部分を見ていきます。
背景画像の上に文字やボタンを重ねるために、Stack や Positioned を使います。
ここから一気に、動画アプリらしい見た目になっていきます。
