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

【Home画面UI】大きな背景画像・ロゴ・Playボタンを重ねて表示する

この節で学ぶこと

前の節では、下部ナビゲーションを使って、Home・Clips・Search・My Netaflixの画面を切り替える仕組みを学びました。

今回の節では、いよいよHome画面の上部を作っている部分を見ていきます。

このアプリのHome画面では、最初に大きな背景画像が表示され、その上にロゴ、作品タイトル、カテゴリ情報、Playボタン、Infoボタンなどが重なっています。

見た目としては、かなり動画アプリらしい部分です。

この節では、次のようなことを学びます。

大きな背景画像を表示する
↓
画像の上に黒いグラデーションを重ねる
↓
ロゴや作品タイトルを重ねる
↓
PlayボタンとInfoボタンを配置する
↓
ボタンを押したときに別画面へ移動する

ここで特に大事になるのが、StackPositioned です。

この2つを使えるようになると、背景画像の上に文字やボタンを重ねるUIが作れるようになります。


Home画面の上部はどこで作っている?

今回のHome画面上部は、主にこのクラスで作っています。

class NetflixTopArea extends StatelessWidget {
  const NetflixTopArea({
    super.key,
    required this.hero,
    required this.selectedCategory,
    required this.onTapMenu,
    required this.onSelectCategory,
  });

  final MovieItem hero;
  final HomeCategory selectedCategory;
  final VoidCallback onTapMenu;
  final ValueChanged<HomeCategory> onSelectCategory;

NetflixTopArea は、Home画面の一番上にある大きなビジュアル部分です。

ここには、次のような要素が入っています。

要素内容
背景画像作品の横長画像
黒いグラデーション文字を読みやすくするための暗い重ね色
ヘッダーメニュー、ロゴ、プロフィール画像、カテゴリタブ
中央ロゴNETAFLIXロゴ
作品タイトル大きく表示されるタイトル
作品情報カテゴリ、シーズン、年齢制限
ボタンMy List、Play、Info

Home画面の第一印象を作っている、とても大事な部分です。


heroとは?

NetflixTopArea には、hero という名前の MovieItem が渡されています。

final MovieItem hero;

ここでいう hero は、Home画面の一番大きな背景に使う作品データのことです。

NetflixHomePage の中では、次のように決めています。

final items = filteredMovies.isEmpty ? movies : filteredMovies;
final hero = items.first;

これは、

表示対象の作品リストの一番最初の作品を、
Home画面上部のメイン作品として使う

という意味です。

たとえば、items.firstSquid Game なら、Home画面上部には Squid Game の背景画像やタイトルが表示されます。


SizedBoxで高さを決める

NetflixTopArea の最初の見た目は、次のようになっています。

@override
Widget build(BuildContext context) {
  return SizedBox(
    height: 610,
    child: Stack(
      children: [
        ...
      ],
    ),
  );
}

ここでは、Home画面上部の高さを 610 にしています。

height: 610,

この数字が大きいほど、背景画像のエリアが縦に大きくなります。

スマホ画面のかなり大きな部分を使って、作品を見せるための指定です。

動画アプリ風のUIでは、最初に大きなビジュアルを見せることで、作品の世界観を伝えやすくなります。


Stackとは?

今回の一番大事なWidgetが Stack です。

child: Stack(
  children: [
    ...
  ],
),

Stack は、Widgetを重ねて表示するときに使います。

普通の Column は、上から下に並べます。

ロゴ
↓
タイトル
↓
ボタン

普通の Row は、左から右に並べます。

アイコン 文字 ボタン

一方、Stack は重ねます。

背景画像
その上に黒いグラデーション
その上にヘッダー
その上にタイトルやボタン

今回のHome画面上部は、まさにこの形です。


背景画像を表示する

まず、背景画像を表示している部分を見てみましょう。

Positioned.fill(
  child: Image.network(
    hero.backdropUrl,
    fit: BoxFit.cover,
    alignment: Alignment.topCenter,
  ),
),

ここでは、hero.backdropUrl の画像を画面いっぱいに表示しています。

backdropUrl は、作品データの中にある横長の背景画像URLです。

backdropUrl:
    'https://image.tmdb.org/t/p/w1280/oaGvjB0DvdhXhOAuADfHb261ZHa.jpg',

この画像を、Image.network で読み込んでいます。


Image.networkとは?

Image.network は、インターネット上の画像を表示するためのWidgetです。

Image.network(
  hero.backdropUrl,
)

今回の作品画像は、アプリの中に保存している画像ではなく、URLから読み込んでいます。

つまり、

作品データに画像URLを書く
↓
Image.networkでその画像を表示する

という流れです。

画像URLを変えると、Home画面の背景画像も変わります。


Positioned.fillとは?

背景画像は、Positioned.fill で囲まれています。

Positioned.fill(
  child: Image.network(...),
),

Positioned.fill は、Stack の中で使います。

意味としては、

親のStackいっぱいに広げて表示する

です。

今回の場合は、SizedBox(height: 610) の中いっぱいに背景画像を広げています。

そのため、Home画面上部全体に大きな画像が表示されます。

BoxFit.coverとは?

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

fit: BoxFit.cover,

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

画像の縦横比を保ったまま、表示エリア全体を埋めるように拡大・縮小します。

そのため、画像の一部が少し切れることもあります。

ただし、背景画像として使う場合は、余白が出るよりも、画面全体にしっかり広がった方がきれいに見えます。

そのため、今回のようなHome画面の大きな背景画像には BoxFit.cover がよく合います。

指定見え方
BoxFit.cover枠いっぱいに表示する。一部が切れることがある
BoxFit.contain画像全体を表示する。余白が出ることがある
BoxFit.fill枠に合わせて伸ばす。画像がゆがむことがある

背景画像では、基本的に cover が使いやすいです。


黒いグラデーションを重ねる

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

Positioned.fill(
  child: DecoratedBox(
    decoration: BoxDecoration(
      gradient: LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [
          NetflixColors.black,
          Colors.black.withValues(alpha: 0.92),
          Colors.black.withValues(alpha: 0.28),
          Colors.black.withValues(alpha: 0.78),
          NetflixColors.black,
        ],
        stops: const [0.0, 0.16, 0.38, 0.78, 1.0],
      ),
    ),
  ),
),

