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

【カテゴリタブ】Shows・Movies・Games・New & Hotをタップして表示内容を切り替える

この節で学ぶこと

前の節では、Home画面上部にあるメニューアイコン、ロゴ、プロフィール画像を配置するヘッダーUIを学びました。

今回の節では、その下にあるカテゴリタブを見ていきます。

Shows
Movies
Games
New & Hot

このタブを押すと、Home画面に表示される作品が切り替わります。

たとえば、Shows を押すと番組・シリーズ系の作品が表示され、Movies を押すと映画系の作品が表示される、という仕組みです。

この節では、次の流れで学びます。

カテゴリの種類をenumで定義する
↓
作品データにカテゴリ情報を持たせる
↓
選択中のカテゴリを状態として管理する
↓
タブをタップしたらカテゴリを変更する
↓
カテゴリに合う作品だけを表示する

カテゴリ切り替えの全体像

今回のカテゴリ切り替えは、主に次の4つで作られています。

役割コード
カテゴリの種類を決めるHomeCategory
表示用のカテゴリ名を作るHomeCategoryLabel
カテゴリタブの見た目を作るTopCategoryTabs
選択中カテゴリに合わせて作品を絞り込むfilteredMovies

流れとしては、こうです。

ユーザーがMoviesタブを押す
↓
selectedCategoryがHomeCategory.moviesになる
↓
moviesリストからmoviesカテゴリの作品だけを取り出す
↓
Home画面の作品一覧が切り替わる

ここで大切なのは、タブを押しただけで画面が変わるのではなく、選択中のカテゴリ状態が変わることで、表示する作品が変わる という考え方です。


HomeCategoryとは?

まず、カテゴリの種類を決めているコードを見てみましょう。

enum HomeCategory {
  all,
  shows,
  movies,
  games,
  newHot,
}

enum は、選択肢をあらかじめ決めておくための仕組みです。

今回の場合、Home画面で使えるカテゴリをここで決めています。

enumの値意味
allすべて
shows番組・シリーズ
movies映画
gamesゲーム
newHot新着・話題

文字列で 'shows''movies' と書く方法もありますが、文字列だとスペルミスが起きやすくなります。

たとえば、'movies' を間違えて 'moveis' と書いても、Dartは普通の文字として扱ってしまいます。

でも、enum を使えば、決められた選択肢の中から選ぶ形になるので、安全にコードを書きやすくなります。


labelで表示名を作る

HomeCategory はコード上の名前なので、そのまま画面に表示すると少し使いにくいです。

そこで、画面に表示するための文字を label として用意しています。

extension HomeCategoryLabel on HomeCategory {
  String get label {
    switch (this) {
      case HomeCategory.all:
        return 'All';
      case HomeCategory.shows:
        return 'Shows';
      case HomeCategory.movies:
        return 'Movies';
      case HomeCategory.games:
        return 'Games';
      case HomeCategory.newHot:
        return 'New & Hot';
    }
  }
}

これは、カテゴリごとに表示する文字を返すコードです。

たとえば、

HomeCategory.shows.label

とすると、次の文字が返ってきます。

Shows

HomeCategory.newHot.label なら、次の文字になります。

New & Hot

このように、コード上のカテゴリ名と、画面に表示する文字を分けています。


なぜextensionを使うの?

ここでは extension を使っています。

