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

【完成コードを読む】NETFLIX風アプリの全体構造を上から順番に理解する

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

この節で学ぶこと

これまでの節では、NETAFLIX風アプリを部品ごとに作ってきました。

Home画面、詳細画面、YouTube再生画面、Clips画面、Search画面、My Netaflix画面、共有機能、iOS設定、アプリアイコン設定などを、1つずつ学びました。

今回の節では、完成したコード全体を上から順番に読みます。

アプリ開発では、「このWidgetは分かる」「この機能は分かる」という理解も大切です。

しかし、最終的には、コード全体がどのようにつながっているのかを説明できることが重要です。

main関数
↓
アプリ全体
↓
BottomNavigation
↓
各画面
↓
作品データ
↓
詳細画面
↓
YouTube再生
↓
検索・共有・My画面

この節のゴールは、完成コードを見たときに、「このアプリは、上からこういう流れで動いている」と説明できるようになることです。


完成コードを読むときのコツ

完成コードを読むときは、いきなり細かい1行ずつを読もうとしなくて大丈夫です。

まずは、大きな地図を見るように、全体の構造をつかみます。

1. どこからアプリが始まるのか
2. どこで画面全体を管理しているのか
3. どこにデータがあるのか
4. どこで画面を切り替えているのか
5. どこで詳細画面へ移動しているのか
6. どこで動画再生や共有をしているのか

この順番で読むと、長いコードでも迷いにくくなります。


全体構造の地図

完成したNETAFLIX風アプリは、大きく見ると次のような構造です。

main()
↓
runApp()
↓
NetflixLikeApp
↓
NetflixShell
↓
BottomNavigationBar
↓
HomePage / SearchPage / ClipsPage / MyNetflixPage
↓
MovieDetailPage
↓
MoviePlayerPage

それぞれの役割は、次の通りです。

名前役割
main()アプリのスタート地点
runApp()Flutterに最初のWidgetを渡す
NetflixLikeAppアプリ全体の設定を行う
NetflixShellBottomNavigationで画面を切り替える
HomePageホーム画面
SearchPage検索画面
ClipsPage縦型動画風の画面
MyNetflixPageプロフィール・通知・My List画面
MovieDetailPage作品詳細画面
MoviePlayerPageYouTube動画再生画面

まずは、アプリの入口から見ていきます。


main関数がアプリの入口

Flutterアプリは、基本的に main() から始まります。

void main() {
  runApp(const NetflixLikeApp());
}

main() は、アプリのスタート地点です。

アプリ起動
↓
main()が呼ばれる
↓
runApp()が実行される

Flutterでは、runApp() に最初に表示したいWidgetを渡します。

今回の場合は、NetflixLikeApp を渡しています。

runApp(const NetflixLikeApp());

つまり、このアプリの最初のWidgetは NetflixLikeApp です。


runAppとは?

runApp() は、Flutterアプリを画面に表示し始めるための関数です。

runApp(const NetflixLikeApp());

イメージとしては、Flutterに対して次のように伝えています。

このWidgetをアプリ全体の根っこにしてください

ここで渡したWidgetを起点にして、下に画面が広がっていきます。

NetflixLikeApp
↓
MaterialApp
↓
NetflixShell
↓
各画面

NetflixLikeAppでアプリ全体の設定をする

次に、NetflixLikeApp を見ます。

class NetflixLikeApp extends StatelessWidget {
  const NetflixLikeApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'NETAFLIX',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        scaffoldBackgroundColor: NetflixColors.black,
      ),
      home: const NetflixShell(),
    );
  }
}

NetflixLikeApp は、アプリ全体の設定をするWidgetです。

ここでは、MaterialApp を返しています。


MaterialAppとは?

MaterialApp は、Flutterでアプリ全体を作るための基本Widgetです。

return MaterialApp(
  title: 'NETAFLIX',
  debugShowCheckedModeBanner: false,
  theme: ThemeData(
    useMaterial3: true,
    scaffoldBackgroundColor: NetflixColors.black,
  ),
  home: const NetflixShell(),
);

