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

【無限ループ風表示】ページ番号と余り算を使ってClipsを繰り返し表示する

この節で学ぶこと

前の節では、youtube_player_iframe を使って、作品トレーラーをアプリ内で再生する方法を学びました。

今回の節では、BottomNavigationの Clips 画面を作ります。

Clips 画面は、TikTokやInstagram Reels、YouTube Shortsのように、縦にスワイプして次々と作品を見られる画面です。

このアプリでは、実際の動画を自動再生するのではなく、作品の背景画像を画面いっぱいに表示し、右側にLike・My List・Share・Playボタンを並べて、動画フィード風のUIを作っています。

Clips画面
↓
PageView.builderで縦スクロール
↓
1ページに1作品を表示
↓
背景画像を画面いっぱいに表示
↓
右側にアクションボタン
↓
下部に作品タイトルと説明文

この節では、PageView.builder を使って、縦スクロールの作品フィードを作る流れを学びます。


Clips画面の完成イメージ

Clips 画面は、次のような構成です。

┌────────────────────┐
│                    │
│   背景画像が全面表示 │
│                    │
│              ♡     │
│              +     │
│              Share │
│              Play  │
│                    │
│ NETAFLIX CLIPS     │
│ 作品タイトル         │
│ 作品説明文           │
└────────────────────┘

スマホ画面いっぱいに作品の背景画像を表示します。

その上に黒いグラデーションを重ね、文字を読みやすくします。

右側にはアクションボタン、下側には作品タイトルや説明文を表示します。

動画アプリらしい「一画面一作品」の見せ方です。


今回見るコード

Clips 画面は、主に次の2つのWidgetで作っています。

ClipsPage
ClipMoviePage

役割は次の通りです。

Widget役割
ClipsPage縦スクロール全体を管理する
ClipMoviePage1作品分の画面を表示する

まずは、ClipsPage のコードを見ていきます。

class ClipsPage extends StatefulWidget {
  const ClipsPage({super.key});

  @override
  State<ClipsPage> createState() => _ClipsPageState();
}

class _ClipsPageState extends State<ClipsPage> {
  late final PageController pageController;
  final Set<int> likedIndexes = {};
  final Set<int> savedIndexes = {};

  @override
  void initState() {
    super.initState();
    pageController = PageController(initialPage: movies.length * 1000);
  }

  @override
  void dispose() {
    pageController.dispose();
    super.dispose();
  }

  int movieIndexFromPage(int pageIndex) {
    return pageIndex % movies.length;
  }

  void toggleLike(int movieIndex) {
    setState(() {
      if (likedIndexes.contains(movieIndex)) {
        likedIndexes.remove(movieIndex);
      } else {
        likedIndexes.add(movieIndex);
      }
    });
  }

  void toggleSave(int movieIndex) {
    setState(() {
      if (savedIndexes.contains(movieIndex)) {
        savedIndexes.remove(movieIndex);
      } else {
        savedIndexes.add(movieIndex);
      }
    });
  }

  void shareMovie(MovieItem movie) {
    shareNetaflixMovie(
      context: context,
      movie: movie,
    );
  }

  void playMovie(MovieItem movie) {
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (context) => YouTubePlayerPage(movie: movie),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: NetflixColors.black,
      body: PageView.builder(
        controller: pageController,
        scrollDirection: Axis.vertical,
        itemBuilder: (context, pageIndex) {
          final movieIndex = movieIndexFromPage(pageIndex);
          final item = movies[movieIndex];

          return ClipMoviePage(
            movie: item,
            isLiked: likedIndexes.contains(movieIndex),
            isSaved: savedIndexes.contains(movieIndex),
            onTapLike: () => toggleLike(movieIndex),
            onTapSave: () => toggleSave(movieIndex),
            onTapShare: () => shareMovie(item),
            onTapPlay: () => playMovie(item),
          );
        },
      ),
    );
  }
}

少し長く見えますが、中心は次の部分です。

PageView.builder(
  controller: pageController,
  scrollDirection: Axis.vertical,
  itemBuilder: (context, pageIndex) {
    ...
  },
)

これを使うことで、縦にスワイプできる画面を作っています。


PageViewとは?

PageView は、ページ単位でスクロールするためのWidgetです。

普通の ListView は、なめらかにスクロールします。

一方で、PageView は、1ページずつ切り替わるようにスクロールします。

ListView
少しずつスクロールする

PageView
1ページずつ切り替わる

動画フィード風UIでは、1画面に1つのコンテンツを大きく表示したいので、PageView が向いています。


PageView.builderとは?