このグラデーションの目的は、背景画像を少し暗くして、上に乗せる文字やボタンを読みやすくすることです。

画像の上にそのまま白い文字を置くと、画像の明るい部分では文字が読みにくくなることがあります。

そこで、黒い半透明のグラデーションを重ねています。


なぜグラデーションが必要なの?

背景画像が明るいシーンだった場合、白い文字は見えにくくなります。

でも、画像の上に黒いレイヤーを重ねると、文字が読みやすくなります。

今回のグラデーションは、上と下をしっかり暗くして、中央は少し画像が見えるようにしています。

上:黒くする
中央:画像を見せる
下:文字やボタンがあるので暗くする

このように、グラデーションは見た目をかっこよくするだけではありません。

文字を読みやすくするためにも使います。

動画アプリ風のUIでは、とてもよく出てくる表現です。


colorsとstopsの意味

グラデーションでは、色の並びを colors で指定しています。

colors: [
  NetflixColors.black,
  Colors.black.withValues(alpha: 0.92),
  Colors.black.withValues(alpha: 0.28),
  Colors.black.withValues(alpha: 0.78),
  NetflixColors.black,
],

alpha は透明度のようなものです。

見え方
1.0しっかり表示
0.92かなり黒い
0.28少しだけ黒い
0.78しっかり暗い
0.0透明

そして、stops は色の位置を決めています。

stops: const [0.0, 0.16, 0.38, 0.78, 1.0],

ざっくり言うと、0.0 が一番上、1.0 が一番下です。