MaterialApp では、アプリ名、テーマ、最初に表示する画面などを設定できます。

設定役割
titleアプリのタイトル
debugShowCheckedModeBanner右上のDEBUG表示を消す
themeアプリ全体のテーマ
home最初に表示する画面

今回のアプリでは、homeNetflixShell を指定しています。

home: const NetflixShell(),

つまり、アプリを起動すると、まず NetflixShell が表示されます。


debugShowCheckedModeBannerをfalseにする

Flutterアプリをデバッグ実行すると、画面右上に DEBUG と表示されることがあります。

それを消しているのが、次の設定です。

debugShowCheckedModeBanner: false,

教材やデモアプリでは、見た目を整えるために消しておくことが多いです。

true
↓
右上にDEBUG表示が出る

false
↓
DEBUG表示を消す

ThemeDataで全体の雰囲気を決める

アプリ全体のテーマは、ThemeData で設定しています。

theme: ThemeData(
  useMaterial3: true,
  scaffoldBackgroundColor: NetflixColors.black,
),

useMaterial3: true は、Material Design 3の見た目を使う指定です。

scaffoldBackgroundColor には、画面の背景色を指定しています。

scaffoldBackgroundColor: NetflixColors.black,

NETAFLIX風アプリなので、背景は黒が基本です。


NetflixShellが画面切り替えの中心

次に、NetflixShell を見ます。

class NetflixShell extends StatefulWidget {
  const NetflixShell({super.key});

  @override
  State<NetflixShell> createState() => _NetflixShellState();
}

NetflixShell は、BottomNavigationを使って画面を切り替える中心部分です。

Home、Search、Clips、My Netaflixの4画面を持っています。

NetflixShell
↓
Home
Search
Clips
My Netaflix

画面切り替えの状態を持つので、StatefulWidget になっています。


なぜNetflixShellはStatefulWidgetなのか

NetflixShell では、現在どのタブを選んでいるかを管理します。

int currentIndex = 0;

たとえば、次のような状態です。

currentIndex表示する画面
0Home
1Search
2Clips
3My Netaflix

ユーザーがBottomNavigationをタップすると、この currentIndex が変わります。

Homeをタップ
↓
currentIndex = 0

Searchをタップ
↓
currentIndex = 1

値が変わると画面も変わるため、StatefulWidget が必要です。


pagesで4つの画面をまとめる

NetflixShell の中では、4つの画面をリストにまとめます。

final pages = const [
  HomePage(),
  SearchPage(),
  ClipsPage(),
  MyNetflixPage(),
];

この pages の中から、currentIndex に合う画面を表示します。

body: pages[currentIndex],

たとえば、currentIndex が0なら、pages[0] が表示されます。

pages[0] = HomePage()

currentIndex が1なら、pages[1] が表示されます。

pages[1] = SearchPage()

このように、数字と画面を対応させています。


BottomNavigationBarで画面を切り替える

BottomNavigationBarは、画面下にあるタブメニューです。

BottomNavigationBar(
  currentIndex: currentIndex,
  onTap: (index) {
    setState(() {
      currentIndex = index;
    });
  },
  items: const [
    BottomNavigationBarItem(
      icon: Icon(Icons.home_outlined),
      activeIcon: Icon(Icons.home),
      label: 'Home',
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.search_outlined),
      activeIcon: Icon(Icons.search),
      label: 'Search',
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.play_circle_outline),
      activeIcon: Icon(Icons.play_circle),
      label: 'Clips',
    ),
    BottomNavigationBarItem(
      icon: Icon(Icons.person_outline),
      activeIcon: Icon(Icons.person),
      label: 'My Netaflix',
    ),
  ],
)

ここで、4つのタブを作っています。

Home
Search
Clips
My Netaflix

onTapでcurrentIndexを更新する

