【完成コードを読む】NETFLIX風アプリの全体構造を上から順番に理解する
この節で学ぶこと
これまでの節では、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 | アプリ全体の設定を行う |
NetflixShell | BottomNavigationで画面を切り替える |
HomePage | ホーム画面 |
SearchPage | 検索画面 |
ClipsPage | 縦型動画風の画面 |
MyNetflixPage | プロフィール・通知・My List画面 |
MovieDetailPage | 作品詳細画面 |
MoviePlayerPage | YouTube動画再生画面 |
まずは、アプリの入口から見ていきます。
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 | 最初に表示する画面 |
今回のアプリでは、home に NetflixShell を指定しています。
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 | 表示する画面 |
|---|---|
0 | Home |
1 | Search |
2 | Clips |
3 | My 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 | 横長背景画像 |
youtubeVideoId | YouTube動画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を使ってアプリ全体の設定を行う。MaterialAppのhomeにNetflixShellを指定している。NetflixShellは、BottomNavigationで4つの画面を切り替える中心。currentIndexによって、表示する画面が決まる。setStateでcurrentIndexを更新すると、タブ画面が切り替わる。MovieItemは、作品1件分のデータの形を決めるクラス。moviesは、アプリ全体で使う作品一覧データ。HomePage、SearchPage、ClipsPage、MyNetflixPageは、同じ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を作る
videoId に widget.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();
take と skip を使って、仮のリストを作っています。
| コード | 意味 |
|---|---|
movies.take(4).toList() | 先頭から4件取り出す |
movies.skip(2).take(4).toList() | 先頭2件を飛ばしてから4件取り出す |
本格的なアプリでは、ここにユーザーごとの視聴履歴や保存リストが入ります。
共通Widgetを再利用する
このアプリでは、いくつかのWidgetを複数の画面で使い回しています。
| 共通Widget | 使う場所 |
|---|---|
ContentRow | Home画面、My Netaflix画面 |
RowTitle | Home画面、My Netaflix画面 |
MovieDetailPage | Home、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());
}
次に、NetflixLikeApp、NetflixShell、各画面の順番で読みます。
main
↓
NetflixLikeApp
↓
NetflixShell
↓
HomePage / SearchPage / ClipsPage / MyNetflixPage
いきなり細かいUIの余白や色を読むのではなく、画面の流れから読むと理解しやすいです。
Q. StatelessWidgetとStatefulWidgetの使い分けが分かりません。
状態が変わらない画面は、StatelessWidget で作ります。
表示するだけ
↓
StatelessWidget
状態が変わる画面は、StatefulWidget で作ります。
タブ選択が変わる
検索文字が変わる
動画Controllerを管理する
↓
StatefulWidget
今回のアプリでは、NetflixShell、SearchPage、MoviePlayerPage などが 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).push で MovieDetailPage(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
ContentRow や RowTitle は、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は、queryとfilteredMoviesを使ってリアルタイム検索を行う。SearchResultTileをタップすると、検索結果から詳細画面へ移動できる。ClipsPageは、PageView.builderで縦型動画風UIを作る。shareNetaflixMovieは、共有処理をまとめた共通関数。shareOriginFromContextにより、iOS共有エラーを防ぎやすくしている。MyNetflixPageは、プロフィール、通知、ダウンロード、視聴中リスト、My Listを表示する。ContentRowやRowTitleのような共通Widgetを再利用すると、コードが整理される。- アプリ全体では、
MovieItemが中心データとして画面間を移動している。
次のステップ
次の節では、完成したアプリを振り返りながら、「どこを変更すれば自分のオリジナルアプリにできるのか」を整理します。
作品データ、色、アイコン、アプリ名、画面構成、検索対象、共有文などをカスタマイズすることで、NETAFLIX風アプリを自分だけの動画アプリに発展させていきます。