extension HomeCategoryLabel on HomeCategory {

extension は、既存の型にあとから便利な機能を追加するようなものです。

今回の場合は、HomeCategorylabel という便利な読み方を追加しています。

これによって、タブを表示するときに次のように書けます。

tab.label

もし extension を使わない場合、毎回 switch 文を書いたり、別の関数を呼び出したりする必要があります。

extension にしておくと、カテゴリ自身が表示名を持っているように扱えるので、コードが読みやすくなります。


作品データにもカテゴリを持たせる

カテゴリタブで作品を切り替えるためには、作品データ側にもカテゴリ情報が必要です。

そのため、MovieItem には homeCategory があります。

final HomeCategory homeCategory;

作品データでは、次のように指定しています。

MovieItem(
  title: 'Squid Game',
  // 省略
  homeCategory: HomeCategory.shows,
),

これは、

Squid GameはShowsカテゴリの作品です

という意味です。

映画として表示したい作品なら、次のようにします。

homeCategory: HomeCategory.movies,

新着・話題として表示したい作品なら、次のようにします。

homeCategory: HomeCategory.newHot,

タブを押したときに表示内容を切り替えられるのは、作品データにこのカテゴリ情報が入っているからです。


selectedCategoryで現在のカテゴリを管理する

Home画面では、現在どのカテゴリが選ばれているかを selectedCategory で管理しています。

HomeCategory selectedCategory = HomeCategory.all;

これは、最初はすべての作品を表示するという意味です。

selectedCategory表示内容
HomeCategory.allすべての作品
HomeCategory.showsShowsの作品
HomeCategory.moviesMoviesの作品
HomeCategory.gamesGamesの作品
HomeCategory.newHotNew & Hotの作品

タブを押すと、この selectedCategory が変わります。

そして、selectedCategory が変わることで、表示する作品も変わります。


作品を絞り込む処理

カテゴリに合わせて作品を絞り込んでいるのが、次の処理です。

final filteredMovies = selectedCategory == HomeCategory.all
    ? movies
    : movies
        .where((movie) => movie.homeCategory == selectedCategory)
        .toList();

少し難しく見えますが、やっていることはシンプルです。

もしAllが選ばれているなら、すべての作品を表示する
そうでなければ、選ばれているカテゴリと同じ作品だけを表示する

という意味です。

たとえば、selectedCategoryHomeCategory.movies の場合は、

movie.homeCategory == HomeCategory.movies

の作品だけが残ります。


whereとは?

ここで出てくる where は、リストの中から条件に合うものだけを取り出すための処理です。

movies.where((movie) => movie.homeCategory == selectedCategory)

このコードは、

moviesリストの中から、
movie.homeCategoryがselectedCategoryと同じ作品だけを取り出す

という意味です。

たとえば、作品データが次のようになっていたとします。

Squid Game → shows
Interstellar → movies
Wednesday → newHot

このとき、selectedCategorymovies なら、Interstellar だけが表示対象になります。


toListとは?

where の後には、toList() がついています。

.toList();

where は条件に合うものを取り出しますが、そのままだと少し特殊な形です。

画面で扱いやすいリストに戻すために、toList() を使っています。

whereで絞り込む
↓
toListでリストにする
↓
画面表示に使う

という流れです。


TopCategoryTabsとは?

次に、カテゴリタブの見た目を作っている TopCategoryTabs を見ていきます。

class TopCategoryTabs extends StatelessWidget {
  const TopCategoryTabs({
    super.key,
    required this.selectedCategory,
    required this.onSelectCategory,
  });