タブを押したときに呼ばれるのが onTap です。

onTap: (index) {
  setState(() {
    currentIndex = index;
  });
},

index には、押されたタブの番号が入ります。

Homeを押す → index = 0
Searchを押す → index = 1
Clipsを押す → index = 2
Myを押す → index = 3

その番号を currentIndex に入れます。

currentIndex = index;

そして setState によって、画面を更新します。

タブを押す
↓
currentIndexが変わる
↓
setStateで再描画
↓
body: pages[currentIndex] が変わる
↓
画面が切り替わる

MovieItemが作品データの形を決める

画面の構造が分かったら、次に作品データを見ます。

作品1件分の形を決めているのが、MovieItem です。

class MovieItem {
  const MovieItem({
    required this.title,
    required this.subtitle,
    required this.description,
    required this.posterUrl,
    required this.backdropUrl,
    required this.youtubeVideoId,
    required this.matchRate,
    required this.year,
    required this.rating,
    required this.seasonLabel,
    required this.category,
  });

  final String title;
  final String subtitle;
  final String description;
  final String posterUrl;
  final String backdropUrl;
  final String youtubeVideoId;
  final int matchRate;
  final int year;
  final String rating;
  final String seasonLabel;
  final String category;
}

これは、作品データの設計図です。


MovieItemの役割

MovieItem は、1つの作品がどんな情報を持つかを決めています。

プロパティ役割
title作品タイトル
subtitle短い説明
description詳しい説明文
posterUrl縦長ポスター画像
backdropUrl横長背景画像
youtubeVideoIdYouTube動画ID
matchRateマッチ率
year公開年
rating年齢制限
seasonLabelシーズン表示
categoryジャンル・カテゴリ

このように、作品に必要な情報を1つのクラスにまとめています。

MovieItem
↓
作品1件分のデータをまとめる箱

moviesが作品一覧データ

作品データの一覧は、movies に入っています。

const movies = [
  MovieItem(
    title: 'Squid Game',
    subtitle: 'Survival thriller',
    description: 'Hundreds of cash-strapped players accept a strange invitation...',
    posterUrl: 'https://...',
    backdropUrl: 'https://...',
    youtubeVideoId: 'oqxAJKy0ii4',
    matchRate: 98,
    year: 2021,
    rating: '18+',
    seasonLabel: 'Season 1',
    category: 'Thriller',
  ),
  MovieItem(
    ...
  ),
];

movies は、アプリ内で使う作品一覧です。

Home画面、Search画面、Clips画面、My Netaflix画面など、いろいろな場所で使います。

movies
↓
Home画面で表示
↓
Search画面で検索
↓
Clips画面で縦表示
↓
My Netaflix画面で一部表示

同じデータを複数の画面で使う

このアプリでは、movies を複数の画面で再利用しています。

画面movies** の使い方**
Home画面作品リストとして表示
Search画面検索対象として使う
Clips画面縦型動画風に表示
My Netaflix画面視聴中リスト・My Listとして一部表示
Detail画面選ばれた1作品を表示

このように、データを1か所にまとめておくと、画面ごとに同じ情報を重複して書かなくて済みます。

悪い例
↓
各画面に作品データを別々に書く

良い例
↓
moviesにまとめて、各画面で使う

HomePageの役割

HomePage は、アプリのメイン画面です。

ここでは、ヒーロー画像、カテゴリ、作品リストなどを表示します。

HomePage
↓
大きなメインビジュアル
↓
Play / My List / Info
↓
作品リスト

Home画面では、ユーザーが最初に作品を見つける入口を作っています。

たとえば、先頭の作品をメイン作品として使う場合は、次のようにできます。

final featured = movies.first;

movies.first は、作品一覧の最初の1件を取り出します。

movies
↓
最初の作品
↓
Homeのメインビジュアル

ContentRowで作品を横並びにする

Home画面やMy Netaflix画面では、ContentRow を使って作品を横並びに表示しています。

