
【横スクロールUI】Popular・Trending・Top10風の作品一覧を作る
この節で学ぶこと
前の節では、メニューアイコンをタップしたときに、画面下からメニューを表示する方法を学びました。
今回の節では、Home画面に表示されている 横スクロールの作品一覧 を作る仕組みを見ていきます。
動画アプリでは、作品が横にずらっと並んでいて、指で左右にスクロールできるUIをよく見かけます。
今回のアプリでも、Home画面に次のような作品一覧があります。
Popular on NETAFLIX
Trending Neta
Top 10 TV Shows in Japan Today
Only on NETAFLIX
これらは、ContentRow という部品を使って作っています。
この節では、次の流れで学びます。
作品一覧のタイトルを表示する
↓
作品カードを横に並べる
↓
ListViewで横スクロールできるようにする
↓
MoviePosterCardでポスター画像を表示する
↓
Top10風にランキング番号を重ねる
↓
タップしたら作品詳細画面へ移動する
横スクロールUIの全体像
Home画面では、複数の作品一覧を縦に並べています。
コードでは、次のように ContentRow を何度も使っています。
SliverToBoxAdapter(
child: ContentRow(
title: selectedCategory == HomeCategory.all
? 'Popular on NETAFLIX'
: 'Popular ${selectedCategory.label}',
items: items,
large: false,
),
),
SliverToBoxAdapter(
child: ContentRow(
title: selectedCategory == HomeCategory.all
? 'Trending Neta'
: 'Trending ${selectedCategory.label}',
items: [...items.reversed],
large: false,
),
),
SliverToBoxAdapter(
child: ContentRow(
title: selectedCategory == HomeCategory.all
? 'Top 10 TV Shows in Japan Today'
: 'Top ${selectedCategory.label} Today',
items: items,
large: true,
showRank: true,
),
),
ここでは、同じ ContentRow を使いながら、タイトルや表示する作品、カードの大きさ、ランキング番号の有無を変えています。
つまり、ContentRow は横スクロール作品一覧を作るための共通部品です。
ContentRowとは?
ContentRow は、作品一覧の1行を作るWidgetです。
class ContentRow extends StatelessWidget {
const ContentRow({
super.key,
required this.title,
required this.items,
this.large = false,
this.showRank = false,
});
final String title;
final List<MovieItem> items;
final bool large;
final bool showRank;
受け取っている値は、次の4つです。
| 値 | 役割 |
|---|---|
title | 作品一覧の見出し |
items | 表示する作品リスト |
large | カードを大きめにするかどうか |
showRank | ランキング番号を表示するかどうか |
この4つを変えることで、同じ部品からいろいろな作品一覧を作れます。
titleで見出しを変える
title は、作品一覧の見出しです。
たとえば、次のように渡しています。
ContentRow(
title: 'Popular on NETAFLIX',
items: items,
large: false,
),
この場合、画面には次のような見出しが表示されます。
Popular on NETAFLIX
別の場所では、次のように使っています。
ContentRow(
title: 'Trending Neta',
items: [...items.reversed],
large: false,
),
この場合は、見出しが Trending Neta になります。
このように、ContentRow は中身を変えながら何度も使えるように作られています。
itemsで表示する作品を渡す
items には、表示する作品リストを渡しています。
items: items,
この items は、Home画面で選ばれているカテゴリに合わせた作品リストです。
たとえば、Shows タブが選ばれているときは、Showsカテゴリの作品だけが入ります。
Movies タブが選ばれているときは、Moviesカテゴリの作品だけが入ります。
つまり、ContentRow は自分でカテゴリを判断しているわけではありません。
親から渡された作品リストを、そのまま横に並べています。
items.reversedとは?
Trendingの行では、次のように書かれています。
items: [...items.reversed],
items.reversed は、リストの順番を逆にする処理です。
たとえば、元のリストが次の順番だったとします。
Squid Game
Stranger Things
Money Heist
items.reversed を使うと、次の順番になります。
Money Heist
Stranger Things
Squid Game
[...] で囲んでいるのは、逆順にしたものを新しいリストとして扱うためです。
[...items.reversed]
こうすることで、PopularとTrendingで同じ作品を使いながら、表示順だけ変えています。
largeとshowRankの役割
ContentRow には、large と showRank があります。
this.large = false,
this.showRank = false,
これは、指定しなければ false になるという意味です。
Top10風の一覧では、次のように指定しています。
ContentRow(
title: 'Top 10 TV Shows in Japan Today',
items: items,
large: true,
showRank: true,
),
ここでは、次のような意味になります。
large: true → カードを少し大きくする
showRank: true → ランキング番号を表示する
PopularやTrendingの行では、ランキング番号はいらないので、showRank は指定していません。
ContentRowのbuildを見てみよう
ContentRow の見た目は、次のように作られています。
@override
Widget build(BuildContext context) {
final itemWidth = large ? 132.0 : 116.0;
final itemHeight = large ? 190.0 : 164.0;
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RowTitle(title: title),
const SizedBox(height: 10),
SizedBox(
height: itemHeight,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 12),
scrollDirection: Axis.horizontal,
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(width: 9),
itemBuilder: (context, index) {
final movie = items[index];
return MoviePosterCard(
movie: movie,
width: itemWidth,
height: itemHeight,
rank: showRank ? index + 1 : null,
);
},
),
),
],
),
);
}
少し長いですが、流れはシンプルです。
カードのサイズを決める
↓
見出しを表示する
↓
余白を入れる
↓
ListViewで作品カードを横に並べる
カードのサイズを切り替える
最初に、カードの横幅と高さを決めています。
final itemWidth = large ? 132.0 : 116.0;
final itemHeight = large ? 190.0 : 164.0;
これは、large が true かどうかでサイズを変えています。
| large | 幅 | 高さ |
|---|---|---|
false | 116.0 | 164.0 |
true | 132.0 | 190.0 |
つまり、Top10風の行ではカードが少し大きくなります。
large: true,
PopularやTrendingの行では、通常サイズです。
large: false,
三項演算子を確認しよう
この書き方をもう少し見てみましょう。
final itemWidth = large ? 132.0 : 116.0;
これは、次の意味です。
largeがtrueなら132.0
largeがfalseなら116.0
このような書き方を、三項演算子といいます。
短く条件分岐を書きたいときに便利です。
もし普通の if 文で書くなら、次のようなイメージです。
double itemWidth;
if (large) {
itemWidth = 132.0;
} else {
itemWidth = 116.0;
}
三項演算子を使うと、これを1行で書けます。
Paddingで行の下に余白を作る
ContentRow 全体は、Padding で包まれています。
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
...
),
);
ここでは、下に 24 の余白を入れています。
padding: const EdgeInsets.only(bottom: 24),
横スクロールの行がいくつも続くので、行同士がくっつきすぎないようにしています。
UIでは、要素そのものだけでなく、要素と要素の間の余白もとても大切です。
Columnで見出しと作品一覧を縦に並べる
ContentRow の中では、Column を使っています。
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RowTitle(title: title),
const SizedBox(height: 10),
SizedBox(
height: itemHeight,
child: ListView.separated(
...
),
),
],
),
並びはこうです。
見出し
↓
余白
↓
横スクロール作品一覧
Column は、縦に並べるときに使うWidgetです。
見出しと作品一覧を上から下に並べるために使っています。
crossAxisAlignment: CrossAxisAlignment.startとは?
Column には、次の指定があります。
crossAxisAlignment: CrossAxisAlignment.start,
これは、横方向の位置を左寄せにする指定です。
Column は縦に並べるWidgetですが、その中にある要素を横方向にどこへ寄せるかも決められます。
今回の場合、見出しを左寄せにしたいので、CrossAxisAlignment.start にしています。
Popular on NETAFLIX
[作品カード][作品カード][作品カード]
動画アプリの一覧では、見出しは左寄せにすることが多いです。
RowTitleで見出しを表示する
作品一覧の見出しは、RowTitle という部品で表示しています。
RowTitle(title: title),
RowTitle のコードはこちらです。
class RowTitle extends StatelessWidget {
const RowTitle({
super.key,
required this.title,
});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Text(
title,
style: const TextStyle(
color: NetflixColors.white,
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
);
}
}
見出しの文字色は白、サイズは18、太さはかなり太めです。
color: NetflixColors.white,
fontSize: 18,
fontWeight: FontWeight.w900,
黒背景の上でしっかり見えるようにしています。
SizedBoxで見出しとカードの間に余白を作る
見出しと作品カードの間には、SizedBox で余白を入れています。
const SizedBox(height: 10),
これは、縦方向に10の余白を作る指定です。
もしこの余白がないと、見出しと作品カードが近すぎて窮屈に見えます。
Popular on NETAFLIX
[作品カード]
少し余白があることで、見出しと一覧の関係が分かりやすくなります。
ListViewで横スクロールを作る
横スクロールの中心になるのが、ListView.separated です。
SizedBox(
height: itemHeight,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 12),
scrollDirection: Axis.horizontal,
itemCount: items.length,
separatorBuilder: (context, index) => const SizedBox(width: 9),
itemBuilder: (context, index) {
final movie = items[index];
return MoviePosterCard(
movie: movie,
width: itemWidth,
height: itemHeight,
rank: showRank ? index + 1 : null,
);
},
),
),
ListView は、リストをスクロール表示するためのWidgetです。
普通に使うと縦スクロールになります。
今回は横にスクロールしたいので、次の指定をしています。
scrollDirection: Axis.horizontal,
これで、作品カードが横に並び、左右にスクロールできるようになります。
なぜSizedBoxで高さを指定するの?
ListView は、表示する範囲の高さが必要です。
そのため、外側を SizedBox で包んでいます。
SizedBox(
height: itemHeight,
child: ListView.separated(
...
),
),
もし高さを指定しないと、Flutterが「このListViewはどれくらいの高さで表示すればいいの?」と迷ってエラーになることがあります。
横スクロールの ListView を作るときは、だいたい高さを指定します。
ListView.separatedとは?
ListView.separated は、リスト項目の間に余白や線を入れやすいListViewです。
通常の ListView.builder では、項目だけを作ります。
ListView.separated では、項目と項目の間のWidgetも作れます。
今回の場合は、カード同士の間に横幅9の余白を入れています。
separatorBuilder: (context, index) => const SizedBox(width: 9),
これにより、作品カード同士がくっつきすぎず、見やすくなります。
paddingで左右の余白を作る
ListView.separated には、左右の余白も入れています。
padding: const EdgeInsets.symmetric(horizontal: 12),
これは、左と右に12ずつ余白を作る指定です。
作品カードが画面の端にぴったりくっつくと、少し窮屈に見えます。
左右に余白を入れることで、見た目にゆとりが出ます。
itemCountで表示する数を決める
itemCount には、作品数を指定しています。
itemCount: items.length,
items.length は、作品リストの数です。
たとえば、items に6作品入っていれば、カードが6枚表示されます。
items.length = 6
↓
作品カードを6枚作る
このように、作品データの数に合わせて、表示されるカード数が自動で変わります。
itemBuilderで作品カードを作る
作品カードを作っているのが itemBuilder です。
itemBuilder: (context, index) {
final movie = items[index];
return MoviePosterCard(
movie: movie,
width: itemWidth,
height: itemHeight,
rank: showRank ? index + 1 : null,
);
},
index は、今何番目の項目を作っているかを表します。
たとえば、最初のカードなら index は 0 です。
final movie = items[index];
これで、作品リストから該当する作品データを取り出しています。
その作品データを MoviePosterCard に渡して、カードとして表示しています。
MoviePosterCardとは?
MoviePosterCard は、作品ポスター画像を表示するカードです。
MoviePosterCard(
movie: movie,
width: itemWidth,
height: itemHeight,
rank: showRank ? index + 1 : null,
);
渡している値は次の通りです。
| 値 | 役割 |
|---|---|
movie | 表示する作品データ |
width | カードの横幅 |
height | カードの高さ |
rank | ランキング番号。不要なときはnull |
Top10風の行では、showRank が true なので、ランキング番号が入ります。
rank: showRank ? index + 1 : null,
PopularやTrendingの行では、showRank が false なので、rank は null になります。
rank: showRank ? index + 1 : null の意味
このコードを見てみましょう。
rank: showRank ? index + 1 : null,
意味はこうです。
showRankがtrueなら、index + 1を渡す
showRankがfalseなら、nullを渡す
index は0から始まります。
でも、ランキング表示は1から始めたいです。
そのため、index + 1 にしています。
| index | 表示したい順位 |
|---|---|
| 0 | 1 |
| 1 | 2 |
| 2 | 3 |
このように、プログラムの番号は0から始まることが多いですが、人間に見せる順位は1から始めることが多いです。
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;
rank には int? が使われています。
int? は、整数が入ることもあれば、null になることもあるという意味です。
ランキング番号を表示する行では数字が入ります。
通常の作品一覧では null になります。
タップしたら詳細画面へ移動する
作品カード全体は、GestureDetector で包まれています。
return GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MovieDetailPage(movie: movie),
),
);
},
child: SizedBox(
...
),
);
カードをタップすると、MovieDetailPage に移動します。
そのときに、タップした作品データを渡しています。
MovieDetailPage(movie: movie)
つまり、どのカードを押したかによって、詳細画面に表示される作品が変わります。
Squid Gameのカードを押す
↓
Squid Gameの詳細画面
Wednesdayのカードを押す
↓
Wednesdayの詳細画面
SizedBoxでカードの大きさを決める
カードの外側は、SizedBox で大きさを決めています。
child: SizedBox(
width: rank == null ? width : width + 18,
height: height,
child: Stack(
children: [
...
],
),
),
ここで、ランキング番号があるときだけ横幅を少し広くしています。
width: rank == null ? width : width + 18,
ランキング番号はポスターの左側に重ねて表示するため、少し余分な幅が必要です。
そのため、rank があるときだけ width + 18 にしています。
Stackで番号と画像を重ねる
作品カードの中でも Stack を使っています。
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',
...
),
),
],
),
ここでは、ポスター画像とランキング番号を重ねています。
ランキング番号
その右にポスター画像
Top10風のデザインでは、大きな数字とポスターを少し重ねることで、ランキングらしい見た目になります。
ポスター画像を表示する
ポスター画像は、Image.network で表示しています。
Image.network(
movie.posterUrl,
width: width,
height: height,
fit: BoxFit.cover,
)
movie.posterUrl は、作品データの中にある縦長画像URLです。
posterUrl:
'https://image.tmdb.org/t/p/w500/dDlEmu3EZ0Pgg93K2SVNLCjCSvE.jpg',
横スクロールの作品カードでは、縦長のポスター画像を使うときれいに見えます。
ClipRRectでカードを角丸にする
ポスター画像は、ClipRRect で包まれています。
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
...
),
),
ClipRRect は、画像を角丸に切り抜くためのWidgetです。
borderRadius: BorderRadius.circular(6),
角丸を少し入れることで、カードらしい見た目になります。
動画アプリ風のUIでは、角丸を控えめにすると落ち着いた印象になります。
ランキング番号を表示する
rank があるときだけ、ランキング番号を表示します。
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),
),
],
),
),
),
if (rank != null) と書くことで、rank がある場合だけ表示されます。
通常の作品一覧では rank が null なので、番号は出ません。
Top10風の一覧だけ、番号が表示されます。
‘$rank’とは?
ランキング番号を表示するときに、次のように書いています。
'$rank'
これは、数字を文字として表示するための書き方です。
rank は int、つまり数字です。
Text は文字を表示するWidgetなので、数字を文字列にして渡す必要があります。
'$rank' と書くと、rank の値を文字として使えます。
たとえば、rank が 1 なら、'1' として表示されます。
shadowsで数字に縁取りをつける
ランキング番号には、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),
),
],
これは、数字のまわりに白い影をつけるための指定です。
黒い数字だけだと、黒背景の上では見えにくくなります。
そこで、白い影を少し入れて、数字の輪郭が見えるようにしています。
Top10風の大きな数字では、このような影や縁取りがあると読みやすくなります。
まずカスタマイズしてみよう
今回は、作品カードの大きさを変えてみましょう。
次のコードを探してください。
final itemWidth = large ? 132.0 : 116.0;
final itemHeight = large ? 190.0 : 164.0;
通常カードを少し大きくしたい場合は、次のように変更します。
final itemWidth = large ? 132.0 : 126.0;
final itemHeight = large ? 190.0 : 178.0;
保存して、Home画面を確認してください。
PopularやTrendingの作品カードが少し大きくなります。
作品カード同士の間隔を変えてみよう
カード同士の間隔は、次のコードで決まっています。
separatorBuilder: (context, index) => const SizedBox(width: 9),
間隔を広くしたい場合は、次のようにします。
separatorBuilder: (context, index) => const SizedBox(width: 14),
保存して、横スクロールの作品一覧を確認してください。
カード同士の余白が広がります。
余白が広いとゆったり見えますが、一度に見えるカード数は少なくなります。
Top10の数字を小さくしてみよう
ランキング番号の大きさは、次の部分で決まっています。
fontSize: 84,
少し小さくしたい場合は、次のようにします。
fontSize: 72,
数字を大きくすると迫力が出ますが、画面内で少し重く見えることもあります。
作品カードとのバランスを見ながら調整しましょう。
よくあるつまずきポイント
Q. 横スクロールになりません。
scrollDirection が横方向になっているか確認してください。
scrollDirection: Axis.horizontal,
これがない場合、ListViewは基本的に縦方向にスクロールしようとします。
Q. ListViewでエラーが出ます。
横スクロールの ListView は、高さを指定しておくと安定します。
SizedBox(
height: itemHeight,
child: ListView.separated(
...
),
),
SizedBox で高さを指定しているか確認してください。
Q. 作品カードが表示されません。
items に作品データが入っているか確認してください。
itemCount: items.length,
items.length が 0 だと、カードは表示されません。
また、作品画像が表示されない場合は、posterUrl が正しいか確認してください。
Q. Top10の番号が表示されません。
ContentRow に showRank: true が指定されているか確認してください。
ContentRow(
title: 'Top 10 TV Shows in Japan Today',
items: items,
large: true,
showRank: true,
),
また、MoviePosterCard に rank が渡されているかも確認します。
rank: showRank ? index + 1 : null,
Q. カードを押しても詳細画面に移動しません。
MoviePosterCard の中で GestureDetector と Navigator.push があるか確認してください。
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => MovieDetailPage(movie: movie),
),
);
},
child: ...
)
チャレンジ
チャレンジ1:通常カードを少し大きくしよう
次のコードを探してください。
final itemWidth = large ? 132.0 : 116.0;
final itemHeight = large ? 190.0 : 164.0;
これを次のように変更します。
final itemWidth = large ? 132.0 : 126.0;
final itemHeight = large ? 190.0 : 178.0;
PopularやTrendingのカードが大きくなるか確認してください。
チャレンジ2:カード同士の間隔を広げよう
次のコードを探してください。
separatorBuilder: (context, index) => const SizedBox(width: 9),
これを次のように変更します。
separatorBuilder: (context, index) => const SizedBox(width: 14),
カード同士の間隔が広くなるか確認しましょう。
チャレンジ3:Top10の数字を小さくしよう
次のコードを探してください。
fontSize: 84,
これを次のように変更します。
fontSize: 72,
ランキング番号の見え方がどう変わるか確認してください。
チャレンジ4:新しい作品一覧を追加しよう
Home画面の CustomScrollView の中に、次の ContentRow を追加してみましょう。
SliverToBoxAdapter(
child: ContentRow(
title: 'Because You Watched',
items: items,
large: false,
),
),
保存して、Home画面に新しい作品一覧が追加されるか確認してください。
チャレンジの答え
チャレンジ1の答え
変更前:
final itemWidth = large ? 132.0 : 116.0;
final itemHeight = large ? 190.0 : 164.0;
変更後:
final itemWidth = large ? 132.0 : 126.0;
final itemHeight = large ? 190.0 : 178.0;
通常の作品カードが少し大きくなります。
チャレンジ2の答え
変更前:
separatorBuilder: (context, index) => const SizedBox(width: 9),
変更後:
separatorBuilder: (context, index) => const SizedBox(width: 14),
カード同士の間隔が広くなります。
チャレンジ3の答え
変更前:
fontSize: 84,
変更後:
fontSize: 72,
Top10風のランキング番号が少し小さくなります。
チャレンジ4の答え
追加するコードはこちらです。
SliverToBoxAdapter(
child: ContentRow(
title: 'Because You Watched',
items: items,
large: false,
),
),
既存の ContentRow の下に追加すると、Home画面に新しい横スクロール一覧が増えます。
この節のまとめ
この節では、Home画面にPopular・Trending・Top10風の横スクロール作品一覧を作る方法を学びました。
大切なポイントは次の通りです。
- 横スクロールの作品一覧は、
ContentRowで作っている。 titleを変えると、一覧の見出しを変えられる。itemsに作品リストを渡すと、その作品が横に並ぶ。largeを使うと、カードサイズを切り替えられる。showRankを使うと、Top10風のランキング番号を表示できる。ListView.separatedを使うと、横スクロールのリストと項目間の余白を作れる。scrollDirection: Axis.horizontalで横スクロールになる。- 横スクロールListViewでは、
SizedBoxで高さを指定すると安定する。 MoviePosterCardは、作品ポスターを表示するカード。GestureDetectorとNavigator.pushを使うと、カードタップで詳細画面に移動できる。Stackを使うと、Top10風の大きな番号とポスター画像を重ねられる。ClipRRectを使うと、ポスター画像を角丸にできる。
次のステップ
次の節では、作品カードをタップしたときに表示される 作品詳細画面 を見ていきます。
作品タイトル、説明文、メタ情報、Playボタン、Downloadボタン、Shareボタンなどをどう配置しているのかを学びます。
Home画面から詳細画面へ作品データを渡す流れも、もう少し詳しく確認していきましょう。