
【ポスターカードUI】作品画像をタップして詳細画面へ移動する
この節で学ぶこと
前の節では、Home画面に Popular、Trending、Top10 のような横スクロール作品一覧を作る方法を学びました。
今回の節では、その横スクロールの中に表示されている ポスターカード を詳しく見ていきます。
ポスターカードとは、作品の縦長画像が表示されているカードのことです。
このカードをタップすると、作品の詳細画面に移動します。
今回学ぶ流れは、次の通りです。
作品データをカードに渡す
↓
ポスター画像を表示する
↓
カードを角丸にする
↓
Top10用のランキング番号を重ねる
↓
GestureDetectorでタップできるようにする
↓
Navigator.pushで詳細画面へ移動する
この節では、ただ画像を表示するだけではなく、タップした作品の情報を次の画面へ渡す ところまで学びます。
ここが分かると、一覧画面から詳細画面へ移動するアプリらしい動きが作れるようになります。
今回見るコード
ポスターカードは、MoviePosterCard というクラスで作っています。
class MoviePosterCard extends StatelessWidget {
const MoviePosterCard({
super.key,
required this.movie,
required this.width,
required this.height,
this.rank,
});
final MovieItem movie;
final double width;
final double height;
final int? rank;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MovieDetailPage(movie: movie),
),
);
},
child: SizedBox(
width: rank == null ? width : width + 18,
height: height,
child: Stack(
children: [
Positioned(
right: 0,
top: 0,
bottom: 0,
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
movie.posterUrl,
width: width,
height: height,
fit: BoxFit.cover,
),
),
),
if (rank != null)
Positioned(
left: 0,
bottom: -9,
child: Text(
'$rank',
style: const TextStyle(
color: Colors.black,
fontSize: 84,
fontWeight: FontWeight.w900,
height: 1,
shadows: [
Shadow(
color: Colors.white,
blurRadius: 1.6,
offset: Offset(1.1, 1.1),
),
Shadow(
color: Colors.white,
blurRadius: 1.6,
offset: Offset(-1.1, -1.1),
),
],
),
),
),
],
),
),
);
}
}
少し長く見えますが、分けて見ると分かりやすいです。
1. 作品データを受け取る
2. カードの大きさを受け取る
3. 必要ならランキング番号を受け取る
4. 画像を表示する
5. ランキング番号を重ねる
6. タップしたら詳細画面へ移動する
順番に見ていきましょう。
MoviePosterCardの役割
MoviePosterCard は、作品1つ分のポスター画像を表示するためのWidgetです。
Home画面の横スクロール一覧では、この MoviePosterCard が何枚も並んでいます。
[ポスターカード] [ポスターカード] [ポスターカード] [ポスターカード]
1枚のカードには、次のような役割があります。
| 役割 | 内容 |
|---|---|
| 画像を表示する | movie.posterUrl を使ってポスター画像を表示する |
| サイズを決める | width と height でカードの大きさを決める |
| ランキング番号を出す | rank があるときだけ番号を重ねる |
| タップできるようにする | GestureDetector でタップを受け取る |
| 詳細画面へ移動する | Navigator.push で MovieDetailPage を開く |
つまり、MoviePosterCard は、見た目と動きをセットで持った部品です。
受け取っている値を確認しよう
MoviePosterCard は、次の4つの値を受け取っています。
const MoviePosterCard({
super.key,
required this.movie,
required this.width,
required this.height,
this.rank,
});
それぞれの役割は次の通りです。
| 値 | 型 | 役割 |
|---|---|---|
movie | MovieItem | 表示する作品データ |
width | double | カードの横幅 |
height | double | カードの高さ |
rank | int? | Top10用のランキング番号 |
movie、width、height には required がついています。
required this.movie,
required this.width,
required this.height,
つまり、この3つは必ず渡す必要があります。
一方で、rank には required がついていません。
this.rank,
これは、ランキング番号が必要な場合だけ渡せばよい、という意味です。
movieには何が入っている?
movie には、MovieItem が入ります。
final MovieItem movie;
MovieItem は、作品情報をひとまとまりにしたクラスです。
たとえば、次のような情報を持っています。
MovieItem(
title: 'Squid Game',
posterUrl:
'https://image.tmdb.org/t/p/w500/dDlEmu3EZ0Pgg93K2SVNLCjCSvE.jpg',
backdropUrl:
'https://image.tmdb.org/t/p/w1280/oaGvjB0DvdhXhOAuADfHb261ZHa.jpg',
youtubeVideoId: 'oqxAJKy0ii4',
year: '2021',
rating: '18+',
seasonLabel: 'Season 1',
category: 'TV Drama',
matchRate: 98,
homeCategory: HomeCategory.shows,
),
ポスターカードでは、この中の posterUrl を使って画像を表示します。
movie.posterUrl
そして、カードをタップしたときには、この movie をそのまま詳細画面へ渡します。
widthとheightでカードサイズを決める
カードの大きさは、width と height で決まります。
final double width;
final double height;
double は、小数も扱える数字の型です。
たとえば、横スクロール一覧では、次のように渡しています。
MoviePosterCard(
movie: movie,
width: itemWidth,
height: itemHeight,
rank: showRank ? index + 1 : null,
);
itemWidth と itemHeight は、ContentRow 側で決めています。
final itemWidth = large ? 132.0 : 116.0;
final itemHeight = large ? 190.0 : 164.0;
つまり、通常の一覧では小さめ、Top10風の一覧では少し大きめのカードになります。
rankはなぜint?なのか
rank は次のように定義されています。
final int? rank;
int は整数です。int? は、「整数が入ることもあるし、null になることもある」という意味です。
今回のポスターカードでは、ランキング番号が必要な場合と不要な場合があります。
通常の作品一覧では、ランキング番号はいりません。
Popular on NETAFLIX
[作品画像] [作品画像] [作品画像]
Top10風の一覧では、ランキング番号を表示します。
Top 10 TV Shows
1 [作品画像] 2 [作品画像] 3 [作品画像]
そのため、rank はあってもなくてもよい値として、int? にしています。
rankがnullかどうかで見た目を変える
カードの横幅は、rank があるかどうかで変えています。
width: rank == null ? width : width + 18,
これは、次の意味です。
rankがnullなら、通常のwidthを使う
rankがnullでないなら、width + 18にする
ランキング番号を表示するときは、画像の左側に大きな数字を重ねます。
その分、少し横幅に余裕が必要です。
だから、rank があるときだけ width + 18 にしています。
GestureDetectorでカードをタップできるようにする
ポスターカード全体は、GestureDetector で包まれています。
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MovieDetailPage(movie: movie),
),
);
},
child: SizedBox(
...
),
);
GestureDetector は、タップやスワイプなどの操作を受け取るためのWidgetです。
今回は、カードをタップしたときに詳細画面へ移動したいので、onTap を使っています。
onTap: () {
...
},
これで、ポスター画像をタップできるようになります。
onTapの中で何をしている?
onTap の中では、次の処理をしています。
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MovieDetailPage(movie: movie),
),
);
これは、新しい画面へ移動するためのコードです。
ここでは、MovieDetailPage を開いています。
しかも、ただ詳細画面を開くだけではありません。
MovieDetailPage(movie: movie)
と書いているので、タップした作品データを詳細画面へ渡しています。
つまり、カードごとに違う作品情報を詳細画面へ送っています。
Navigator.pushとは?
Navigator.of(context).push は、新しい画面へ移動するときに使います。
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MovieDetailPage(movie: movie),
),
);
イメージとしては、今の画面の上に新しい画面を重ねるような動きです。
Home画面
↓
ポスターカードをタップ
↓
MovieDetailPageが上に表示される
戻るボタンを押すと、前のHome画面に戻れます。
Flutterでは、画面移動の基本としてよく使う書き方です。
MaterialPageRouteとは?
MaterialPageRoute は、Flutterで新しい画面を開くためのルートです。
MaterialPageRoute<void>(
builder: (context) => MovieDetailPage(movie: movie),
),
builder の中で、次に表示したい画面を返しています。
今回の場合は、MovieDetailPage です。
builder: (context) => MovieDetailPage(movie: movie),
このように書くことで、タップしたときに作品詳細画面へ移動できます。
詳細画面にmovieを渡す理由
詳細画面では、作品ごとに違う情報を表示します。
たとえば、Squid Game のカードを押した場合は、Squid Game のタイトルや説明文を表示したいです。
Wednesday のカードを押した場合は、Wednesday の情報を表示したいです。
そのため、カードをタップしたときに、どの作品が押されたのかを詳細画面に渡す必要があります。
MovieDetailPage(movie: movie)
このように渡しているので、詳細画面側では次のように作品情報を使えます。
widget.movie.title
widget.movie.description
widget.movie.backdropUrl
一覧画面から詳細画面へデータを渡す流れは、アプリ開発でとても大切です。
ポスター画像を表示する
後半では、ポスターカードの見た目を詳しく見ていきます。
まず、作品画像を表示している部分です。
Image.network(
movie.posterUrl,
width: width,
height: height,
fit: BoxFit.cover,
),
Image.network は、インターネット上の画像URLを読み込んで表示するWidgetです。
今回のコードでは、movie.posterUrl を使っています。
movie.posterUrl
これは、MovieItem の中に入っているポスター画像のURLです。
たとえば、作品データ側では次のように設定されています。
posterUrl:
'https://image.tmdb.org/t/p/w500/dDlEmu3EZ0Pgg93K2SVNLCjCSvE.jpg',
このURLの画像が、ポスターカードとして表示されます。
posterUrlとbackdropUrlの違い
作品データには、画像URLが2種類あります。
posterUrl
backdropUrl
それぞれの役割は違います。
| 項目 | 使う場所 | 画像の特徴 |
|---|---|---|
posterUrl | 横スクロールの作品カード | 縦長のポスター画像 |
backdropUrl | ヒーロー画面・詳細画面の背景 | 横長の背景画像 |
今回のポスターカードでは、縦長の画像を使いたいので、posterUrl を使っています。
もしここで backdropUrl を使うと、横長画像を縦長カードに無理やり入れることになり、見た目が崩れやすくなります。
Image.network(
movie.posterUrl,
...
)
ポスターカードには、基本的に posterUrl を使うと覚えておきましょう。
widthとheightを画像にも指定する
画像には、カードと同じ幅と高さを指定しています。
width: width,
height: height,
これにより、画像がカードサイズに合わせて表示されます。
たとえば、通常カードなら次のようなサイズになります。
幅:116
高さ:164
Top10風カードなら、次のようなサイズになります。
幅:132
高さ:190
外側の SizedBox だけでなく、画像にもサイズを指定することで、カードの大きさが安定します。
BoxFit.coverで画像をきれいに収める
画像には、次の指定があります。
fit: BoxFit.cover,
BoxFit.cover は、画像を枠いっぱいに表示する指定です。
枠からはみ出る部分は少し切り取られますが、空白が出にくくなります。
画像を枠いっぱいに広げる
↓
はみ出る部分は自然に切り取る
↓
カードいっぱいに画像が表示される
ポスターカードのように、決まったサイズの中に画像をきれいに入れたいときによく使います。
BoxFit.coverを使わないとどうなる?
もし fit: BoxFit.cover を指定しない場合、画像の縦横比によっては、カードの中に余白ができることがあります。
たとえば、画像が少し横長だった場合、縦長カードの中に入れると上下または左右に余白が出ることがあります。
余白が出る
↓
カードとしての一体感が弱くなる
↓
動画アプリ風の見た目から少し離れる
そのため、作品カードでは BoxFit.cover を使って、画像をしっかりカード全体に表示しています。
ClipRRectで画像を角丸にする
ポスター画像は、ClipRRect で包まれています。
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
movie.posterUrl,
width: width,
height: height,
fit: BoxFit.cover,
),
),
ClipRRect は、中に入っているWidgetを角丸に切り抜くためのWidgetです。
ここでは、ポスター画像の角を少し丸くしています。
borderRadius: BorderRadius.circular(6),
これにより、画像が四角すぎず、カードらしい見た目になります。
角丸の大きさを調整する
角丸の大きさは、次の数字で変えられます。
BorderRadius.circular(6)
数字を大きくすると、角がより丸くなります。
BorderRadius.circular(14)
逆に、数字を小さくすると、角ばった見た目になります。
BorderRadius.circular(2)
動画アプリ風のUIでは、角丸を大きくしすぎると少し可愛らしい印象になります。
今回のように、落ち着いた黒背景の動画アプリにしたい場合は、6 くらいの控えめな角丸が合いやすいです。
Stackで画像とランキング番号を重ねる
ポスターカードの中では、Stack を使っています。
child: Stack(
children: [
Positioned(
right: 0,
top: 0,
bottom: 0,
child: ClipRRect(
...
),
),
if (rank != null)
Positioned(
left: 0,
bottom: -9,
child: Text(
'$rank',
...
),
),
],
),
Stack は、Widget同士を重ねるためのWidgetです。
今回のカードでは、次の2つを重ねています。
ポスター画像
ランキング番号
Top10風の行では、大きな数字を画像の左側に重ねることで、ランキングらしい見た目にしています。
Positionedで画像の位置を決める
ポスター画像は、Positioned で位置を指定しています。
Positioned(
right: 0,
top: 0,
bottom: 0,
child: ClipRRect(
...
),
),
ここでは、画像を右側に寄せています。
right: 0
さらに、上と下を 0 にしています。
top: 0,
bottom: 0,
これにより、画像がカードの右側で上下いっぱいに配置されます。
ランキング番号がない通常カードでも、この構造を使っています。
なぜ画像をright: 0にしているのか
通常のカードだけなら、画像を中央に置けば十分です。
しかし、この MoviePosterCard はTop10風のカードにも使います。
Top10風では、左側に大きなランキング番号を置き、右側にポスター画像を置きます。
1 [ポスター画像]
そのため、画像を右側に寄せる設計にしています。
right: 0
そして、ランキング番号があるときだけ、カード全体の幅を少し広げています。
width: rank == null ? width : width + 18,
これにより、通常カードにもTop10カードにも同じ MoviePosterCard を使えるようになっています。
if文でランキング番号を出し分ける
ランキング番号は、rank があるときだけ表示しています。
if (rank != null)
Positioned(
left: 0,
bottom: -9,
child: Text(
'$rank',
...
),
),
Dartでは、Widgetリストの中で if を使うことができます。
これにより、条件に合うときだけWidgetを追加できます。
今回の場合は、こういう意味です。
rankがある
↓
ランキング番号を表示する
rankがnull
↓
ランキング番号を表示しない
通常のPopularやTrendingでは、rank は null です。
Top10風の行では、rank に 1、2、3 のような数字が入ります。
ランキング番号の位置を決める
ランキング番号も、Positioned で位置を決めています。
Positioned(
left: 0,
bottom: -9,
child: Text(
'$rank',
...
),
),
左位置は 0 です。
left: 0
下位置は -9 です。
bottom: -9
bottom にマイナスの値を入れると、少し下にはみ出すように配置されます。
これにより、大きなランキング番号がカードの下に少し食い込むような、迫力のある見た目になります。
Textでランキング番号を表示する
ランキング番号は、Text で表示しています。
Text(
'$rank',
style: const TextStyle(
color: Colors.black,
fontSize: 84,
fontWeight: FontWeight.w900,
height: 1,
shadows: [
Shadow(
color: Colors.white,
blurRadius: 1.6,
offset: Offset(1.1, 1.1),
),
Shadow(
color: Colors.white,
blurRadius: 1.6,
offset: Offset(-1.1, -1.1),
),
],
),
),
'$rank' は、数字を文字として表示するための書き方です。
たとえば、rank が 1 の場合、画面には 1 と表示されます。
'$rank'
ランキング番号のデザイン
ランキング番号の見た目は、TextStyle で決めています。
color: Colors.black,
fontSize: 84,
fontWeight: FontWeight.w900,
height: 1,
それぞれの意味は次の通りです。
| 指定 | 意味 |
|---|---|
color: Colors.black | 数字を黒にする |
fontSize: 84 | 数字を大きくする |
fontWeight: FontWeight.w900 | かなり太くする |
height: 1 | 行の高さを詰める |
Top10風の数字は、かなり大きく、太く表示しています。
作品カードよりも目立つくらいの数字にすることで、「ランキング」の雰囲気が出ます。
shadowsで数字に白い縁取りをつける
ランキング番号には、白い影をつけています。
shadows: [
Shadow(
color: Colors.white,
blurRadius: 1.6,
offset: Offset(1.1, 1.1),
),
Shadow(
color: Colors.white,
blurRadius: 1.6,
offset: Offset(-1.1, -1.1),
),
],
黒い数字だけだと、黒背景の上では見えにくくなります。
そこで、白い影を少しずらして重ねています。
黒い数字
+
白い影
↓
輪郭が見えやすくなる
このように、黒背景の上で黒文字を使いたい場合は、影や縁取りを入れると読みやすくなります。
なぜ影を2つ使っているのか
Shadow は2つ入っています。
Shadow(
color: Colors.white,
blurRadius: 1.6,
offset: Offset(1.1, 1.1),
),
Shadow(
color: Colors.white,
blurRadius: 1.6,
offset: Offset(-1.1, -1.1),
),
1つ目は右下方向に白い影を出しています。
offset: Offset(1.1, 1.1)
2つ目は左上方向に白い影を出しています。
offset: Offset(-1.1, -1.1)
これにより、数字の片側だけでなく、両側にうっすら白い輪郭が出ます。
本格的な縁取りではありませんが、簡単に数字を読みやすくする工夫です。
ポスターカードの全体構造を整理しよう
MoviePosterCard の構造を整理すると、こうなります。
MoviePosterCard
└── GestureDetector
└── SizedBox
└── Stack
├── Positioned
│ └── ClipRRect
│ └── Image.network
└── if rank != null
└── Positioned
└── Text
このように、画像を表示するだけでなく、タップ処理、サイズ指定、重ね合わせ、条件分岐が組み合わさっています。
ポスターカードは小さな部品ですが、Flutterアプリの基本がたくさん詰まっています。
まずカスタマイズしてみよう
今回は、ポスターカードの角丸を少し大きくしてみましょう。
次のコードを探してください。
borderRadius: BorderRadius.circular(6),
これを次のように変更します。
borderRadius: BorderRadius.circular(14),
保存して、Home画面を確認してください。
ポスターカードの角が少し丸くなります。
見た目が柔らかくなりますが、動画アプリ風のシャープさは少し弱くなるかもしれません。
ランキング番号を小さくしてみよう
Top10風の番号が大きすぎると感じる場合は、次のコードを変更します。
fontSize: 84,
少し小さくするなら、次のようにします。
fontSize: 72,
保存して、Top10風の作品一覧を確認してください。
数字が少し控えめになります。
タップ時に動いた感じを出すには?
今の MoviePosterCard は、タップするとすぐに詳細画面へ移動します。
もっと「押した感じ」を出したい場合は、GestureDetector の代わりに InkWell を使う方法もあります。
ただし、InkWell の波紋効果をきれいに出すには、Material と組み合わせる必要があります。
教材のこの段階では、まず GestureDetector でシンプルにタップ処理を理解するのが分かりやすいです。
まずはGestureDetectorでタップ処理を理解する
↓
慣れてきたらInkWellやアニメーションに挑戦する
よくあるつまずきポイント
Q. ポスター画像が表示されません。
まず、posterUrl が正しいか確認してください。
movie.posterUrl
URLが間違っていたり、画像が削除されていたりすると表示されません。
また、ネットワーク画像なので、通信環境によって表示に時間がかかる場合もあります。
Q. 画像が変な比率で表示されます。
次の指定が入っているか確認してください。
fit: BoxFit.cover,
BoxFit.cover を使うと、画像がカードいっぱいに表示されます。
Q. カードを押しても詳細画面に移動しません。
GestureDetector の onTap が入っているか確認してください。
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MovieDetailPage(movie: movie),
),
);
},
また、MovieDetailPage が正しく定義されているかも確認しましょう。
Q. 詳細画面に違う作品が表示されます。
MoviePosterCard に渡している movie が正しいか確認してください。
final movie = items[index];
return MoviePosterCard(
movie: movie,
width: itemWidth,
height: itemHeight,
rank: showRank ? index + 1 : null,
);
items[index] で取り出した作品を、そのまま MoviePosterCard に渡すことが大切です。
Q. Top10の番号が表示されません。
rank が null になっていないか確認してください。
rank: showRank ? index + 1 : null,
Top10風に表示したい ContentRow では、次の指定が必要です。
showRank: true,
チャレンジ
チャレンジ1:ポスターカードの角丸を大きくしよう
次のコードを探してください。
borderRadius: BorderRadius.circular(6),
これを次のように変更します。
borderRadius: BorderRadius.circular(14),
カードの印象がどう変わるか確認してください。
チャレンジ2:ランキング番号を少し小さくしよう
次のコードを探してください。
fontSize: 84,
これを次のように変更します。
fontSize: 72,
Top10風カードの数字が少し小さくなります。
チャレンジ3:ランキング番号の位置を少し上にしよう
次のコードを探してください。
bottom: -9,
これを次のように変更します。
bottom: 0,
数字が少し上に移動します。
チャレンジ4:詳細画面へ渡すデータの流れを説明しよう
次の流れを、自分の言葉で説明してみましょう。
items[index]
↓
MoviePosterCard(movie: movie)
↓
MovieDetailPage(movie: movie)
↓
詳細画面で作品情報を表示
コードを書くだけでなく、「どこからどこへデータが渡っているのか」を説明できるようになると、アプリ全体を理解しやすくなります。
チャレンジの答え
チャレンジ1の答え
変更前:
borderRadius: BorderRadius.circular(6),
変更後:
borderRadius: BorderRadius.circular(14),
ポスターカードの角がより丸くなります。
チャレンジ2の答え
変更前:
fontSize: 84,
変更後:
fontSize: 72,
Top10風のランキング番号が少し小さくなります。
チャレンジ3の答え
変更前:
bottom: -9,
変更後:
bottom: 0,
ランキング番号が少し上に移動します。
チャレンジ4の答え
データの流れは、次のようになります。
ContentRowのitemBuilderで、items[index]から1作品分のデータを取り出す
↓
取り出したmovieをMoviePosterCardに渡す
↓
MoviePosterCardのカードがタップされる
↓
Navigator.pushでMovieDetailPageを開く
↓
MovieDetailPage(movie: movie)として、同じ作品データを詳細画面へ渡す
↓
詳細画面では、そのmovieを使ってタイトルや説明文、画像を表示する
この流れを理解すると、一覧画面から詳細画面へデータを渡す仕組みが分かります。
この節のまとめ
この節では、ポスターカードUIを使って、作品画像を表示し、タップしたら詳細画面へ移動する方法を学びました。
大切なポイントは次の通りです。
MoviePosterCardは、作品1つ分のポスターカードを作るWidget。Image.networkを使うと、URLから画像を読み込んで表示できる。- ポスターカードには、縦長画像の
posterUrlを使う。 BoxFit.coverを使うと、画像をカードいっぱいに表示できる。ClipRRectを使うと、画像を角丸にできる。Stackを使うと、画像とランキング番号を重ねられる。Positionedを使うと、重ねたWidgetの位置を細かく指定できる。if (rank != null)を使うと、ランキング番号を出すカードと出さないカードを分けられる。GestureDetectorを使うと、カード全体をタップできるようになる。Navigator.pushとMaterialPageRouteを使うと、詳細画面へ移動できる。MovieDetailPage(movie: movie)と書くことで、タップした作品データを詳細画面へ渡せる。
次のステップ
次の節では、作品詳細画面を詳しく見ていきます。
詳細画面では、背景画像、タイトル、作品説明、メタ情報、Playボタン、Downloadボタン、My List、Like、Shareなど、たくさんのUIが組み合わさっています。
一覧画面で選んだ作品情報を、どのように詳細画面で表示しているのかを確認していきましょう。