ContentRow(items: movies)

ContentRow は、作品リストを横スクロールで表示するための部品です。

[作品] [作品] [作品] [作品] →

横スクロールの作品リストは、動画アプリらしいUIです。

ContentRow を共通化しておくことで、Home画面でもMy画面でも同じ表示を使えます。


ここまでのまとめ

ここまでで、完成コードの前半として、アプリの入口、アプリ全体の設定、BottomNavigation、作品データ、Home画面の役割まで確認しました。

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

  • Flutterアプリは main() から始まる。
  • runApp() に渡したWidgetがアプリの根っこになる。
  • NetflixLikeApp は、MaterialApp を使ってアプリ全体の設定を行う。
  • MaterialApphomeNetflixShell を指定している。
  • NetflixShell は、BottomNavigationで4つの画面を切り替える中心。
  • currentIndex によって、表示する画面が決まる。
  • setStatecurrentIndex を更新すると、タブ画面が切り替わる。
  • MovieItem は、作品1件分のデータの形を決めるクラス。
  • movies は、アプリ全体で使う作品一覧データ。
  • HomePageSearchPageClipsPageMyNetflixPage は、同じ movies をそれぞれ違う形で使っている。
  • ContentRow は、作品を横スクロールで表示する共通Widget。

ContentCardで作品1件分を表示する

ここからは、完成コードの後半として、作品を選んだあとの流れを見ていきます。

ContentRow の中では、作品1件分を ContentCard のようなWidgetで表示します。

ContentRow
↓
ContentCard
↓
ポスター画像
↓
タップすると詳細画面へ移動

作品カードをタップすると、Navigator を使って詳細画面に移動します。

Navigator.of(context).push(
  MaterialPageRoute<void>(
    builder: (context) => MovieDetailPage(movie: movie),
  ),
);

ここで大切なのは、選んだ作品データである movie を、次の画面に渡していることです。

MovieDetailPage(movie: movie)

つまり、作品カードは「画像を表示するだけ」ではありません。

ユーザーが作品を選び、詳細画面へ進むための入口になっています。


MovieDetailPageの役割

MovieDetailPage は、作品の詳細を表示する画面です。

MovieDetailPage
↓
背景画像
↓
タイトル
↓
マッチ率・年・年齢制限
↓
Playボタン
↓
Downloadボタン
↓
説明文
↓
My List / Rate / Share

Home画面、Search画面、Clips画面などから作品を選ぶと、この詳細画面に移動します。

詳細画面は、MovieItem movie を受け取ります。

class MovieDetailPage extends StatelessWidget {
  const MovieDetailPage({
    super.key,
    required this.movie,
  });

  final MovieItem movie;
}

この movie の中に、タイトル、説明文、画像URL、YouTube動画IDなどが入っています。

そのため、詳細画面は1つだけでも、渡される作品によって表示内容を変えられます。


required this.movieで作品を受け取る

MovieDetailPage では、コンストラクタで movie を受け取っています。

required this.movie,

そして、画面の中でその情報を使います。

Text(movie.title)
Text(movie.description)
Image.network(movie.backdropUrl)

つまり、詳細画面の中身は、渡された movie によって決まります。

Squid Gameを渡す
↓
Squid Gameの詳細画面

Wednesdayを渡す
↓
Wednesdayの詳細画面

この考え方は、Flutterアプリ開発でとても大切です。

画面を作品ごとに何個も作るのではなく、1つの詳細画面にデータを渡して使い回します。


PlayボタンからMoviePlayerPageへ移動する

詳細画面には、Playボタンがあります。

Playボタンを押すと、YouTube再生画面へ移動します。

Navigator.of(context).push(
  MaterialPageRoute<void>(
    builder: (context) => MoviePlayerPage(movie: movie),
  ),
);

ここでも、同じ movie を次の画面に渡しています。

MoviePlayerPage(movie: movie)

この流れがとても大切です。