今回使っているのは、PageView.builder です。

PageView.builder(
  controller: pageController,
  scrollDirection: Axis.vertical,
  itemBuilder: (context, pageIndex) {
    final movieIndex = movieIndexFromPage(pageIndex);
    final item = movies[movieIndex];

    return ClipMoviePage(
      movie: item,
      isLiked: likedIndexes.contains(movieIndex),
      isSaved: savedIndexes.contains(movieIndex),
      onTapLike: () => toggleLike(movieIndex),
      onTapSave: () => toggleSave(movieIndex),
      onTapShare: () => shareMovie(item),
      onTapPlay: () => playMovie(item),
    );
  },
)

PageView.builder は、必要になったページだけを作る方法です。

たくさんのページを最初から全部作るのではなく、表示に必要な分だけ作ってくれます。

今見えているページ
+
前後のページ
↓
必要な分だけ作る

そのため、作品数が増えても効率よく表示できます。


scrollDirectionで縦スクロールにする

PageView は、何も指定しないと横方向にページが切り替わります。

今回作りたいのは、縦スクロールの動画フィードです。

そのため、次の指定をしています。

scrollDirection: Axis.vertical,

これで、上下にスワイプしてページを切り替えられます。

上へスワイプ
↓
次の作品

下へスワイプ
↓
前の作品

TikTokやReelsのようなUIを作りたいときは、Axis.vertical がポイントになります。


PageControllerとは?

ClipsPage では、PageController を使っています。

late final PageController pageController;

PageController は、PageView を操作したり、最初に表示するページを決めたりするためのControllerです。

今回のコードでは、initState の中で作っています。

pageController = PageController(initialPage: movies.length * 1000);

initialPage は、最初に表示するページ番号です。

ここでは、movies.length * 1000 にしています。

少し不思議な書き方に見えるかもしれません。

これは、後の節で扱う「無限ループ風表示」に関係しています。

最初から0ページ目にするのではなく、かなり大きな番号から始めることで、上にも下にもたくさんスクロールできるようにしています。


initStateでPageControllerを準備する

PageController は、画面が作られるときに準備しています。

@override
void initState() {
  super.initState();
  pageController = PageController(initialPage: movies.length * 1000);
}

initState は、StatefulWidget の画面が最初に作られるときに一度だけ呼ばれるメソッドです。

ここでは、PageView のためのControllerを作っています。

ClipsPageが作られる
↓
initStateが呼ばれる
↓
PageControllerを作る
↓
PageViewに渡す

disposeでPageControllerを片付ける

PageController を作ったら、画面を閉じるときに片付けます。

@override
void dispose() {
  pageController.dispose();
  super.dispose();
}

dispose は、画面が使われなくなるときに呼ばれるメソッドです。

Controllerを片付けないと、不要な処理が残ることがあります。

動画やスクロール、入力欄などのControllerを使うときは、基本的に dispose で片付けると覚えておきましょう。


itemBuilderで1ページずつ作る

PageView.builder の中では、itemBuilder を使っています。

itemBuilder: (context, pageIndex) {
  final movieIndex = movieIndexFromPage(pageIndex);
  final item = movies[movieIndex];

  return ClipMoviePage(
    movie: item,
    isLiked: likedIndexes.contains(movieIndex),
    isSaved: savedIndexes.contains(movieIndex),
    onTapLike: () => toggleLike(movieIndex),
    onTapSave: () => toggleSave(movieIndex),
    onTapShare: () => shareMovie(item),
    onTapPlay: () => playMovie(item),
  );
},

itemBuilder は、ページを作るための関数です。

pageIndex には、今作ろうとしているページ番号が入ります。

pageIndex = 0 → 1ページ目
pageIndex = 1 → 2ページ目
pageIndex = 2 → 3ページ目

この pageIndex を使って、表示する作品を決めています。


movieIndexFromPageで作品番号に変換する

ページ番号から作品番号を作っているのが、この関数です。

int movieIndexFromPage(int pageIndex) {
  return pageIndex % movies.length;
}

pageIndex は、PageView側のページ番号です。

しかし、作品数は限られています。

たとえば、作品が6個しかないのに、ページ番号が100や101になることがあります。

そこで、% を使って、作品リストの範囲内に戻しています。

pageIndex
↓
movieIndexFromPage
↓
moviesの中で使える番号に変換

% は余りを求める記号

% は、割った余りを求める記号です。

return pageIndex % movies.length;

たとえば、作品数が6個ある場合、次のようになります。

pageIndexpageIndex % 6表示する作品番号
000番目
111番目
222番目
333番目
444番目
555番目
600番目に戻る
711番目
822番目