この設定によって、画面の上と下は暗く、中央は画像が見えやすくなっています。


ヘッダーを上に重ねる

次に、上部のヘッダーを重ねています。

Positioned(
  left: 0,
  right: 0,
  top: 0,
  child: NetflixHeader(
    selectedCategory: selectedCategory,
    onTapMenu: onTapMenu,
    onSelectCategory: onSelectCategory,
  ),
),

Positioned は、Stack の中で位置を指定するために使います。

ここでは、

左端から0
右端から0
上から0

なので、画面の上いっぱいに NetflixHeader を配置しています。

NetflixHeader の中には、メニューアイコン、ロゴ、プロフィール画像、カテゴリタブがあります。

Home画面の上部に固定されているように見える部分です。


Positionedとは?

Positioned は、Stack の中で「どこに置くか」を指定するWidgetです。

たとえば、今回のコードでは、

top: 0,
left: 0,
right: 0,

と指定しています。

これは、

上にぴったり
左にぴったり
右にぴったり

という意味です。

別の場所では、下にタイトルやボタンを配置しています。

Positioned(
  left: 18,
  right: 18,
  bottom: 34,
  child: Column(
    children: [
      ...
    ],
  ),
),

これは、

左に18px余白
右に18px余白
下から34px上に配置

という意味です。

StackPositioned を組み合わせると、背景画像の上に自由にUIを置けるようになります。

下部にロゴ・タイトル・ボタンを置く

Home画面上部の下の方には、ロゴ、作品タイトル、カテゴリ情報、ボタンを配置しています。

Positioned(
  left: 18,
  right: 18,
  bottom: 34,
  child: Column(
    children: [
      const NetflixWordLogo(height: 38),
      const SizedBox(height: 18),
      Text(
        hero.title.toUpperCase(),
        textAlign: TextAlign.center,
        style: const TextStyle(
          color: NetflixColors.white,
          fontSize: 30,
          fontWeight: FontWeight.w900,
          letterSpacing: 2.4,
          height: 1.05,
        ),
      ),
      const SizedBox(height: 14),
      Text(
        '${hero.category} • ${hero.seasonLabel} • ${hero.rating}',
        style: const TextStyle(
          color: NetflixColors.white,
          fontSize: 13,
          fontWeight: FontWeight.w700,
        ),
      ),
      const SizedBox(height: 18),
      Row(
        children: [
          ...
        ],
      ),
    ],
  ),
),

ここでも Positioned を使っています。

bottom: 34 と書くことで、背景画像エリアの下から少し上に配置しています。

つまり、画面の下の方にロゴ、タイトル、ボタンをまとめて置いているということです。


作品タイトルを大きく表示する

作品タイトルは、次のように表示しています。

Text(
  hero.title.toUpperCase(),
  textAlign: TextAlign.center,
  style: const TextStyle(
    color: NetflixColors.white,
    fontSize: 30,
    fontWeight: FontWeight.w900,
    letterSpacing: 2.4,
    height: 1.05,
  ),
),

ポイントは、hero.title.toUpperCase() です。

これは、作品タイトルを大文字にして表示する処理です。

たとえば、作品タイトルが、

Squid Game

だった場合、画面では次のように表示されます。

SQUID GAME

動画アプリ風の大きなタイトルでは、大文字にすると、見出しとして強く見えます。


TextStyleで文字の見た目を整える

タイトルの見た目は、TextStyle で細かく調整しています。

style: const TextStyle(
  color: NetflixColors.white,
  fontSize: 30,
  fontWeight: FontWeight.w900,
  letterSpacing: 2.4,
  height: 1.05,
),

それぞれの意味は次の通りです。

指定内容
color文字色を白にする
fontSize文字サイズを30にする
fontWeightかなり太くする
letterSpacing文字と文字の間を広げる
height行の高さを少し詰める

fontWeight: FontWeight.w900 は、かなり太い文字です。