Home画面で作品を選ぶ
↓
MovieDetailPage(movie)
↓
Playを押す
↓
MoviePlayerPage(movie)
↓
movie.youtubeVideoIdでYouTube再生

作品データが、画面から画面へ渡されていくことで、アプリ全体がつながっています。


MoviePlayerPageの役割

MoviePlayerPage は、YouTube動画を再生する画面です。

MoviePlayerPage
↓
YoutubePlayerControllerを作る
↓
movie.youtubeVideoIdを渡す
↓
YoutubePlayerで表示する

再生する動画は、movie.youtubeVideoId で決まります。

videoId: widget.movie.youtubeVideoId,

作品ごとに違う動画IDを持たせているので、選んだ作品に応じた動画を再生できます。

Squid Game
↓
Squid GameのYouTube動画ID

Wednesday
↓
WednesdayのYouTube動画ID

StatefulWidgetで動画Controllerを管理する

MoviePlayerPage は、StatefulWidget で作ることが多いです。

理由は、YoutubePlayerController を作成し、最後に破棄する必要があるからです。

画面を開く
↓
Controllerを作る

画面を閉じる
↓
Controllerを破棄する

表示するだけの画面なら StatelessWidget でもよいですが、Controllerのようにライフサイクル管理が必要なものは、StatefulWidget が向いています。


initStateでControllerを作る

動画プレイヤーでは、画面が作られたタイミングでControllerを作ります。

@override
void initState() {
  super.initState();

  controller = YoutubePlayerController.fromVideoId(
    videoId: widget.movie.youtubeVideoId,
    autoPlay: true,
    params: const YoutubePlayerParams(
      showFullscreenButton: true,
      playsInline: true,
      strictRelatedVideos: true,
    ),
  );
}

initState は、Stateが最初に作られたときに一度だけ呼ばれます。

MoviePlayerPageを開く
↓
initStateが呼ばれる
↓
Controllerを作る

videoIdwidget.movie.youtubeVideoId を渡しているので、選ばれた作品の動画が再生されます。


disposeでControllerを片付ける

動画プレイヤーのControllerは、画面を閉じるときに片付けます。

@override
void dispose() {
  controller.close();
  super.dispose();
}

dispose は、画面が破棄されるときに呼ばれます。

MoviePlayerPageを閉じる
↓
disposeが呼ばれる
↓
Controllerを片付ける

Controllerを使うWidgetでは、作るだけでなく、片付けることも大切です。

これを忘れると、不要な処理が残ったり、アプリの動作が重くなったりする原因になります。


SearchPageの役割

SearchPage は、作品を検索する画面です。

SearchPage
↓
TextFieldに文字を入力
↓
queryを更新
↓
filteredMoviesで絞り込み
↓
SearchResultTileで表示

入力文字を管理するため、StatefulWidget で作ります。

String query = '';

文字を入力すると、onChanged が呼ばれます。

onChanged: (value) {
  setState(() {
    query = value;
  });
}

setState によって画面が更新され、検索結果が変わります。


filteredMoviesで検索結果を作る

検索結果は、filteredMovies で作ります。

List<MovieItem> get filteredMovies {
  final keyword = query.trim().toLowerCase();

  if (keyword.isEmpty) {
    return movies;
  }

  return movies.where((movie) {
    final title = movie.title.toLowerCase();
    final subtitle = movie.subtitle.toLowerCase();
    final description = movie.description.toLowerCase();
    final category = movie.category.toLowerCase();

    return title.contains(keyword) ||
        subtitle.contains(keyword) ||
        description.contains(keyword) ||
        category.contains(keyword);
  }).toList();
}

ここでは、タイトル、サブタイトル、説明文、カテゴリから検索しています。

query
↓
trimで前後の空白を削除
↓
toLowerCaseで小文字にそろえる
↓
moviesをwhereで絞り込む
↓
検索結果をListで返す

検索欄に文字を入れるたびに、filteredMovies の結果が変わります。