このように、最後の作品まで行ったら、また最初の作品に戻ります。

つまり、作品を繰り返し表示できます。


なぜ余りを使うのか

PageView.builder では、itemCount を指定しない場合、かなり長くページを作り続けることができます。

そのまま movies[pageIndex] と書くと、作品数を超えたときにエラーになります。

// これは危険
final item = movies[pageIndex];

作品数が6個なのに、pageIndex が6になると、存在しない7番目の作品を取りに行ってしまいます。

movies[0] あり
movies[1] あり
movies[2] あり
movies[3] あり
movies[4] あり
movies[5] あり
movies[6] なし → エラー

そこで、% を使います。

final movieIndex = movieIndexFromPage(pageIndex);
final item = movies[movieIndex];

これにより、pageIndex が大きくなっても、作品リストの範囲内に戻せます。


likedIndexesとsavedIndexes

ClipsPage では、LikeとMy Listの状態を次の2つで管理しています。

final Set<int> likedIndexes = {};
final Set<int> savedIndexes = {};

これは、Like済みの作品番号、保存済みの作品番号を入れておくためのものです。

likedIndexes
↓
Likeした作品番号を入れる

savedIndexes
↓
My Listに追加した作品番号を入れる

たとえば、0番目と2番目の作品にLikeした場合、イメージとしてはこうなります。

likedIndexes = {0, 2}

Setとは?

Set は、重複しない値を入れるための入れ物です。

final Set<int> likedIndexes = {};

List は同じ値を何度も入れられます。

一方、Set は同じ値を重複して持ちません。

Listの場合
[1, 1, 2, 2, 3]

Setの場合
{1, 2, 3}

Likeした作品番号を管理する場合、同じ作品番号を何回も入れる必要はありません。

そのため、Set<int> が合っています。


containsでON/OFFを確認する

作品がLike済みかどうかは、contains で確認しています。

isLiked: likedIndexes.contains(movieIndex),

My Listも同じです。

isSaved: savedIndexes.contains(movieIndex),

これは、次の意味です。

likedIndexesの中にmovieIndexが入っている
↓
Like済み

likedIndexesの中にmovieIndexが入っていない
↓
まだLikeしていない

この結果を ClipMoviePage に渡しています。


toggleLikeでLikeを切り替える

Likeボタンを押したときは、toggleLike が動きます。

void toggleLike(int movieIndex) {
  setState(() {
    if (likedIndexes.contains(movieIndex)) {
      likedIndexes.remove(movieIndex);
    } else {
      likedIndexes.add(movieIndex);
    }
  });
}

これは、次のような処理です。

すでにLike済みなら
↓
Likeを外す

まだLikeしていないなら
↓
Likeに追加する

setState の中で変更しているので、ボタンを押したあとに画面が更新されます。


toggleSaveでMy Listを切り替える

My Listボタンも、考え方は同じです。

void toggleSave(int movieIndex) {
  setState(() {
    if (savedIndexes.contains(movieIndex)) {
      savedIndexes.remove(movieIndex);
    } else {
      savedIndexes.add(movieIndex);
    }
  });
}

これは、次のような処理です。

すでに保存済みなら
↓
My Listから外す

まだ保存していないなら
↓
My Listに追加する

likedIndexessavedIndexes を分けているので、Likeと保存を別々に管理できます。


ClipMoviePageに状態と処理を渡す

最後に、ClipMoviePage へ状態と処理を渡します。

return ClipMoviePage(
  movie: item,
  isLiked: likedIndexes.contains(movieIndex),
  isSaved: savedIndexes.contains(movieIndex),
  onTapLike: () => toggleLike(movieIndex),
  onTapSave: () => toggleSave(movieIndex),
  onTapShare: () => shareMovie(item),
  onTapPlay: () => playMovie(item),
);

ここで渡しているものを整理すると、次の通りです。

役割
movie表示する作品
isLikedLike済みかどうか
isSavedMy Listに保存済みかどうか
onTapLikeLikeボタンを押したときの処理
onTapSaveMy Listボタンを押したときの処理
onTapShareShareボタンを押したときの処理
onTapPlayPlayボタンを押したときの処理

ClipsPage は状態管理とページ切り替えを担当し、ClipMoviePage は1ページ分の見た目を担当します。


ここまでのまとめ

ここまでで、Clips画面の縦スクロール全体を作る ClipsPage を確認しました。

