
【詳細画面UI】作品タイトル・説明文・メタ情報・アクションボタンを表示する
この節で学ぶこと
前の節では、MoviePosterCard を使って、作品画像をタップしたら詳細画面へ移動する方法を学びました。
今回の節では、移動先である 作品詳細画面 を見ていきます。
作品詳細画面では、次のような情報を表示します。
作品の背景画像
作品タイトル
年・年齢制限・シーズン情報
作品説明文
Play Trailerボタン
Downloadボタン
My List / Like / Share などのアクションボタン
エピソード一覧
動画アプリでは、一覧画面だけでなく、作品ごとの詳細画面がとても大切です。
ユーザーはポスター画像を見て興味を持ち、詳細画面で「見るかどうか」を判断します。
つまり、詳細画面は 作品の魅力を伝えて、次の行動につなげる画面 です。
今回見るコード
作品詳細画面は、MovieDetailPage というクラスで作っています。
class MovieDetailPage extends StatefulWidget {
const MovieDetailPage({
super.key,
required this.movie,
});
final MovieItem movie;
@override
State<MovieDetailPage> createState() => _MovieDetailPageState();
}
この画面は、MovieItem を受け取っています。
final MovieItem movie;
つまり、Home画面でタップされた作品データが、この詳細画面に渡されています。
前の節で見たように、ポスターカードから詳細画面を開くときは、次のように書いていました。
MovieDetailPage(movie: movie)
この movie が、詳細画面の中で使われます。
作品データが詳細画面へ渡る流れ
まず、データの流れを整理しましょう。
Home画面の作品リスト
↓
items[index] で1作品分を取り出す
↓
MoviePosterCard(movie: movie) に渡す
↓
カードをタップする
↓
MovieDetailPage(movie: movie) を開く
↓
詳細画面で movie.title や movie.description を表示する
この流れが分かると、詳細画面のコードがかなり読みやすくなります。
一覧画面と詳細画面は別々の画面ですが、movie というデータでつながっています。
StatefulWidgetになっている理由
MovieDetailPage は、StatefulWidget です。
class MovieDetailPage extends StatefulWidget {
なぜ StatelessWidget ではなく、StatefulWidget なのでしょうか。
理由は、詳細画面の中で状態が変わるボタンがあるからです。
たとえば、次のようなボタンがあります。
My List
Like
Share
このうち、My List や Like は、押したときにON/OFFの見た目を変えたいボタンです。
Likeを押す
↓
ハートの見た目が変わる
My Listを押す
↓
保存済みの見た目に変わる
このように、ユーザー操作によって画面の一部が変わる場合は、StatefulWidget を使います。
Stateクラスを確認しよう
MovieDetailPage の状態は、_MovieDetailPageState で管理しています。
class _MovieDetailPageState extends State<MovieDetailPage> {
bool isInMyList = false;
bool isLiked = false;
ここでは、2つの状態を持っています。
| 変数 | 役割 |
|---|---|
isInMyList | My Listに追加されているか |
isLiked | Likeされているか |
最初はどちらも false です。
bool isInMyList = false;
bool isLiked = false;
つまり、画面を開いた直後は、まだリストにも入っておらず、Likeもされていない状態です。
buildの全体像
詳細画面の build は、次のような構造になっています。
@override
Widget build(BuildContext context) {
final movie = widget.movie;
return Scaffold(
backgroundColor: NetflixColors.black,
body: CustomScrollView(
slivers: [
DetailHero(movie: movie),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MetaLine(movie: movie),
const SizedBox(height: 16),
PlayButton(
label: 'Play Trailer',
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => YouTubePlayerPage(movie: movie),
),
);
},
),
const SizedBox(height: 10),
HeroActionButton(
icon: Icons.download_rounded,
label: 'Download',
background: NetflixColors.darkGray,
foreground: NetflixColors.white,
onTap: () {},
),
const SizedBox(height: 18),
Text(
movie.description,
style: const TextStyle(
color: NetflixColors.white,
fontSize: 14.5,
height: 1.55,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 14),
Text(
'Starring: ${movie.title} cast, Netaflix Originals',
style: const TextStyle(
color: NetflixColors.muted,
fontSize: 12.5,
height: 1.45,
),
),
const SizedBox(height: 22),
DetailActions(
isInMyList: isInMyList,
isLiked: isLiked,
onToggleMyList: () {
setState(() {
isInMyList = !isInMyList;
});
},
onToggleLike: () {
setState(() {
isLiked = !isLiked;
});
},
onShare: () => shareNetaflixMovie(
context: context,
movie: movie,
),
),
const SizedBox(height: 26),
Episodes(movie: movie),
],
),
),
),
],
),
);
}
かなり長く見えますが、画面の流れは自然です。
背景画像エリア
↓
メタ情報
↓
Play Trailerボタン
↓
Downloadボタン
↓
説明文
↓
出演情報風テキスト
↓
My List / Like / Share
↓
エピソード一覧
上から順番に、詳細画面を組み立てています。
widget.movieとは?
build の最初に、次のコードがあります。
final movie = widget.movie;
StatefulWidget の State クラスの中では、親Widgetが持っている値に widget を通してアクセスします。
今回の場合、MovieDetailPage が持っている movie を使いたいので、次のように書きます。
widget.movie
ただ、毎回 widget.movie と書くと少し長くなります。
そこで、最初に変数へ入れています。
final movie = widget.movie;
こうしておくと、以降は次のように短く書けます。
movie.title
movie.description
movie.backdropUrl
Scaffoldで詳細画面の土台を作る
詳細画面の土台は、Scaffold で作っています。
return Scaffold(
backgroundColor: NetflixColors.black,
body: CustomScrollView(
slivers: [
...
],
),
);
Scaffold は、Flutterで画面の基本構造を作るためのWidgetです。
今回は、背景色を黒にしています。
backgroundColor: NetflixColors.black,
動画アプリ風のUIでは、黒背景に白文字を合わせることで、映像コンテンツが目立ちやすくなります。
CustomScrollViewを使う理由
詳細画面では、CustomScrollView を使っています。
body: CustomScrollView(
slivers: [
DetailHero(movie: movie),
SliverToBoxAdapter(
child: Padding(
...
),
),
],
),
CustomScrollView は、複雑なスクロール画面を作るときに便利です。
今回の詳細画面では、上部に大きな画像エリアがあり、その下に説明文やボタンが続きます。
大きな画像エリア
↓
詳細情報
↓
エピソード一覧
このように、上部の見せ場と下の内容を組み合わせたいときに、CustomScrollView と Sliver を使うと作りやすくなります。
Sliverとは?
Sliver は、スクロール画面の中に入れる特別な部品です。
通常のWidgetとは少し違い、スクロールに合わせた表示を作るために使います。
今回の詳細画面では、次の2つが使われています。
slivers: [
DetailHero(movie: movie),
SliverToBoxAdapter(
child: Padding(
...
),
),
],
| Sliver | 役割 |
|---|---|
DetailHero | 上部の大きな作品画像エリア |
SliverToBoxAdapter | 通常のWidgetをSliverの中に入れるための変換役 |
まず上に DetailHero を置き、その下に通常の詳細情報を表示しています。
DetailHeroとは?
DetailHero は、詳細画面の一番上にある大きな画像エリアです。
作品の背景画像、戻るボタン、タイトル、作品情報などを表示します。
DetailHero(movie: movie),
このように、movie を渡して使っています。
DetailHero の中では、主に次のような情報を使います。
movie.backdropUrl
movie.title
movie.year
movie.rating
movie.seasonLabel
movie.category
詳細画面では、まず大きなビジュアルで作品の雰囲気を伝えます。
そのため、一覧カードで使った posterUrl ではなく、横長の背景画像である backdropUrl を使います。
DetailHeroのイメージ
DetailHero は、次のような見た目です。
┌────────────────────┐
│ 背景画像 │
│ 戻る │
│ │
│ 作品タイトル │
│ 2021 ・ 18+ ・ Season 1 │
└────────────────────┘
上部に大きな背景画像があり、その上に戻るボタンや作品タイトルが重なっています。
一覧画面ではポスター画像で作品を見せていましたが、詳細画面では背景画像を使って、より映画らしい印象を作っています。
DetailHeroのコード
DetailHero は、おおよそ次のような構造です。
class DetailHero extends StatelessWidget {
const DetailHero({
super.key,
required this.movie,
});
final MovieItem movie;
@override
Widget build(BuildContext context) {
return SliverAppBar(
expandedHeight: 430,
pinned: true,
backgroundColor: NetflixColors.black,
leading: IconButton(
icon: const Icon(
Icons.arrow_back_rounded,
color: NetflixColors.white,
),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
Image.network(
movie.backdropUrl,
fit: BoxFit.cover,
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.05),
Colors.black.withValues(alpha: 0.55),
NetflixColors.black,
],
stops: const [0.2, 0.68, 1.0],
),
),
),
Positioned(
left: 18,
right: 18,
bottom: 24,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
movie.title,
style: const TextStyle(
color: NetflixColors.white,
fontSize: 34,
fontWeight: FontWeight.w900,
height: 1.05,
),
),
const SizedBox(height: 10),
MetaLine(movie: movie),
],
),
),
],
),
),
);
}
}
細かい部分は環境によって少し違っても大丈夫です。
大切なのは、次の構造です。
SliverAppBar
↓
FlexibleSpaceBar
↓
Stack
↓
背景画像
↓
グラデーション
↓
タイトルとメタ情報
SliverAppBarで上部の大きなエリアを作る
DetailHero では、SliverAppBar を使っています。
return SliverAppBar(
expandedHeight: 430,
pinned: true,
backgroundColor: NetflixColors.black,
...
);
SliverAppBar は、スクロールに反応するAppBarです。
通常のAppBarよりも大きな表示エリアを作ることができます。
今回のように、上部に大きな作品画像を表示したい場合に便利です。
expandedHeightとは?
expandedHeight は、開いている状態の高さです。
expandedHeight: 430,
つまり、詳細画面を開いた直後は、上部のヒーロー画像エリアが高さ430で表示されます。
数値を大きくすると、背景画像がより大きく見えます。
expandedHeight: 500,
逆に、コンパクトにしたい場合は小さくします。
expandedHeight: 360,
作品の雰囲気をしっかり見せたいなら、少し大きめにすると印象的になります。
pinnedとは?
SliverAppBar には、次の指定があります。
pinned: true,
pinned は、スクロールしたときにAppBarを上部に残すかどうかを決める指定です。
true にすると、スクロールしても上部にAppBarが残ります。
詳細画面をスクロールする
↓
大きな画像部分は縮む
↓
上部のバーは残る
ユーザーが下までスクロールしたあとでも、戻る操作をしやすくなります。
戻るボタンを表示する
詳細画面の左上には、戻るボタンがあります。
leading: IconButton(
icon: const Icon(
Icons.arrow_back_rounded,
color: NetflixColors.white,
),
onPressed: () => Navigator.pop(context),
),
IconButton は、アイコンを押せるボタンにするWidgetです。
ここでは、左向きの矢印アイコンを表示しています。
Icons.arrow_back_rounded
押したときには、次の処理が動きます。
Navigator.pop(context)
これは、前の画面に戻る処理です。
つまり、詳細画面からHome画面へ戻れます。
FlexibleSpaceBarとは?
SliverAppBar の中には、FlexibleSpaceBar があります。
flexibleSpace: FlexibleSpaceBar(
background: Stack(
...
),
),
FlexibleSpaceBar は、SliverAppBar の広がった部分に背景やタイトルなどを置くためのWidgetです。
今回は、この中に Stack を入れて、背景画像、グラデーション、タイトルを重ねています。
FlexibleSpaceBar
└── Stack
├── 背景画像
├── 黒いグラデーション
└── タイトル・メタ情報
この構造によって、映画アプリらしい大きなビジュアルエリアを作っています。
詳細情報エリアを作る
DetailHero の下には、作品の説明やボタンを表示する詳細情報エリアがあります。
コードでは、SliverToBoxAdapter の中に通常のWidgetを入れています。
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MetaLine(movie: movie),
const SizedBox(height: 16),
// ボタンや説明文が続く
],
),
),
),
SliverToBoxAdapter は、通常のWidgetを CustomScrollView の中で使えるようにするためのWidgetです。
この中に、メタ情報、ボタン、説明文、アクションボタンなどを順番に並べています。
Paddingで詳細情報の余白を作る
詳細情報エリアには、左右と上下に余白を入れています。
padding: const EdgeInsets.fromLTRB(18, 16, 18, 32),
これは、左・上・右・下の余白を指定する書き方です。
| 位置 | 余白 |
|---|---|
| 左 | 18 |
| 上 | 16 |
| 右 | 18 |
| 下 | 32 |
スマホ画面では、文字やボタンが端に寄りすぎると読みにくくなります。
そのため、左右に適度な余白を作っています。
Columnで情報を上から順番に並べる
詳細情報は、Column で縦に並べています。
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MetaLine(movie: movie),
const SizedBox(height: 16),
PlayButton(...),
const SizedBox(height: 10),
HeroActionButton(...),
const SizedBox(height: 18),
Text(movie.description),
const SizedBox(height: 14),
Text('Starring: ...'),
const SizedBox(height: 22),
DetailActions(...),
const SizedBox(height: 26),
Episodes(movie: movie),
],
)
並びとしては、次のようになります。
メタ情報
↓
Play Trailerボタン
↓
Downloadボタン
↓
説明文
↓
出演情報風テキスト
↓
My List / Like / Share
↓
エピソード一覧
作品詳細画面では、ユーザーが上から自然に読める順番に情報を置くことが大切です。
crossAxisAlignmentで左寄せにする
Column には、次の指定があります。
crossAxisAlignment: CrossAxisAlignment.start,
これは、中のWidgetを左寄せにする指定です。
詳細画面では、説明文やメタ情報を左から読ませたいので、左寄せが合っています。
2021 ・ 18+ ・ Season 1
Play Trailer
Download
作品説明文...
動画アプリの詳細画面でも、本文情報は左寄せで表示されることが多いです。
メタ情報を表示する
作品の年、年齢制限、シーズン、カテゴリなどは、MetaLine で表示しています。
MetaLine(movie: movie),
MetaLine は、作品の基本情報を1行でまとめて表示するWidgetです。
たとえば、次のような情報を表示します。
98% Match 2021 18+ Season 1 TV Drama HD
作品を見る前に、ジャンルや年齢制限を確認できるようにするための情報です。
MetaLineのコード
MetaLine は、次のような構造です。
class MetaLine extends StatelessWidget {
const MetaLine({
super.key,
required this.movie,
});
final MovieItem movie;
@override
Widget build(BuildContext context) {
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 9,
runSpacing: 8,
children: [
Text(
'${movie.matchRate}% Match',
style: const TextStyle(
color: Color(0xFF46D369),
fontSize: 13,
fontWeight: FontWeight.w900,
),
),
Text(
movie.year,
style: const TextStyle(
color: NetflixColors.white,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
RatingPill(label: movie.rating),
Text(
movie.seasonLabel,
style: const TextStyle(
color: NetflixColors.white,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
Text(
movie.category,
style: const TextStyle(
color: NetflixColors.white,
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
const Icon(
Icons.hd_rounded,
color: NetflixColors.white,
size: 19,
),
],
);
}
}
MetaLine では、Wrap を使って複数の情報を横に並べています。
Wrapとは?
Wrap は、横に並べた要素が入りきらなくなったとき、自動で次の行に折り返してくれるWidgetです。
Wrap(
spacing: 9,
runSpacing: 8,
children: [
...
],
)
Row でも横並びはできますが、Row は画面幅を超えるとエラーになることがあります。
一方で、Wrap は幅が足りなくなったら自然に折り返してくれます。
98% Match 2021 18+ Season 1
TV Drama HD
スマホ画面では横幅が限られているため、メタ情報のように複数の小さな要素を並べる場合は Wrap が使いやすいです。
spacingとrunSpacing
Wrap には、次の指定があります。
spacing: 9,
runSpacing: 8,
それぞれの意味は次の通りです。
| 指定 | 内容 |
|---|---|
spacing | 横方向の間隔 |
runSpacing | 折り返したときの縦方向の間隔 |
メタ情報が横に並んだとき、要素同士がくっつきすぎないように spacing を入れています。
また、画面幅が狭くて折り返した場合にも、行同士が近すぎないように runSpacing を入れています。
Match率を緑で表示する
MetaLine の最初には、マッチ率を表示しています。
Text(
'${movie.matchRate}% Match',
style: const TextStyle(
color: Color(0xFF46D369),
fontSize: 13,
fontWeight: FontWeight.w900,
),
),
movie.matchRate は、作品データに入っているおすすめ度のような数字です。
たとえば、98 が入っている場合、画面には次のように表示されます。
98% Match
緑色で表示することで、ポジティブな印象を出しています。
color: Color(0xFF46D369),
RatingPillで年齢制限を表示する
年齢制限は、RatingPill という小さなラベルで表示しています。
RatingPill(label: movie.rating),
たとえば、movie.rating が 18+ の場合、画面には小さな枠付きで 18+ と表示されます。
18+
ただ文字として表示するだけでなく、枠付きのラベルにすることで、年齢制限の情報だと分かりやすくしています。
RatingPillのコード
RatingPill は、次のようなWidgetです。
class RatingPill extends StatelessWidget {
const RatingPill({
super.key,
required this.label,
});
final String label;
@override
Widget build(BuildContext context) {
return Container(
height: 20,
padding: const EdgeInsets.symmetric(horizontal: 7),
alignment: Alignment.center,
decoration: BoxDecoration(
color: NetflixColors.darkGray,
borderRadius: BorderRadius.circular(3),
border: Border.all(color: Colors.white24),
),
child: Text(
label,
style: const TextStyle(
color: NetflixColors.white,
fontSize: 11,
fontWeight: FontWeight.w900,
),
),
);
}
}
Container を使って、高さ、余白、背景色、枠線をまとめて指定しています。
Play Trailerボタンを表示する
メタ情報の下には、Play Trailerボタンがあります。
SizedBox(
width: double.infinity,
height: 44,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => YouTubePlayerPage(
movie: movie,
),
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: NetflixColors.white,
foregroundColor: NetflixColors.black,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
),
icon: const Icon(
Icons.play_arrow_rounded,
size: 28,
),
label: const Text(
'Play Trailer on YouTube',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
),
),
),
),
このボタンを押すと、YouTube再生画面へ移動します。
詳細画面で一番大事なアクションなので、白背景の目立つボタンにしています。
width: double.infinityとは?
Playボタンには、次の指定があります。
width: double.infinity,
これは、横幅いっぱいに広げるという意味です。
スマホ画面の左右余白を除いた幅いっぱいにボタンが広がります。
┌────────────────────┐
│ ▶ Play Trailer │
└────────────────────┘
詳細画面で重要なボタンは、横幅いっぱいにすると押しやすくなります。
ElevatedButton.iconとは?
ElevatedButton.icon は、アイコン付きのボタンを作るWidgetです。
ElevatedButton.icon(
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Play Trailer on YouTube'),
onPressed: () {},
)
左にアイコン、右にテキストを表示できます。
今回のボタンでは、再生アイコンと文字を組み合わせています。
▶ Play Trailer on YouTube
動画アプリのPlayボタンとして分かりやすい見た目です。
onPressedでYouTube再生画面へ移動する
Play Trailerボタンを押したときは、次の処理が動きます。
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => YouTubePlayerPage(
movie: movie,
),
),
);
これは、YouTube再生画面へ移動する処理です。
ここでも、movie を渡しています。
YouTubePlayerPage(movie: movie)
これにより、YouTube再生画面では、その作品の youtubeVideoId を使って動画を再生できます。
Downloadボタンを表示する
Play Trailerボタンの下には、Downloadボタンがあります。
SizedBox(
width: double.infinity,
height: 44,
child: ElevatedButton.icon(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: NetflixColors.darkGray,
foregroundColor: NetflixColors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
),
icon: const Icon(
Icons.download_rounded,
size: 24,
),
label: const Text(
'Download',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
),
),
),
),
今のコードでは、onPressed: () {} なので、押しても実際のダウンロードは行われません。
ただし、UIとしては「ダウンロードできそうなボタン」を表示しています。
教材用アプリでは、まず見た目と構造を理解することが大切です。
PlayボタンとDownloadボタンの違い
2つのボタンは、見た目を変えています。
| ボタン | 背景色 | 文字色 | 役割 |
|---|---|---|---|
| Play Trailer | 白 | 黒 | 一番重要なアクション |
| Download | 濃いグレー | 白 | 補助的なアクション |
Play Trailerは、ユーザーに一番押してほしいボタンです。
そのため、白背景で目立たせています。
Downloadは補助的な操作なので、濃いグレーにしています。
このように、ボタンの重要度によって見た目を変えると、ユーザーが自然に操作しやすくなります。
作品説明文を表示する
ボタンの下には、作品説明文を表示しています。
Text(
movie.description,
style: const TextStyle(
color: NetflixColors.white,
fontSize: 14,
height: 1.42,
fontWeight: FontWeight.w500,
),
),
movie.description は、作品データに入っている説明文です。
たとえば、Squid Game の場合は、次のような説明文が入っています。
Hundreds of cash-strapped players accept a strange invitation...
詳細画面では、この説明文を表示することで、作品の内容を伝えています。
説明文のTextStyle
説明文の見た目は、次のように指定しています。
style: const TextStyle(
color: NetflixColors.white,
fontSize: 14,
height: 1.42,
fontWeight: FontWeight.w500,
),
それぞれの意味は次の通りです。
| 指定 | 内容 |
|---|---|
color | 文字色を白にする |
fontSize | 文字サイズを14にする |
height | 行間を調整する |
fontWeight | 少しだけ太めにする |
説明文は長くなることがあるので、行間が大切です。
height: 1.42,
行間が狭すぎると読みにくくなります。
少しゆとりを持たせることで、スマホでも読みやすくなります。
出演情報風テキストを表示する
説明文の下には、出演情報のようなテキストを表示しています。
Text(
'Starring: Lee Jung-jae, Park Hae-soo, Jung Ho-yeon',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.62),
fontSize: 12.5,
height: 1.4,
),
),
これは、作品の補足情報として表示しています。
実際のアプリでは、キャスト名や監督名、ジャンルなどを表示することがあります。
今回の教材では、固定の文字列として書いています。
Starring: Lee Jung-jae, Park Hae-soo, Jung Ho-yeon
文字色は少し薄くしています。
color: Colors.white.withValues(alpha: 0.62),
メインの説明文よりも控えめに見せるためです。
アクションボタンを横に並べる
詳細画面には、My List、Like、Shareのアクションボタンがあります。
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
DetailAction(
icon: isAddedToList ? Icons.check : Icons.add,
label: 'My List',
active: isAddedToList,
onTap: () {
setState(() {
isAddedToList = !isAddedToList;
});
},
),
DetailAction(
icon: isLiked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
label: 'Like',
active: isLiked,
onTap: () {
setState(() {
isLiked = !isLiked;
});
},
),
DetailAction(
icon: Icons.share_outlined,
label: 'Share',
active: false,
onTap: () {
shareNetaflixMovie(
context: context,
movie: movie,
);
},
),
],
),
この3つは、動画アプリの詳細画面でよく見る操作です。
My List Like Share
横に並べることで、ユーザーがすぐに操作できるようにしています。
My Listボタンの状態を切り替える
My Listボタンでは、isAddedToList を使っています。
icon: isAddedToList ? Icons.check : Icons.add,
active: isAddedToList,
isAddedToList が false のときは、追加アイコンを表示します。
Icons.add
true のときは、チェックアイコンを表示します。
Icons.check
押したときは、次の処理で状態を反転します。
setState(() {
isAddedToList = !isAddedToList;
});
! は、真偽値を反対にする記号です。
false → true
true → false
これにより、ボタンを押すたびに追加・解除の見た目が切り替わります。
Likeボタンの状態を切り替える
Likeボタンでは、isLiked を使っています。
icon: isLiked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
active: isLiked,
isLiked が false のときは、空のハートを表示します。
Icons.favorite_border_rounded
true のときは、塗りつぶしのハートを表示します。
Icons.favorite_rounded
押したときは、次のように状態を反転します。
setState(() {
isLiked = !isLiked;
});
これで、Likeボタンを押したときに見た目が変わるようになります。
Shareボタンで共有する
Shareボタンでは、次の処理を呼び出しています。
shareNetaflixMovie(
context: context,
movie: movie,
);
これは、作品タイトルやYouTubeリンクを共有するための関数です。
詳細画面では、今見ている作品を共有したいので、ここでも movie を渡しています。
movie: movie
これにより、共有する文章の中に作品タイトルやYouTube動画IDを入れることができます。
DetailActionとは?
My List、Like、Shareの見た目は、DetailAction という部品で作っています。
class DetailAction extends StatelessWidget {
const DetailAction({
super.key,
required this.icon,
required this.label,
required this.active,
required this.onTap,
});
final IconData icon;
final String label;
final bool active;
final VoidCallback onTap;
受け取っている値は次の通りです。
| 値 | 役割 |
|---|---|
icon | 表示するアイコン |
label | 表示する文字 |
active | 選択中かどうか |
onTap | タップしたときの処理 |
同じ形のボタンを3つ並べたいので、部品化しています。
DetailActionの見た目
DetailAction の見た目は、次のようになっています。
@override
Widget build(BuildContext context) {
final color = active ? NetflixColors.white : NetflixColors.muted;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
width: 88,
child: Column(
children: [
Icon(icon, color: color, size: 28),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
color: color,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
active が true のときは白、false のときはグレーにしています。
final color = active ? NetflixColors.white : NetflixColors.muted;
これにより、選択中のボタンとそうでないボタンの違いが分かります。
よくあるつまずきポイント
Q. My ListやLikeを押しても見た目が変わりません。
setState が入っているか確認してください。
setState(() {
isLiked = !isLiked;
});
状態を変えるだけでは画面は更新されません。
画面を更新したい場合は、setState の中で状態を変更します。
Q. 詳細画面に違う作品が表示されます。
MovieDetailPage(movie: movie) に、正しい movie が渡っているか確認してください。
builder: (context) => MovieDetailPage(movie: movie),
一覧画面から渡したデータが、詳細画面の表示内容になります。
Q. Play Trailerを押しても動画画面に移動しません。
Navigator.push の中で、YouTubePlayerPage(movie: movie) を開いているか確認してください。
builder: (context) => YouTubePlayerPage(movie: movie),
また、作品データの youtubeVideoId も確認しましょう。
Q. Shareでエラーが出ます。
iOSやiPadでは、共有メニューの表示位置が必要になることがあります。
そのため、共有処理では sharePositionOrigin を指定しておくと安定します。
shareNetaflixMovie(
context: context,
movie: movie,
);
この関数の中で、画面サイズに合わせた共有位置を指定しておきます。
チャレンジ
チャレンジ1:Play Trailerの文字を日本語にしよう
次のコードを探してください。
'Play Trailer on YouTube'
これを次のように変更します。
'予告編を再生'
詳細画面のボタン文字が変わるか確認してください。
チャレンジ2:説明文の文字サイズを少し大きくしよう
次のコードを探してください。
fontSize: 14,
これを次のように変更します。
fontSize: 15,
説明文が少し読みやすくなるか確認しましょう。
チャレンジ3:LikeボタンのラベルをFavoriteに変えよう
次のコードを探してください。
label: 'Like',
これを次のように変更します。
label: 'Favorite',
ボタンの文字が変わるか確認してください。
チャレンジ4:Downloadボタンの背景色を少し明るくしよう
次のコードを探してください。
backgroundColor: NetflixColors.darkGray,
これを次のように変更します。
backgroundColor: const Color(0xFF333333),
Downloadボタンが少し明るくなります。
チャレンジの答え
チャレンジ1の答え
変更前:
'Play Trailer on YouTube'
変更後:
'予告編を再生'
Play Trailerボタンの文字が日本語になります。
チャレンジ2の答え
変更前:
fontSize: 14,
変更後:
fontSize: 15,
説明文の文字が少し大きくなります。
チャレンジ3の答え
変更前:
label: 'Like',
変更後:
label: 'Favorite',
Likeボタンの表示文字が変わります。
チャレンジ4の答え
変更前:
backgroundColor: NetflixColors.darkGray,
変更後:
backgroundColor: const Color(0xFF333333),
Downloadボタンの背景が少し明るくなります。
この節のまとめ
この節では、作品詳細画面にタイトル、説明文、メタ情報、アクションボタンを表示する方法を学びました。
大切なポイントは次の通りです。
MovieDetailPageは、タップされた作品データmovieを受け取る。StatefulWidgetを使うと、My ListやLikeのON/OFFを管理できる。widget.movieで、詳細画面に渡された作品データを使える。CustomScrollViewとSliverAppBarを使うと、大きな画像エリアを持つ詳細画面を作れる。MetaLineで、年・年齢制限・カテゴリなどをまとめて表示できる。Wrapを使うと、横幅が足りないときに自然に折り返せる。ElevatedButton.iconを使うと、アイコン付きの大きなボタンを作れる。Navigator.pushを使うと、YouTube再生画面へ移動できる。Textで、作品説明文や出演情報風テキストを表示できる。DetailActionを部品化すると、My List、Like、Shareのような同じ形のボタンを使い回せる。setStateを使うと、ボタンの状態変更を画面に反映できる。
次のステップ
次の節では、YouTube再生画面を作ります。
youtube_player_iframe を使って、作品データに登録した youtubeVideoId の動画をアプリ内で再生する方法を学びます。
作品詳細画面から再生画面へ移動する流れも、もう一度確認していきましょう。