SearchResultTileで検索結果を表示する

検索結果1件分は、SearchResultTile で表示します。

SearchResultTile(movie: movie)

SearchResultTile は、ポスター画像、タイトル、メタ情報、説明文を表示します。

さらに、カードをタップすると詳細画面へ移動します。

Navigator.of(context).push(
  MaterialPageRoute<void>(
    builder: (context) => MovieDetailPage(movie: movie),
  ),
);

検索画面でも、選んだ作品を MovieDetailPage に渡す流れは同じです。

SearchPageで検索
↓
SearchResultTileをタップ
↓
MovieDetailPage(movie)

ClipsPageの役割

ClipsPage は、縦型ショート動画風の画面です。

ClipsPage
↓
PageView.builder
↓
作品を1画面ずつ表示
↓
縦スワイプで切り替え
↓
Shareボタンで共有

PageView.builder を使うことで、作品を1ページずつ表示します。

PageView.builder(
  scrollDirection: Axis.vertical,
  itemCount: movies.length,
  itemBuilder: (context, index) {
    final item = movies[index];

    return ClipPageItem(
      movie: item,
      onTapShare: () => shareMovie(item),
    );
  },
)

scrollDirection: Axis.vertical によって、縦方向にスワイプできます。

上へスワイプ
↓
次の作品

下へスワイプ
↓
前の作品

ClipsPageから共有機能を呼び出す

Clips画面では、Shareボタンから共有処理を呼びます。

void shareMovie(MovieItem movie) {
  shareNetaflixMovie(
    context: context,
    movie: movie,
  );
}

共有の実処理は、shareNetaflixMovie にまとめています。

Future<void> shareNetaflixMovie({
  required BuildContext context,
  required MovieItem movie,
}) async {
  await SharePlus.instance.share(
    ShareParams(
      text:
          'NETAFLIXで「${movie.title}」をチェック!\nhttps://www.youtube.com/watch?v=${movie.youtubeVideoId}',
      subject: movie.title,
      sharePositionOrigin: shareOriginFromContext(context),
    ),
  );
}

このように、共有処理を共通関数にしておくと、詳細画面からもClips画面からも同じ処理を使えます。

MovieDetailPageのShare
↓
shareNetaflixMovie

ClipsPageのShare
↓
shareNetaflixMovie

shareOriginFromContextでiOS共有エラーを防ぐ

共有処理では、sharePositionOrigin を指定しています。

sharePositionOrigin: shareOriginFromContext(context),

その位置情報を作るのが、shareOriginFromContext です。

Rect shareOriginFromContext(BuildContext context) {
  final size = MediaQuery.sizeOf(context);

  return Rect.fromLTWH(
    1,
    1,
    size.width <= 2 ? 1 : size.width - 2,
    size.height <= 2 ? 1 : size.height - 2,
  );
}

iOSやiPadでは、共有画面をどの位置から表示するかが必要になることがあります。

そのため、画面サイズをもとに安全な Rect を作っています。

MediaQueryで画面サイズを取得
↓
Rect.fromLTWHで四角形を作る
↓
sharePositionOriginに渡す
↓
iOS共有エラーを防ぎやすくする

MyNetflixPageの役割

MyNetflixPage は、ユーザーのページです。

MyNetflixPage
↓
プロフィール画像
↓
ユーザー名
↓
Notifications
↓
Downloads
↓
Continue Watching
↓
My List

ここでは、movies から一部の作品を取り出して表示しています。

final watchingMovies = movies.take(4).toList();
final myListMovies = movies.skip(2).take(4).toList();

takeskip を使って、仮のリストを作っています。

コード意味
movies.take(4).toList()先頭から4件取り出す
movies.skip(2).take(4).toList()先頭2件を飛ばしてから4件取り出す

本格的なアプリでは、ここにユーザーごとの視聴履歴や保存リストが入ります。


共通Widgetを再利用する

このアプリでは、いくつかのWidgetを複数の画面で使い回しています。