大切なポイントは次の通りです。

  • PageView.builder を使うと、ページ単位で切り替わるUIを作れる。
  • scrollDirection: Axis.vertical で縦スクロールにできる。
  • PageController を使うと、最初のページ位置などを管理できる。
  • initState でControllerを作り、dispose で片付ける。
  • movieIndexFromPage でページ番号を作品番号に変換する。
  • % を使うと、作品リストを繰り返し表示できる。
  • Set<int> を使うと、Like済み・保存済みの作品番号を管理できる。
  • contains で、その作品がONかOFFかを確認できる。
  • toggleLiketoggleSave で、LikeやMy Listを切り替えられる。
  • ClipMoviePage に、表示する作品・状態・処理を渡す。

ClipMoviePageで1作品分の画面を作る

ここからは、ClipMoviePage を見ていきます。

ClipsPage は、縦スクロール全体を管理する画面でした。

一方で、ClipMoviePage は、1ページ分、つまり1作品分の見た目を作るWidgetです。

class ClipMoviePage extends StatelessWidget {
  const ClipMoviePage({
    super.key,
    required this.movie,
    required this.isLiked,
    required this.isSaved,
    required this.onTapLike,
    required this.onTapSave,
    required this.onTapShare,
    required this.onTapPlay,
  });

  final MovieItem movie;
  final bool isLiked;
  final bool isSaved;
  final VoidCallback onTapLike;
  final VoidCallback onTapSave;
  final VoidCallback onTapShare;
  final VoidCallback onTapPlay;

受け取っている値を整理すると、次のようになります。

役割
movie表示する作品データ
isLikedLike済みかどうか
isSavedMy Listに保存済みかどうか
onTapLikeLikeボタンを押したときの処理
onTapSaveMy Listボタンを押したときの処理
onTapShareShareボタンを押したときの処理
onTapPlayPlayボタンを押したときの処理

ClipMoviePage 自体は、状態を持っていません。

親である ClipsPage から、状態と処理を受け取って表示しています。


ClipMoviePageの全体構造

ClipMoviePage の画面は、Stack で作っています。

@override
Widget build(BuildContext context) {
  return Stack(
    children: [
      Positioned.fill(
        child: Image.network(
          movie.backdropUrl,
          fit: BoxFit.cover,
        ),
      ),
      Positioned.fill(
        child: DecoratedBox(
          decoration: BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                NetflixColors.black,
                Colors.black.withValues(alpha: 0.24),
                Colors.black.withValues(alpha: 0.16),
                Colors.black.withValues(alpha: 0.84),
              ],
              stops: const [0.0, 0.22, 0.55, 1.0],
            ),
          ),
        ),
      ),
      SafeArea(
        child: Padding(
          padding: const EdgeInsets.fromLTRB(18, 12, 18, 92),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // 上部タイトル
              // 下部作品情報
              // 右側アクションボタン
            ],
          ),
        ),
      ),
    ],
  );
}

Stack を使うことで、次のように要素を重ねています。

背景画像
↓
黒いグラデーション
↓
上部のClipsタイトル
↓
下部の作品情報
↓
右側のアクションボタン

動画フィード風のUIでは、背景画像の上に文字やボタンを重ねることが多いので、Stack がとても便利です。


背景画像を画面いっぱいに表示する

背景画像は、次のコードで表示しています。

Positioned.fill(
  child: Image.network(
    movie.backdropUrl,
    fit: BoxFit.cover,
  ),
),

Positioned.fill は、Stack の中で上下左右いっぱいに広げるためのWidgetです。

つまり、画面全体に作品の背景画像を表示しています。

画面全体
┌────────────────────┐
│                    │
│      背景画像        │
│                    │
└────────────────────┘

ここでは、movie.backdropUrl を使っています。

movie.backdropUrl

backdropUrl は、横長の背景画像です。

Home画面の大きなビジュアルや、詳細画面の背景にも使いやすい画像です。


BoxFit.coverで余白を出さない

背景画像には、次の指定があります。

fit: BoxFit.cover,

BoxFit.cover は、画像を枠いっぱいに表示する指定です。

画面いっぱいに画像を広げ、はみ出た部分は自然に切り取られます。

画像を画面いっぱいに広げる
↓
はみ出た部分は切り取られる
↓
余白のない背景になる

Clips画面は、スマホ画面全体を使うUIなので、背景に余白が出ないようにすることが大切です。


グラデーションを重ねて文字を読みやすくする

背景画像の上には、黒いグラデーションを重ねています。