作品タイトルを主役に見せたいので、ここでは強めの文字にしています。


作品情報を表示する

タイトルの下には、カテゴリ、シーズン、年齢制限を表示しています。

Text(
  '${hero.category} • ${hero.seasonLabel} • ${hero.rating}',
  style: const TextStyle(
    color: NetflixColors.white,
    fontSize: 13,
    fontWeight: FontWeight.w700,
  ),
),

ここでは、文字の中に変数を埋め込んでいます。

たとえば、次のようなデータが入っていたとします。

category: 'TV Drama',
seasonLabel: 'Season 1',
rating: '18+',

その場合、画面には次のように表示されます。

TV Drama • Season 1 • 18+
  • は区切り記号です。

こうすると、短いスペースで作品情報をすっきり見せられます。


ボタンを横に並べる

次に、My List、Play、Infoボタンを表示している部分です。

Row(
  children: [
    HeroActionButton(
      icon: Icons.add,
      label: 'My List',
      onTap: () {},
    ),
    const SizedBox(width: 12),
    Expanded(
      child: PlayButton(
        onTap: () {
          Navigator.of(context).push(
            MaterialPageRoute<void>(
              builder: (context) =>
                  YouTubePlayerPage(movie: hero),
            ),
          );
        },
      ),
    ),
    const SizedBox(width: 12),
    HeroActionButton(
      icon: Icons.info_outline,
      label: 'Info',
      onTap: () {
        Navigator.of(context).push(
          MaterialPageRoute<void>(
            builder: (context) => MovieDetailPage(movie: hero),
          ),
        );
      },
    ),
  ],
),

ここでは Row を使って、3つのボタンを横に並べています。

My List | Play | Info

中央のPlayボタンだけは Expanded で包まれています。

そのため、左右の小さなボタンよりも横に広く表示されます。


My Listボタン

左側のボタンは、HeroActionButton を使っています。

HeroActionButton(
  icon: Icons.add,
  label: 'My List',
  onTap: () {},
),

これは、アイコンと文字を縦に並べたシンプルなボタンです。

今のコードでは、onTap: () {} になっているので、押しても特に何も起きません。

ただし、あとからお気に入り保存などの機能を入れる場所として使えます。

今は「見た目だけ先に作っている」と考えると分かりやすいです。


Playボタン

中央のPlayボタンは、PlayButton という部品で作っています。

Expanded(
  child: PlayButton(
    onTap: () {
      Navigator.of(context).push(
        MaterialPageRoute<void>(
          builder: (context) =>
              YouTubePlayerPage(movie: hero),
        ),
      );
    },
  ),
),

このボタンを押すと、YouTubePlayerPage に移動します。

そのときに、movie: hero を渡しています。

つまり、

Home画面で表示しているメイン作品のデータを、
YouTube再生画面に渡す

ということです。

YouTubePlayerPage 側では、その作品データの youtubeVideoId を使って動画を再生します。


ここで使っているのが、Navigator.of(context).push です。

Navigator.of(context).push(
  MaterialPageRoute<void>(
    builder: (context) => YouTubePlayerPage(movie: hero),
  ),
);

これは、新しい画面へ移動するためのコードです。

流れとしてはこうです。

Playボタンを押す
↓
YouTubePlayerPageを開く
↓
heroの作品データを渡す
↓
その作品のYouTube動画が再生される

push は、今の画面の上に新しい画面を重ねるイメージです。

戻るボタンを押すと、前のHome画面に戻れます。


Infoボタン

右側のInfoボタンも、HeroActionButton で作っています。

HeroActionButton(
  icon: Icons.info_outline,
  label: 'Info',
  onTap: () {
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (context) => MovieDetailPage(movie: hero),
      ),
    );
  },
),

このボタンを押すと、作品詳細画面に移動します。

こちらも movie: hero を渡しています。

つまり、Home画面上部に出ている作品の詳細画面を開く流れです。

