Flutterアプリケーション開発概論

【画面切り替え】BottomNavigationBarでHome・Clips・Search・My Netaflixを切り替える

9NETFLIX風動画アプリを作りながらUI・検索・動画再生・共有機能を学ぶFlutter(iOS・Android)アプリ開発
FlutteriOSAndroidMacOSWindows基礎から学ぶ開発アプリ開発

この節で学ぶこと

前の節では、movies リストに作品データを追加する方法を学びました。

今回の節では、アプリ下部にあるメニューを使って、画面を切り替える仕組みを見ていきます。

今回のアプリには、下の4つのメイン画面があります。

Home
Clips
Search
My Netaflix

スマホアプリでは、このような下部メニューをよく見かけます。

たとえば、動画アプリ、SNS、ニュースアプリ、ショッピングアプリなどでも、画面の下にメニューがあり、タップすると画面が切り替わります。

今回のアプリでは、それを BottomNavigationBar 風のUIとして自作しています。

正確にはFlutter標準の BottomNavigationBar ウィジェットをそのまま使うのではなく、ContainerRowGestureDetector などを使って、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表示する画面
0Home
1Clips
2Search
3My 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],

たとえば、selectedIndex0 なら、

body: pages[0],

なので、Home画面が表示されます。

selectedIndex2 なら、

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;
    });
  },
),

ここで大切なのは、selectedIndexonChanged を渡していることです。

渡しているもの役割
selectedIndex今どのタブが選ばれているかを伝える
onChangedタブが押されたときに実行する処理を渡す

NetflixBottomNav は見た目を作るだけではなく、タップされたときに親の selectedIndex を変えるために onChanged を使います。


setStateで画面を更新する

タブが押されたときは、次のコードが動きます。

onChanged: (index) {
  setState(() {
    selectedIndex = index;
  });
},

setState は、画面を更新するための大切な処理です。

ここでは、

タップされたタブ番号をselectedIndexに入れる
↓
setStateで画面を更新する
↓
body: pages[selectedIndex] が切り替わる

という流れになります。

たとえば、Searchタブをタップすると、index2 になります。

すると、

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'),
];

ここでは、下部ナビゲーションの項目をリストで管理しています。

順番アイコンラベル
0Homeアイコン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;

これは、今作っているタブが選択中かどうかを判断しています。

たとえば、selectedIndex2 のとき、Searchタブの index2 なので、

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画面が表示されます。

それは、次のように selectedIndex0 だからです。

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ボタンを作る部分を見ていきます。

背景画像の上に文字やボタンを重ねるために、StackPositioned を使います。

ここから一気に、動画アプリらしい見た目になっていきます。

教材トップへ戻る