Positioned.fill(
  child: DecoratedBox(
    decoration: BoxDecoration(
      gradient: LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [
          NetflixColors.black,
          Colors.black.withValues(alpha: 0.24),
          Colors.black.withValues(alpha: 0.16),
          Colors.black.withValues(alpha: 0.84),
        ],
        stops: const [0.0, 0.22, 0.55, 1.0],
      ),
    ),
  ),
),

背景画像の上に白文字を置くと、画像の明るさによって文字が読みにくくなることがあります。

そのため、黒いグラデーションを重ねています。

背景画像
+
黒いグラデーション
+
白文字
↓
文字が読みやすくなる

特に下部には作品タイトルや説明文が入るので、下に向かって黒が濃くなるようにしています。


LinearGradientの流れ

グラデーションは、上から下に向かって変化します。

begin: Alignment.topCenter,
end: Alignment.bottomCenter,

色の流れは、次のようなイメージです。

上:黒
少し下:薄い黒
中央:かなり薄い黒
下:しっかり黒

実際のコードでは、透明度を変えた黒を並べています。

colors: [
  NetflixColors.black,
  Colors.black.withValues(alpha: 0.24),
  Colors.black.withValues(alpha: 0.16),
  Colors.black.withValues(alpha: 0.84),
],

下部を暗くすることで、作品説明文が読みやすくなります。


SafeAreaで画面端を避ける

背景画像とグラデーションの上に、実際の文字やボタンを置いています。

その部分は SafeArea で包まれています。

SafeArea(
  child: Padding(
    padding: const EdgeInsets.fromLTRB(18, 12, 18, 92),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        ...
      ],
    ),
  ),
),

SafeArea は、スマホのノッチやステータスバー、ホームインジケーターにUIがかぶりにくくするためのWidgetです。

特にClips画面は画面いっぱいに表示するので、SafeArea を使っておくと安心です。


Paddingで内側の余白を作る

SafeArea の中には、Padding があります。

padding: const EdgeInsets.fromLTRB(18, 12, 18, 92),

これは、左・上・右・下の余白を指定しています。

位置余白
18
12
18
92

下の余白が大きいのは、BottomNavigationBarと重なりすぎないようにするためです。

Clips画面の内容
↓
下部ナビゲーションと重ならないように余白を作る

画面いっぱいのUIでは、下部ナビゲーションとの重なりに注意が必要です。


上部にClipsタイトルとロゴを置く

Column の最初には、上部の行があります。

Row(
  children: [
    const Text(
      'Clips',
      style: TextStyle(
        color: NetflixColors.white,
        fontSize: 28,
        fontWeight: FontWeight.w900,
      ),
    ),
    const Spacer(),
    const NetflixWordLogo(height: 24),
  ],
),

左に Clips、右にロゴを置いています。

Clips                         NETAFLIXロゴ

Spacer を使うことで、左と右に要素を分けています。


Spacerで左右に分ける

上部の Row には、Spacer があります。

const Spacer(),

Spacer は、空いているスペースを埋めるWidgetです。

次のように、左の Clips と右のロゴの間を広げてくれます。

Clips             余白             ロゴ

横並びの中で、要素を左右に分けたいときによく使います。


Spacerで下部へ押し下げる

上部の行のあとには、次のコードがあります。

const Spacer(),

この Spacer は、上部タイトルと下部コンテンツの間を大きく空けるために使っています。

上部タイトル
↓
大きな空白
↓
下部の作品情報とボタン

Clips画面では、作品情報を下部に置きたいので、Spacer で下に押し下げています。

この使い方は、画面内で要素を上下に分けたいときに便利です。


下部に作品情報とボタンを並べる

下部では、Row を使っています。

Row(
  crossAxisAlignment: CrossAxisAlignment.end,
  children: [
    Expanded(
      child: Padding(
        padding: const EdgeInsets.only(right: 14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 作品タイトル
            // メタ情報
            // 説明文
            // Playボタン
          ],
        ),
      ),
    ),
    Column(
      children: [
        // Like
        // My List
        // Share
        // 赤いPlayボタン
      ],
    ),
  ],
),

左側には作品情報、右側にはアクションボタンを置いています。

左:作品タイトル・説明文・Playボタン
右:Like・My List・Share・Playアイコン

右側にボタンがあるため、左側の作品情報には Expanded を使っています。


Expandedで作品情報エリアを広げる

作品情報の部分は、Expanded で包まれています。

Expanded(
  child: Padding(
    padding: const EdgeInsets.only(right: 14),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        ...
      ],
    ),
  ),
),

Expanded は、空いている横幅を使って広がるWidgetです。

右側のボタン列の分を除いた残りの幅を、作品情報エリアとして使います。

これにより、タイトルや説明文ができるだけ広く表示されます。