Infoボタンを押す
↓
MovieDetailPageを開く
↓
heroの作品データを渡す
↓
タイトル・説明文・カテゴリなどを表示する

HeroActionButtonを見てみよう

HeroActionButton の中身も確認しておきましょう。

class HeroActionButton extends StatelessWidget {
  const HeroActionButton({
    super.key,
    required this.icon,
    required this.label,
    required this.onTap,
  });

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

このボタンは、アイコン、文字、タップ時の処理を受け取ります。

使う側では、次のように指定します。

HeroActionButton(
  icon: Icons.info_outline,
  label: 'Info',
  onTap: () {
    ...
  },
),

このように部品化しておくと、My ListボタンとInfoボタンのような似た見た目のボタンを簡単に使い回せます。


HeroActionButtonの見た目

中身はこのようになっています。

@override
Widget build(BuildContext context) {
  return SizedBox(
    width: 76,
    child: GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: onTap,
      child: Column(
        children: [
          Icon(icon, color: NetflixColors.white, size: 28),
          const SizedBox(height: 5),
          Text(
            label,
            style: const TextStyle(
              color: NetflixColors.white,
              fontSize: 11,
              fontWeight: FontWeight.w700,
            ),
          ),
        ],
      ),
    ),
  );
}

ここでは、アイコンと文字を Column で縦に並べています。

アイコン
↓
文字

また、GestureDetector を使ってタップできるようにしています。


PlayButtonを見てみよう

中央のPlayボタンは、白い横長ボタンです。

class PlayButton extends StatelessWidget {
  const PlayButton({
    super.key,
    required this.onTap,
  });

  final VoidCallback onTap;

見た目は次のように作っています。

@override
Widget build(BuildContext context) {
  return SizedBox(
    height: 42,
    child: ElevatedButton.icon(
      onPressed: onTap,
      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',
        style: TextStyle(
          fontSize: 16,
          fontWeight: FontWeight.w900,
        ),
      ),
    ),
  );
}

ElevatedButton.icon を使うと、アイコン付きのボタンを簡単に作れます。

ここでは、白い背景に黒い文字で、動画アプリらしいPlayボタンにしています。


Home画面らしさは「重ね方」で決まる

今回のHome画面上部は、次のように重なっています。

1. 背景画像
2. 黒いグラデーション
3. 上部ヘッダー
4. ロゴ
5. 作品タイトル
6. 作品情報
7. My List / Play / Info ボタン

この重ね方が、動画アプリ風の見た目を作っています。

特に、StackPositioned を使うことで、背景画像の上に自由にUIを置くことができます。

この考え方は、動画アプリだけでなく、旅行アプリ、飲食店アプリ、イベントアプリなどでも使えます。

観光地の写真の上に、地域名と予約ボタンを重ねる
レストランの写真の上に、店名とメニューを見るボタンを重ねる
イベントの写真の上に、開催日と参加ボタンを重ねる

画面をかっこよく見せたいときは、ただ並べるだけでなく、「重ねる」レイアウトも考えてみましょう。


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

今回は、Home画面上部の高さを変えてみましょう。

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