共通Widget使う場所
ContentRowHome画面、My Netaflix画面
RowTitleHome画面、My Netaflix画面
MovieDetailPageHome、Search、Clipsから遷移
shareNetaflixMovie詳細画面、Clips画面
MovieItemアプリ全体の作品データ

同じ処理や同じUIを共通化すると、コードが読みやすくなります。

同じUIを毎回書く
↓
コードが長くなる

共通Widgetにする
↓
再利用できる
↓
修正もしやすい

たとえば、ContentRow のデザインを変えると、Home画面とMy Netaflix画面の両方に反映できます。


アプリ全体のデータの流れ

このアプリの一番大切な流れは、MovieItem が画面から画面へ渡されていくことです。

movies
↓
HomePageで一覧表示
↓
作品をタップ
↓
MovieDetailPage(movie)
↓
Playをタップ
↓
MoviePlayerPage(movie)
↓
youtubeVideoIdで再生

検索画面でも同じです。

movies
↓
SearchPageで検索
↓
SearchResultTile(movie)
↓
MovieDetailPage(movie)

Clips画面でも同じです。

movies
↓
ClipsPage
↓
ClipPageItem(movie)
↓
共有する

アプリ全体で、作品データが中心になっています。

画面はそれぞれ違う見た目ですが、中心にあるデータは同じ MovieItem です。


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

Q. コードが長くて、どこから読めばよいか分かりません。

まずは、main() から読んでください。

void main() {
  runApp(const NetflixLikeApp());
}

次に、NetflixLikeAppNetflixShell、各画面の順番で読みます。

main
↓
NetflixLikeApp
↓
NetflixShell
↓
HomePage / SearchPage / ClipsPage / MyNetflixPage

いきなり細かいUIの余白や色を読むのではなく、画面の流れから読むと理解しやすいです。


Q. StatelessWidgetとStatefulWidgetの使い分けが分かりません。

状態が変わらない画面は、StatelessWidget で作ります。

表示するだけ
↓
StatelessWidget

状態が変わる画面は、StatefulWidget で作ります。

タブ選択が変わる
検索文字が変わる
動画Controllerを管理する
↓
StatefulWidget

今回のアプリでは、NetflixShellSearchPageMoviePlayerPage などが StatefulWidget になりやすいです。


Q. 作品をタップしたときに詳細画面へ移動しません。

Navigator.of(context).push が正しく書かれているか確認します。

Navigator.of(context).push(
  MaterialPageRoute<void>(
    builder: (context) => MovieDetailPage(movie: movie),
  ),
);

また、MovieDetailPage 側で movie を受け取っているか確認します。

final MovieItem movie;

画面遷移では、「遷移すること」と「データを渡すこと」の両方が大切です。


Q. YouTube動画が違う作品になってしまいます。

MoviePlayerPage に正しい movie を渡しているか確認します。

MoviePlayerPage(movie: movie)

そして、再生側で movie.youtubeVideoId を使っているか確認します。

videoId: widget.movie.youtubeVideoId,

作品ごとの動画IDが間違っている場合もあるため、movies の中の youtubeVideoId も確認しましょう。


Q. BottomNavigationで画面が切り替わりません。

onTap の中で、setState を使って currentIndex を更新しているか確認します。

onTap: (index) {
  setState(() {
    currentIndex = index;
  });
},

また、body が次のようになっているか確認します。

body: pages[currentIndex],

この2つがそろうことで、タブを押したときに画面が切り替わります。


チャレンジ

チャレンジ1:作品カードから動画再生までの流れを説明しよう

次の流れを、自分の言葉で説明してみましょう。

ContentCardをタップ
↓
MovieDetailPageへ移動
↓
Playボタンを押す
↓
MoviePlayerPageへ移動
↓
YouTube動画を再生

チャレンジ2:SearchPageで検索結果が変わる流れを説明しよう

次の流れを説明してください。