作品タイトルを表示する

作品タイトルは、次のコードで表示しています。

Text(
  movie.title,
  style: const TextStyle(
    color: NetflixColors.white,
    fontSize: 25,
    fontWeight: FontWeight.w900,
  ),
),

movie.title は、表示中の作品名です。

たとえば、Squid Game のページなら Squid Game と表示されます。

文字色は白、文字サイズは25、太さはかなり太めです。

背景画像の上でもはっきり見えるようにしています。


メタ情報を表示する

タイトルの下には、簡単なメタ情報を表示しています。

Text(
  '${movie.matchRate}% Match • ${movie.year} • ${movie.rating} • ${movie.category}',
  style: const TextStyle(
    color: NetflixColors.white,
    fontSize: 12.5,
    fontWeight: FontWeight.w800,
  ),
),

ここでは、次の情報を1行にまとめています。

マッチ率・公開年・年齢制限・カテゴリ

たとえば、次のように表示されます。

98% Match • 2021 • 18+ • TV Drama
  • は、情報同士を区切るための記号です。

文字列の中に変数を入れる

このコードでは、文字列の中に変数を入れています。

'${movie.matchRate}% Match • ${movie.year} • ${movie.rating} • ${movie.category}'

${} を使うと、文字列の中に変数の値を入れられます。

たとえば、次のような値だった場合、

matchRate = 98
year = 2021
rating = 18+
category = TV Drama

画面にはこう表示されます。

98% Match • 2021 • 18+ • TV Drama

この書き方は、Dartでとてもよく使います。


説明文を3行まで表示する

作品説明文は、次のコードで表示しています。

Text(
  movie.description,
  maxLines: 3,
  overflow: TextOverflow.ellipsis,
  style: const TextStyle(
    color: NetflixColors.white,
    fontSize: 14,
    height: 1.4,
    fontWeight: FontWeight.w500,
  ),
),

ここで大切なのは、次の2つです。

maxLines: 3,
overflow: TextOverflow.ellipsis,

maxLines: 3 は、最大3行まで表示するという意味です。

TextOverflow.ellipsis は、入りきらない文字を ... で省略する指定です。

Clips画面ではテンポよく作品を見せたいので、説明文は長く出しすぎないようにしています。


左側にもPlayボタンを置く

説明文の下には、白いPlayボタンがあります。

SizedBox(
  height: 42,
  child: ElevatedButton.icon(
    onPressed: onTapPlay,
    style: ElevatedButton.styleFrom(
      backgroundColor: NetflixColors.white,
      foregroundColor: NetflixColors.black,
      elevation: 0,
      padding: const EdgeInsets.symmetric(
        horizontal: 20,
      ),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(5),
      ),
    ),
    icon: const Icon(
      Icons.play_arrow_rounded,
      size: 28,
    ),
    label: const Text(
      'Play',
      style: TextStyle(
        fontSize: 15,
        fontWeight: FontWeight.w900,
      ),
    ),
  ),
),

onPressed には、親から受け取った onTapPlay を指定しています。

onPressed: onTapPlay,

これにより、Playボタンを押すと、YouTube再生画面へ移動します。


右側のアクションボタン

右側には、縦にアクションボタンを並べています。

Column(
  children: [
    ClipAction(
      icon: isLiked
          ? Icons.favorite_rounded
          : Icons.favorite_border_rounded,
      label: 'Like',
      active: isLiked,
      onTap: onTapLike,
    ),
    const SizedBox(height: 24),
    ClipAction(
      icon: isSaved ? Icons.check_rounded : Icons.add,
      label: 'My List',
      active: isSaved,
      onTap: onTapSave,
    ),
    const SizedBox(height: 24),
    ClipAction(
      icon: Icons.share_outlined,
      label: 'Share',
      onTap: onTapShare,
    ),
    const SizedBox(height: 26),
    GestureDetector(
      onTap: onTapPlay,
      child: Container(
        width: 58,
        height: 58,
        decoration: const BoxDecoration(
          color: NetflixColors.red,
          shape: BoxShape.circle,
        ),
        child: const Icon(
          Icons.play_arrow_rounded,
          color: NetflixColors.white,
          size: 40,
        ),
      ),
    ),
  ],
),

並びは次のようになります。

Like
↓
My List
↓
Share
↓
赤いPlayボタン

動画フィード風の画面では、右側に操作ボタンを縦に置くと、それらしい見た目になります。


LikeとMy Listのアイコンを切り替える

Likeボタンは、isLiked によってアイコンを変えています。

icon: isLiked
    ? Icons.favorite_rounded
    : Icons.favorite_border_rounded,