return SizedBox(
  height: 610,
  child: Stack(

この 610 を少し小さくしてみます。

return SizedBox(
  height: 540,
  child: Stack(

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

Home画面上部のビジュアルエリアが少し低くなり、その下の作品一覧が早く見えるようになります。

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

height: 680,

大きくすると迫力は出ますが、下の作品一覧が見えにくくなります。

UIでは、見た目の迫力と使いやすさのバランスが大切です。


タイトルの大きさをカスタマイズしてみよう

次に、作品タイトルの文字サイズを変えてみます。

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

fontSize: 30,

これを少し大きくします。

fontSize: 36,

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

作品タイトルが大きくなります。

ただし、大きくしすぎると、長いタイトルが画面に収まりにくくなります。

タイトルが長い作品もあるので、画面全体のバランスを見ながら調整しましょう。


Playボタンの文字をカスタマイズしてみよう

Playボタンの表示文字を変えてみます。

PlayButton の中にある次のコードを探してください。

label: const Text(
  'Play',

これを日本語にしてみます。

label: const Text(
  '再生',

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

中央のボタンが 再生 に変わっていれば成功です。

授業用のアプリとして分かりやすくしたい場合は、日本語にするのも良い方法です。


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

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

まず、hero.backdropUrl に入っているURLが正しいか確認してください。

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

ブラウザで画像URLを開いて、画像が表示されるか確認すると分かりやすいです。

Q. 文字が読みにくいです。

背景画像の明るさによって、白文字が見づらくなることがあります。

その場合は、黒いグラデーションを少し強くします。

Colors.black.withValues(alpha: 0.28),

これを少し濃くします。

Colors.black.withValues(alpha: 0.45),

中央部分が少し暗くなり、文字が読みやすくなります。

Q. Playボタンを押しても動画が開きません。

この部分があるか確認してください。

Navigator.of(context).push(
  MaterialPageRoute<void>(
    builder: (context) => YouTubePlayerPage(movie: hero),
  ),
);

また、hero.youtubeVideoId が正しく入っているかも確認しましょう。


チャレンジ

チャレンジ1:Home画面上部の高さを変えてみよう

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

height: 610,

これを次のように変えてみましょう。

height: 540,

Home画面の見え方がどう変わるか確認してください。

チャレンジ2:タイトルの文字を大きくしよう

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

fontSize: 30,

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

fontSize: 36,

作品タイトルが大きくなるか確認しましょう。

チャレンジ3:Playボタンを日本語にしよう

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

label: const Text(
  'Play',

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

label: const Text(
  '再生',

チャレンジ4:背景画像の暗さを調整しよう

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

Colors.black.withValues(alpha: 0.28),

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

Colors.black.withValues(alpha: 0.45),

背景画像が少し暗くなり、文字が読みやすくなるか確認してください。


チャレンジの答え

チャレンジ1の答え

変更前:

height: 610,

変更後:

height: 540,

Home画面上部のビジュアルエリアが少し低くなります。

チャレンジ2の答え

変更前:

fontSize: 30,

変更後:

fontSize: 36,

作品タイトルが大きく表示されます。

チャレンジ3の答え

変更前:

label: const Text(
  'Play',

変更後:

label: const Text(
  '再生',

中央のPlayボタンの文字が日本語になります。

チャレンジ4の答え

変更前:

Colors.black.withValues(alpha: 0.28),

変更後:

Colors.black.withValues(alpha: 0.45),

背景画像の中央部分が少し暗くなり、文字が読みやすくなります。


この節のまとめ

この節では、Home画面上部のUIを作る仕組みを学びました。

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

  • Home画面上部は、NetflixTopArea で作っている。
  • hero は、Home画面のメイン作品として表示する MovieItem
  • Stack を使うと、背景画像の上に文字やボタンを重ねられる。
  • Positioned.fill を使うと、背景画像をエリアいっぱいに広げられる。
  • Image.network は、URLから画像を読み込んで表示する。
  • BoxFit.cover は、画像を枠いっぱいに表示する指定。
  • 黒いグラデーションを重ねると、文字が読みやすくなる。
  • Positioned を使うと、ヘッダーやボタンの位置を細かく指定できる。
  • Navigator.push を使うと、PlayボタンやInfoボタンから別画面へ移動できる。
  • ボタンやロゴを部品化しておくと、同じ見た目を使い回しやすい。

次のステップ

次の節では、背景画像の上に黒いグラデーションを重ねる表現を、もう少し詳しく見ていきます。

画像の上に文字を置くとき、どうすれば読みやすく、かっこよく見えるのかを学びます。

動画アプリ風のUIだけでなく、いろいろなアプリに使える考え方です。

教材トップへ戻る