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

【詳細画面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 ListLike は、押したときにON/OFFの見た目を変えたいボタンです。

Likeを押す
↓
ハートの見た目が変わる

My Listを押す
↓
保存済みの見た目に変わる

このように、ユーザー操作によって画面の一部が変わる場合は、StatefulWidget を使います。


Stateクラスを確認しよう

MovieDetailPage の状態は、_MovieDetailPageState で管理しています。

class _MovieDetailPageState extends State<MovieDetailPage> {
  bool isInMyList = false;
  bool isLiked = false;

ここでは、2つの状態を持っています。

変数役割
isInMyListMy Listに追加されているか
isLikedLikeされているか

最初はどちらも 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;

StatefulWidgetState クラスの中では、親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 は、複雑なスクロール画面を作るときに便利です。

今回の詳細画面では、上部に大きな画像エリアがあり、その下に説明文やボタンが続きます。

大きな画像エリア
↓
詳細情報
↓
エピソード一覧

このように、上部の見せ場と下の内容を組み合わせたいときに、CustomScrollViewSliver を使うと作りやすくなります。


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.rating18+ の場合、画面には小さな枠付きで 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,

isAddedToListfalse のときは、追加アイコンを表示します。

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,

isLikedfalse のときは、空のハートを表示します。

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,
            ),
          ),
        ],
      ),
    ),
  );
}

activetrue のときは白、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 で、詳細画面に渡された作品データを使える。
  • CustomScrollViewSliverAppBar を使うと、大きな画像エリアを持つ詳細画面を作れる。
  • MetaLine で、年・年齢制限・カテゴリなどをまとめて表示できる。
  • Wrap を使うと、横幅が足りないときに自然に折り返せる。
  • ElevatedButton.icon を使うと、アイコン付きの大きなボタンを作れる。
  • Navigator.push を使うと、YouTube再生画面へ移動できる。
  • Text で、作品説明文や出演情報風テキストを表示できる。
  • DetailAction を部品化すると、My List、Like、Shareのような同じ形のボタンを使い回せる。
  • setState を使うと、ボタンの状態変更を画面に反映できる。

次のステップ

次の節では、YouTube再生画面を作ります。

youtube_player_iframe を使って、作品データに登録した youtubeVideoId の動画をアプリ内で再生する方法を学びます。

作品詳細画面から再生画面へ移動する流れも、もう一度確認していきましょう。

教材トップへ戻る