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

【横スクロール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 には、largeshowRank があります。

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;

これは、largetrue かどうかでサイズを変えています。

large高さ
false116.0164.0
true132.0190.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 は、今何番目の項目を作っているかを表します。

たとえば、最初のカードなら index0 です。

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風の行では、showRanktrue なので、ランキング番号が入ります。

rank: showRank ? index + 1 : null,

PopularやTrendingの行では、showRankfalse なので、ranknull になります。


rank: showRank ? index + 1 : null の意味

このコードを見てみましょう。

rank: showRank ? index + 1 : null,

意味はこうです。

showRankがtrueなら、index + 1を渡す
showRankがfalseなら、nullを渡す

index は0から始まります。

でも、ランキング表示は1から始めたいです。

そのため、index + 1 にしています。

index表示したい順位
01
12
23

このように、プログラムの番号は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 がある場合だけ表示されます。

通常の作品一覧では ranknull なので、番号は出ません。

Top10風の一覧だけ、番号が表示されます。


‘$rank’とは?

ランキング番号を表示するときに、次のように書いています。

'$rank'

これは、数字を文字として表示するための書き方です。

rankint、つまり数字です。

Text は文字を表示するWidgetなので、数字を文字列にして渡す必要があります。

'$rank' と書くと、rank の値を文字として使えます。

たとえば、rank1 なら、'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.length0 だと、カードは表示されません。

また、作品画像が表示されない場合は、posterUrl が正しいか確認してください。

Q. Top10の番号が表示されません。

ContentRowshowRank: true が指定されているか確認してください。

ContentRow(
  title: 'Top 10 TV Shows in Japan Today',
  items: items,
  large: true,
  showRank: true,
),

また、MoviePosterCardrank が渡されているかも確認します。

rank: showRank ? index + 1 : null,

Q. カードを押しても詳細画面に移動しません。

MoviePosterCard の中で GestureDetectorNavigator.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 は、作品ポスターを表示するカード。
  • GestureDetectorNavigator.push を使うと、カードタップで詳細画面に移動できる。
  • Stack を使うと、Top10風の大きな番号とポスター画像を重ねられる。
  • ClipRRect を使うと、ポスター画像を角丸にできる。

次のステップ

次の節では、作品カードをタップしたときに表示される 作品詳細画面 を見ていきます。

作品タイトル、説明文、メタ情報、Playボタン、Downloadボタン、Shareボタンなどをどう配置しているのかを学びます。

Home画面から詳細画面へ作品データを渡す流れも、もう少し詳しく確認していきましょう。

教材トップへ戻る