My Listボタンも、isSaved によってアイコンを変えています。

icon: isSaved ? Icons.check_rounded : Icons.add,

これは、詳細画面で学んだON/OFF切り替えと同じ考え方です。

isLikedがtrue → 塗りつぶしハート
isLikedがfalse → 空のハート

isSavedがtrue → チェック
isSavedがfalse → 追加

ClipActionとは?

ClipAction は、Clips画面右側の小さなアクションボタンです。

class ClipAction extends StatelessWidget {
  const ClipAction({
    super.key,
    required this.icon,
    required this.label,
    required this.onTap,
    this.active = false,
  });

  final IconData icon;
  final String label;
  final VoidCallback onTap;
  final bool active;

DetailAction と似ていますが、Clips画面用に少し見た目を調整しています。

背景画像の上に置くため、アイコンと文字に影をつけています。


ClipActionの見た目

ClipAction のコードは、次のようになっています。

@override
Widget build(BuildContext context) {
  final color = active ? NetflixColors.white : NetflixColors.white;

  return GestureDetector(
    behavior: HitTestBehavior.opaque,
    onTap: onTap,
    child: Column(
      children: [
        Icon(
          icon,
          color: color,
          size: 30,
          shadows: const [
            Shadow(
              color: Colors.black87,
              blurRadius: 12,
            ),
          ],
        ),
        const SizedBox(height: 5),
        Text(
          label,
          style: const TextStyle(
            color: NetflixColors.white,
            fontSize: 11,
            fontWeight: FontWeight.w800,
            shadows: [
              Shadow(
                color: Colors.black87,
                blurRadius: 12,
              ),
            ],
          ),
        ),
      ],
    ),
  );
}

ここでは、アイコンとラベルを縦に並べています。

アイコン
↓
文字

shadowsで背景画像の上でも見やすくする

ClipAction のアイコンには、影がついています。

shadows: const [
  Shadow(
    color: Colors.black87,
    blurRadius: 12,
  ),
],

文字にも影がついています。

shadows: [
  Shadow(
    color: Colors.black87,
    blurRadius: 12,
  ),
],

背景画像の上に白いアイコンや文字を置くと、画像の明るい部分では見えにくくなることがあります。

そこで、黒い影をつけて、白いアイコンや文字を読みやすくしています。


赤いPlayボタンを作る

右側の一番下には、赤い丸いPlayボタンがあります。

GestureDetector(
  onTap: onTapPlay,
  child: Container(
    width: 58,
    height: 58,
    decoration: const BoxDecoration(
      color: NetflixColors.red,
      shape: BoxShape.circle,
    ),
    child: const Icon(
      Icons.play_arrow_rounded,
      color: NetflixColors.white,
      size: 40,
    ),
  ),
),

ポイントは、BoxDecoration の中のこの指定です。

shape: BoxShape.circle,

これで、Container が丸になります。

幅と高さを同じにしているので、きれいな円になります。

width: 58,
height: 58,

赤いボタンにすることで、他のアクションよりも「再生」が目立ちます。


まずカスタマイズしてみよう

まずは、Clips画面の説明文の行数を変えてみましょう。

次のコードを探してください。

maxLines: 3,

これを次のように変更します。

maxLines: 2,

保存して、Clips画面を確認してください。

説明文が2行までになり、画面が少しすっきりします。


背景をもっと暗くしてみよう

下部の文字が読みにくい場合は、グラデーションを少し濃くします。

次のコードを探してください。

Colors.black.withValues(alpha: 0.84),

これを次のように変更します。

Colors.black.withValues(alpha: 0.92),

下部がより暗くなり、タイトルや説明文が読みやすくなります。


アクションボタンを大きくしてみよう

右側の赤いPlayボタンは、次のコードで大きさを決めています。

width: 58,
height: 58,

少し大きくしたい場合は、次のようにします。

width: 64,
height: 64,

アイコンも少し大きくしたい場合は、次のコードも変更します。

size: 40,
size: 44,

ボタンが大きくなると押しやすくなりますが、画面上の存在感も強くなります。


よくあるつまずきポイント

Q. 縦スクロールになりません。

PageView.builder に次の指定が入っているか確認してください。

scrollDirection: Axis.vertical,

これがないと、横方向にページが切り替わります。


Q. 途中で作品リストのエラーが出ます。

次のように、余りを使って作品番号を作っているか確認してください。

int movieIndexFromPage(int pageIndex) {
  return pageIndex % movies.length;
}

そして、作品は次のように取り出します。

final item = movies[movieIndex];

movies[pageIndex] と書くと、pageIndex が作品数を超えたときにエラーになります。


Q. 背景画像が表示されません。

movie.backdropUrl が正しいか確認してください。

Image.network(
  movie.backdropUrl,
  fit: BoxFit.cover,
)

ネットワーク画像なので、通信環境によって表示に時間がかかることがあります。


Q. 文字が背景に埋もれて読みにくいです。

グラデーションを重ねているか確認してください。

Positioned.fill(
  child: DecoratedBox(
    decoration: BoxDecoration(
      gradient: LinearGradient(
        ...
      ),
    ),
  ),
),

下部の黒を濃くしたい場合は、最後の alpha を上げます。

Colors.black.withValues(alpha: 0.92),

Q. LikeやMy Listを押しても変わりません。

ClipMoviePage に、状態と処理が正しく渡されているか確認してください。

isLiked: likedIndexes.contains(movieIndex),
isSaved: savedIndexes.contains(movieIndex),
onTapLike: () => toggleLike(movieIndex),
onTapSave: () => toggleSave(movieIndex),

さらに、toggleLiketoggleSave の中で setState を使っているか確認してください。


チャレンジ

チャレンジ1:Clips画面を横スクロールに変えてみよう

次のコードを探してください。

scrollDirection: Axis.vertical,

これを次のように変更します。

scrollDirection: Axis.horizontal,

上下ではなく、左右にスワイプして作品が切り替わるか確認してください。


チャレンジ2:説明文を2行までにしよう

次のコードを探してください。

maxLines: 3,

これを次のように変更します。

maxLines: 2,

説明文が短く表示され、画面がすっきりするか確認しましょう。


チャレンジ3:下部の黒グラデーションを濃くしよう

次のコードを探してください。

Colors.black.withValues(alpha: 0.84),

これを次のように変更します。

Colors.black.withValues(alpha: 0.92),

下部の文字が読みやすくなるか確認してください。


チャレンジ4:PlayボタンのラベルをTrailerに変えよう

次のコードを探してください。

label: 'Play',

これを次のように変更します。

label: 'Trailer',

Clips画面右側のボタン文字が変わるか確認してください。


チャレンジの答え

チャレンジ1の答え

変更前:

scrollDirection: Axis.vertical,

変更後:

scrollDirection: Axis.horizontal,

Clips画面が横方向にスワイプするUIになります。


チャレンジ2の答え

変更前:

maxLines: 3,

変更後:

maxLines: 2,

説明文が最大2行まで表示されます。


チャレンジ3の答え

変更前:

Colors.black.withValues(alpha: 0.84),

変更後:

Colors.black.withValues(alpha: 0.92),

下部の黒グラデーションが濃くなり、文字が読みやすくなります。


チャレンジ4の答え

変更前:

label: 'Play',

変更後:

label: 'Trailer',

右側のPlayボタンの表示が Trailer に変わります。


この節のまとめ

この節では、PageView.builder を使って、縦スクロールの動画フィード風UIを作る方法を学びました。

大切なポイントは次の通りです。