TextFieldに入力
↓
onChangedが呼ばれる
↓
queryが変わる
↓
filteredMoviesが再計算される
↓
SearchResultTileが表示される

チャレンジ3:ClipsPageの共有機能の流れを説明しよう

次の流れを説明してください。

ClipPageItemのShareを押す
↓
shareMovie(item)
↓
shareNetaflixMovie(context: context, movie: movie)
↓
SharePlus.instance.share
↓
共有画面が開く

チャレンジ4:共通Widgetを3つ挙げよう

このアプリで再利用されている共通Widgetや共通関数を3つ挙げてください。

例:

ContentRow
RowTitle
shareNetaflixMovie

チャレンジの答え

チャレンジ1の答え

作品カードをタップすると、Navigator.of(context).pushMovieDetailPage(movie: movie) に移動します。

詳細画面では、受け取った movie のタイトル、説明文、背景画像などを表示します。

Playボタンを押すと、さらに MoviePlayerPage(movie: movie) に移動します。

MoviePlayerPage では、movie.youtubeVideoId を使ってYouTube動画を再生します。


チャレンジ2の答え

Search画面では、TextField に文字を入力すると onChanged が呼ばれます。

onChanged の中で setState を使い、query に入力文字を保存します。

query が変わると、filteredMovies が現在の検索文字に合わせて再計算されます。

その結果、条件に合う作品だけが SearchResultTile として表示されます。


チャレンジ3の答え

Clips画面でShareを押すと、shareMovie(item) が呼ばれます。

shareMovie の中では、共通関数 shareNetaflixMovie を呼び出します。

shareNetaflixMovie は、作品タイトルとYouTube URLを共有文として作り、SharePlus.instance.share を実行します。

そのとき、iOS共有エラー対策として sharePositionOrigin も指定しています。


チャレンジ4の答え

再利用されている共通Widgetや共通関数の例は、次の通りです。

ContentRow
RowTitle
MovieDetailPage
shareNetaflixMovie
MovieItem

ContentRowRowTitle は、Home画面とMy Netaflix画面で使われています。

shareNetaflixMovie は、詳細画面とClips画面から使えます。

MovieItem は、アプリ全体の作品データとして使われています。


この節のまとめ

この節では、完成コードの後半として、作品カードから詳細画面、動画再生画面、検索画面、Clips画面、共有機能、My Netaflix画面までのつながりを確認しました。

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

  • ContentCard は、作品1件分を表示し、タップすると詳細画面へ移動する。
  • MovieDetailPage(movie: movie) により、選んだ作品の詳細を表示できる。
  • 詳細画面のPlayボタンから MoviePlayerPage(movie: movie) へ移動する。
  • MoviePlayerPage では、movie.youtubeVideoId を使ってYouTube動画を再生する。
  • MoviePlayerPage は、Controller管理があるため StatefulWidget が向いている。
  • initState でControllerを作り、dispose で片付ける。
  • SearchPage は、queryfilteredMovies を使ってリアルタイム検索を行う。
  • SearchResultTile をタップすると、検索結果から詳細画面へ移動できる。
  • ClipsPage は、PageView.builder で縦型動画風UIを作る。
  • shareNetaflixMovie は、共有処理をまとめた共通関数。
  • shareOriginFromContext により、iOS共有エラーを防ぎやすくしている。
  • MyNetflixPage は、プロフィール、通知、ダウンロード、視聴中リスト、My Listを表示する。
  • ContentRowRowTitle のような共通Widgetを再利用すると、コードが整理される。
  • アプリ全体では、MovieItem が中心データとして画面間を移動している。

次のステップ

次の節では、完成したアプリを振り返りながら、「どこを変更すれば自分のオリジナルアプリにできるのか」を整理します。

作品データ、色、アイコン、アプリ名、画面構成、検索対象、共有文などをカスタマイズすることで、NETAFLIX風アプリを自分だけの動画アプリに発展させていきます。

教材トップへ戻る