  final HomeCategory selectedCategory;
  final ValueChanged<HomeCategory> onSelectCategory;

TopCategoryTabs は、ShowsMoviesGamesNew & Hot のタブを横並びで表示するWidgetです。

受け取っている値は2つです。

役割
selectedCategory今選ばれているカテゴリ
onSelectCategoryタブを押したときに呼ばれる処理

TopCategoryTabs 自体は StatelessWidget です。

理由は、選択状態を自分で持っているわけではなく、親から受け取って表示しているだけだからです。


タブとして表示するカテゴリを決める

TopCategoryTabs の中では、表示するカテゴリを次のように決めています。

final tabs = [
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
  HomeCategory.newHot,
];

ここで注目したいのは、HomeCategory.all が入っていないことです。

HomeCategory.all は、内部的には存在します。

でも、ヘッダーのカテゴリタブには表示していません。

つまり、コード上ではすべて表示用の all を持っているけれど、画面上のタブには ShowsMoviesGamesNew & Hot だけを出しています。

このように、データとして持つカテゴリ画面に表示するカテゴリ は、必ずしも完全に同じでなくても大丈夫です。


タブを横に並べる

タブは Row で横に並べています。

return Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: tabs.map((tab) {
    ...
  }).toList(),
);

Row は、Widgetを横方向に並べるためのWidgetです。

今回の表示はこうなります。

Shows   Movies   Games   New & Hot

mainAxisAlignment: MainAxisAlignment.center を指定しているので、タブ全体が中央寄せになります。

mainAxisAlignment: MainAxisAlignment.center,

ヘッダーの中央にカテゴリタブが並ぶため、動画アプリ風の雰囲気が出ます。


mapでタブを作る

タブの数だけWidgetを作るために、map を使っています。

children: tabs.map((tab) {
  final selected = tab == selectedCategory;

  return GestureDetector(
    onTap: () => onSelectCategory(tab),
    child: ...
  );
}).toList(),

tabs の中には、4つのカテゴリが入っています。

[
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
  HomeCategory.newHot,
]

この4つを1つずつ取り出して、タブのWidgetに変換しています。

HomeCategory.shows → Showsタブ
HomeCategory.movies → Moviesタブ
HomeCategory.games → Gamesタブ
HomeCategory.newHot → New & Hotタブ

このように、データのリストからUIを作る考え方は、Flutterでとてもよく使います。


選択中かどうかを判断する

タブごとに、選択中かどうかを判断しています。

final selected = tab == selectedCategory;

これは、

今作っているタブが、現在選ばれているカテゴリと同じか?

を見ています。

たとえば、selectedCategoryHomeCategory.movies のとき、Movies タブだけが selected == true になります。

それ以外のタブは false です。

この selected を使って、選択中タブの色や背景を変えます。


GestureDetectorでタップできるようにする

タブは GestureDetector で包まれています。

GestureDetector(
  behavior: HitTestBehavior.opaque,
  onTap: () => onSelectCategory(tab),
  child: ...
)

GestureDetector は、タップなどの操作を受け取るためのWidgetです。

ここでは、タブを押したときに次の処理が動きます。

onSelectCategory(tab)

つまり、

押されたタブのカテゴリを親に伝える

ということです。

たとえば、Movies タブを押した場合は、

onSelectCategory(HomeCategory.movies)

が実行されます。


onSelectCategoryはどこにつながっている?

onSelectCategory は、親である NetflixHomePage から渡されています。

Home画面側では、次のように受け取ります。

NetflixTopArea(
  hero: hero,
  selectedCategory: selectedCategory,
  onTapMenu: openMenu,
  onSelectCategory: selectCategory,
),

ここで渡されているのが、selectCategory です。

void selectCategory(HomeCategory category) {
  setState(() {
    selectedCategory = category;
  });
}

つまり、カテゴリタブを押したときの流れはこうです。

タブを押す
↓
TopCategoryTabsでonSelectCategory(tab)が呼ばれる
↓
NetflixHomePageのselectCategoryが動く
↓
selectedCategoryが変更される
↓
setStateで画面が更新される
↓
filteredMoviesが再計算される
↓
表示される作品が変わる

この流れが、カテゴリ切り替えの中心です。


選択中タブの見た目を変える

カテゴリタブでは、選択中かどうかによって見た目を変えています。

たとえば、選択中のタブだけ背景を少し明るくしたり、文字を白くしたりできます。

イメージとしては、次のようなコードです。

AnimatedContainer(
  duration: const Duration(milliseconds: 180),
  padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
  decoration: BoxDecoration(
    color: selected
        ? Colors.white.withValues(alpha: 0.16)
        : Colors.transparent,
    borderRadius: BorderRadius.circular(999),
    border: Border.all(
      color: selected
          ? Colors.white.withValues(alpha: 0.22)
          : Colors.white.withValues(alpha: 0.0),
    ),
  ),
  child: Text(
    tab.label,
    style: TextStyle(
      color: selected ? NetflixColors.white : NetflixColors.muted,
      fontSize: 13,
      fontWeight: selected ? FontWeight.w800 : FontWeight.w700,
    ),
  ),
)

ここでは、selectedtrue のときだけ、背景や文字色を変えています。

選択中:白っぽい背景 + 白文字
未選択:透明背景 + グレー文字

ユーザーは見た目の違いによって、今どのカテゴリを見ているのか分かります。


AnimatedContainerとは?

ここで使っている AnimatedContainer は、Container の変化をなめらかにアニメーションしてくれるWidgetです。

AnimatedContainer(
  duration: const Duration(milliseconds: 180),
  ...
)

たとえば、選択中タブの背景色が変わるとき、パッと切り替わるのではなく、少しだけなめらかに変わります。

duration: const Duration(milliseconds: 180),

これは、アニメーション時間を180ミリ秒にするという意味です。

タブのような小さなUIでは、速めのアニメーションが合います。

遅すぎると、操作がもたついて見えることがあります。


Textでカテゴリ名を表示する

タブの文字は、Text で表示しています。

Text(
  tab.label,
  style: TextStyle(
    color: selected ? NetflixColors.white : NetflixColors.muted,
    fontSize: 13,
    fontWeight: selected ? FontWeight.w800 : FontWeight.w700,
  ),
)

ここで出てくる tab.label は、先ほどの extension で用意した表示名です。

HomeCategory.movies.label

なら、

Movies

になります。

選択中かどうかによって、文字の色と太さも変えています。

color: selected ? NetflixColors.white : NetflixColors.muted,
fontWeight: selected ? FontWeight.w800 : FontWeight.w700,

選択中のタブを少し強く見せることで、現在地が分かりやすくなります。


作品一覧側も切り替わる

カテゴリが切り替わると、Home画面の作品一覧も変わります。

Home画面では、絞り込まれた作品リストを使って、コンテンツ行を表示しています。

final items = filteredMovies.isEmpty ? movies : filteredMovies;
final hero = items.first;

ここでは、filteredMovies が空でなければ、そのカテゴリに合った作品を使います。

もし該当作品がない場合は、空画面にしないように movies を使っています。

filteredMoviesに作品がある → その作品を表示
filteredMoviesが空 → すべての作品を表示

教材用のアプリでは、カテゴリに作品が少ない場合もあるため、このようにしておくと画面が真っ黒になりにくいです。


カテゴリごとの作品を増やすには?

カテゴリタブを活かすには、作品データに homeCategory を正しく入れる必要があります。

たとえば、映画作品を追加したい場合は、次のようにします。

MovieItem(
  title: 'Interstellar',
  subtitle: 'A journey beyond time and space.',
  description: 'A team travels through a wormhole to find a new home for humanity.',
  posterUrl: 'https://example.com/interstellar-poster.jpg',
  backdropUrl: 'https://example.com/interstellar-backdrop.jpg',
  youtubeVideoId: 'zSWdZVtXT7E',
  year: 2014,
  rating: '13+',
  seasonLabel: 'Movie',
  category: 'Sci-Fi',
  matchRate: 96,
  homeCategory: HomeCategory.movies,
),

ここで重要なのは、最後の部分です。

homeCategory: HomeCategory.movies,

この指定があることで、Movies タブを押したときに表示されるようになります。


まずカスタマイズしてみよう

今回は、Games タブの表示名を変えてみましょう。

次のコードを探してください。

case HomeCategory.games:
  return 'Games';

これを次のように変えてみます。

case HomeCategory.games:
  return 'Play';

保存して、画面を確認してください。

カテゴリタブの GamesPlay に変わっていれば成功です。

このように、表示名だけを変えたい場合は、label の部分を変更します。


タブの順番をカスタマイズしてみよう

タブの順番は、次のリストで決まっています。

final tabs = [
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
  HomeCategory.newHot,
];

たとえば、New & Hot を先頭にしたい場合は、次のようにします。

final tabs = [
  HomeCategory.newHot,
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
];

これで表示順が変わります。

New & Hot
Shows
Movies
Games

ただし、ユーザーが見慣れている順番もあるので、見た目だけでなく使いやすさも考えて決めましょう。


選択中タブの色をカスタマイズしてみよう

選択中タブの背景色は、次のような部分で決まっています。

color: selected
    ? Colors.white.withValues(alpha: 0.16)
    : Colors.transparent,

選択中をもっと目立たせたい場合は、少し濃くします。

color: selected
    ? Colors.white.withValues(alpha: 0.28)
    : Colors.transparent,

逆に、もっと控えめにしたい場合は、薄くします。

color: selected
    ? Colors.white.withValues(alpha: 0.08)
    : Colors.transparent,

UIでは、目立たせすぎると少し重く見えることがあります。

アプリ全体の雰囲気に合わせて調整しましょう。


よくあるつまずきポイント

Q. タブを押しても表示が変わりません。

onSelectCategory が正しく呼ばれているか確認してください。

onTap: () => onSelectCategory(tab),

さらに、親側で setState を使っているか確認します。

void selectCategory(HomeCategory category) {
  setState(() {
    selectedCategory = category;
  });
}

setState がないと、値が変わっても画面が更新されません。


Q. Moviesタブを押しても作品が出ません。

作品データに homeCategory: HomeCategory.movies が入っているか確認してください。

homeCategory: HomeCategory.movies,

もしMoviesカテゴリの作品が1つもなければ、Moviesタブを押しても表示対象が空になります。


Q. タブの文字が変わりません。

表示名を変えたい場合は、HomeCategoryLabellabel を確認してください。

case HomeCategory.games:
  return 'Games';

ここを変更すれば、画面に表示される文字が変わります。


Q. 新しいカテゴリを追加したらエラーが出ました。

enum に新しい値を追加した場合は、labelswitch にも追加する必要があります。

たとえば、anime を追加した場合です。

enum HomeCategory {
  all,
  shows,
  movies,
  games,
  newHot,
  anime,
}

この場合、label にも追加します。

case HomeCategory.anime:
  return 'Anime';

さらに、タブに表示したい場合は tabs にも追加します。

final tabs = [
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
  HomeCategory.newHot,
  HomeCategory.anime,
];

作品データにも、必要に応じて次のように入れます。

homeCategory: HomeCategory.anime,

カテゴリを追加するときは、複数の場所をセットで変更する必要があります。


チャレンジ

チャレンジ1:GamesタブをPlayに変えよう

次のコードを探してください。

case HomeCategory.games:
  return 'Games';

これを次のように変更します。

case HomeCategory.games:
  return 'Play';

画面上のタブ名が変わるか確認してください。


チャレンジ2:New & Hotを先頭に表示しよう

次のコードを探してください。

final tabs = [
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
  HomeCategory.newHot,
];

これを次のように変更します。

final tabs = [
  HomeCategory.newHot,
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
];

タブの表示順が変わるか確認してください。


チャレンジ3:選択中タブの背景を少し濃くしよう

次のコードを探してください。

Colors.white.withValues(alpha: 0.16)

これを次のように変更します。

Colors.white.withValues(alpha: 0.28)

選択中のタブが少し目立つようになります。


チャレンジ4:Animeカテゴリを追加してみよう

まず、HomeCategoryanime を追加します。

enum HomeCategory {
  all,
  shows,
  movies,
  games,
  newHot,
  anime,
}

次に、label にも追加します。

case HomeCategory.anime:
  return 'Anime';

さらに、タブにも追加します。

final tabs = [
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
  HomeCategory.newHot,
  HomeCategory.anime,
];

最後に、作品データにも anime カテゴリを指定します。

homeCategory: HomeCategory.anime,

チャレンジの答え

チャレンジ1の答え

変更前:

case HomeCategory.games:
  return 'Games';

変更後:

case HomeCategory.games:
  return 'Play';

カテゴリタブの表示名が Games から Play に変わります。


チャレンジ2の答え

変更前:

final tabs = [
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
  HomeCategory.newHot,
];

変更後:

final tabs = [
  HomeCategory.newHot,
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
];

New & Hot が一番左に表示されます。


チャレンジ3の答え

変更前:

Colors.white.withValues(alpha: 0.16)

変更後:

Colors.white.withValues(alpha: 0.28)

選択中タブの背景が少し濃くなります。


チャレンジ4の答え

enum に追加します。

enum HomeCategory {
  all,
  shows,
  movies,
  games,
  newHot,
  anime,
}

label に追加します。

case HomeCategory.anime:
  return 'Anime';

tabs に追加します。

final tabs = [
  HomeCategory.shows,
  HomeCategory.movies,
  HomeCategory.games,
  HomeCategory.newHot,
  HomeCategory.anime,
];

作品データに追加します。

homeCategory: HomeCategory.anime,

これで、Anime タブを押したときに、HomeCategory.anime の作品が表示されるようになります。


この節のまとめ

この節では、ShowsMoviesGamesNew & Hot のカテゴリタブを押して、Home画面の表示内容を切り替える方法を学びました。

大切なポイントは次の通りです。

  • HomeCategory は、Home画面で使うカテゴリの種類を決める enum
  • extension を使うと、HomeCategorylabel のような便利な表示名を追加できる。
  • 作品データには homeCategory を持たせる。
  • selectedCategory は、現在選ばれているカテゴリを表す。
  • where を使うと、リストから条件に合う作品だけを取り出せる。
  • toList() を使うと、絞り込んだ結果をリストとして扱える。
  • TopCategoryTabs は、カテゴリタブの見た目を作るWidget。
  • GestureDetector を使うと、タブをタップできるようになる。
  • onSelectCategory(tab) で、押されたカテゴリを親に伝える。
  • 親側で setState を使うことで、画面が更新される。
  • 選択中タブの見た目を変えると、今どのカテゴリを見ているか分かりやすくなる。

次のステップ

次の節では、下から表示されるメニューシートを見ていきます。

メニューアイコンを押したときに、画面下からメニューが出てくる仕組みです。

showModalBottomSheet を使うと、設定メニューやアクションメニューのようなUIを作れるようになります。

教材トップへ戻る