  • ClipsPage は、縦スクロール全体を管理するWidget。
  • ClipMoviePage は、1作品分の画面を表示するWidget。
  • PageView.builder を使うと、ページ単位で切り替わるUIを作れる。
  • scrollDirection: Axis.vertical で縦スクロールにできる。
  • PageController は、PageView を操作するためのController。
  • initState でControllerを作り、dispose で片付ける。
  • % を使うと、作品リストを繰り返し表示できる。
  • Set<int> を使うと、Like済み・保存済みの作品番号を管理できる。
  • Stack を使うと、背景画像、グラデーション、文字、ボタンを重ねられる。
  • Positioned.fill を使うと、背景画像やグラデーションを画面いっぱいに表示できる。
  • BoxFit.cover を使うと、背景画像を余白なく表示できる。
  • LinearGradient を重ねると、背景画像の上でも文字が読みやすくなる。
  • SafeArea を使うと、ノッチやステータスバーを避けて配置できる。
  • Spacer を使うと、上部と下部のUIを自然に分けられる。
  • maxLinesTextOverflow.ellipsis を使うと、長い説明文を省略できる。
  • Clips画面のPlayボタンからも、YouTubePlayerPage(movie: movie) を開ける。

次のステップ

次の節では、PageView.builder と余りの仕組みをもう少し掘り下げて、無限ループ風にClipsを繰り返し表示する方法を学びます。

今回すでに使った movies[index % movies.length] の考え方を、より丁寧に分解して理解していきましょう。

教材トップへ戻る