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

【メニュー表示】メニューアイコンから下に出るモーダルUIを表示する

この節で学ぶこと

前の節では、Home画面上部にあるカテゴリタブを使って、ShowsMoviesGamesNew & Hot の表示を切り替える方法を学びました。

今回の節では、左上のメニューアイコンをタップしたときに、画面下からメニューが出てくる仕組みを見ていきます。

このようなUIは、アプリではよく使われます。

画面下からメニューが出る
↓
ユーザーが項目を選ぶ
↓
閉じる、または別の処理をする

Flutterでは、このような下から出るモーダルUIを showModalBottomSheet で作れます。

今回のアプリでは、メニューアイコンを押すと、NetflixMenuSheet というメニュー画面が下から表示されます。


今回見るコード

メニューを開く処理は、Home画面の中にあります。

void openMenu() {
  showModalBottomSheet<void>(
    context: context,
    backgroundColor: Colors.transparent,
    barrierColor: Colors.black.withValues(alpha: 0.55),
    builder: (context) {
      return const NetflixMenuSheet();
    },
  );
}

そして、この openMenuNetflixTopArea に渡しています。

NetflixTopArea(
  hero: hero,
  selectedCategory: selectedCategory,
  onTapMenu: openMenu,
  onSelectCategory: selectCategory,
),

さらに、NetflixTopArea から NetflixHeader に渡しています。

NetflixHeader(
  selectedCategory: selectedCategory,
  onTapMenu: onTapMenu,
  onSelectCategory: onSelectCategory,
),

最後に、メニューアイコンをタップしたときに実行しています。

GestureDetector(
  behavior: HitTestBehavior.opaque,
  onTap: onTapMenu,
  child: const Icon(
    Icons.menu_rounded,
    color: NetflixColors.white,
    size: 32,
  ),
),

つまり、流れはこうです。

メニューアイコンを押す
↓
onTapMenuが実行される
↓
openMenuが呼ばれる
↓
showModalBottomSheetが動く
↓
NetflixMenuSheetが下から表示される

showModalBottomSheetとは?

showModalBottomSheet は、画面下から出てくるメニューを表示するための関数です。

showModalBottomSheet<void>(
  context: context,
  builder: (context) {
    return const NetflixMenuSheet();
  },
);

「BottomSheet」は、下から出るシートのことです。

アプリでは、次のような場面でよく使います。

使う場面
メニュー表示設定、プロフィール、通知、ヘルプ
選択肢の表示並び替え、カテゴリ選択、フィルター
操作の確認削除、共有、保存
簡単な入力コメント、メモ、検索条件

今回のアプリでは、左上のメニューアイコンを押したときに、下からメニューを表示するために使っています。


contextとは?

showModalBottomSheet には、context を渡しています。

context: context,

context は、Flutterが「今どの画面の中で処理しているか」を知るための情報です。

showModalBottomSheet は、現在の画面の上に新しいUIを重ねて表示します。

そのため、どの画面の上に表示するのかを知る必要があります。

そこで context を渡しています。

最初は難しく感じるかもしれませんが、画面遷移やダイアログ、モーダルを出すときにはよく出てくるものです。


backgroundColorを透明にする

今回のコードでは、背景色を透明にしています。

backgroundColor: Colors.transparent,

これは、BottomSheet自体の背景を透明にする指定です。

なぜ透明にするのでしょうか。

理由は、NetflixMenuSheet の中で、角丸や背景色を自分で細かく作りたいからです。

もしここで背景色を黒にすると、Flutter標準の四角いシート感が強く出てしまいます。

そこで、外側は透明にして、中の NetflixMenuSheet 側でデザインを作っています。


barrierColorとは?

次に見てほしいのが barrierColor です。

barrierColor: Colors.black.withValues(alpha: 0.55),

barrierColor は、モーダルの後ろに表示される暗い背景色です。

メニューが出ているとき、後ろのHome画面が少し暗くなります。

これによって、ユーザーは「今はメニューが前面に出ている」と分かります。

Home画面
↓
黒い半透明の背景
↓
下から出るメニュー

この黒い半透明の背景があることで、メニューに視線が集まりやすくなります。


alphaで暗さを調整する

barrierColor では、黒の透明度を指定しています。

Colors.black.withValues(alpha: 0.55)

alpha は透明度を表します。

見え方
0.0完全に透明
0.3少し暗い
0.55しっかり暗い
0.8かなり暗い
1.0完全な黒

今回の 0.55 は、背景をほどよく暗くする値です。

背景をもっと暗くしたい場合は、次のようにできます。

barrierColor: Colors.black.withValues(alpha: 0.75),

逆に、背景を少しだけ暗くしたい場合は、次のようにできます。

barrierColor: Colors.black.withValues(alpha: 0.35),

builderで表示する中身を返す

showModalBottomSheet では、builder の中で表示するWidgetを返します。

builder: (context) {
  return const NetflixMenuSheet();
},

今回表示するのは、NetflixMenuSheet です。

つまり、showModalBottomSheet は「下から出る場所」を用意し、その中身として NetflixMenuSheet を表示しています。

showModalBottomSheet
└── NetflixMenuSheet

このように、表示する中身を別のWidgetとして分けておくと、コードが読みやすくなります。


NetflixMenuSheetとは?

NetflixMenuSheet は、下から出るメニュー本体です。

class NetflixMenuSheet extends StatelessWidget {
  const NetflixMenuSheet({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
      padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
      decoration: BoxDecoration(
        color: NetflixColors.cardBlack,
        borderRadius: BorderRadius.circular(22),
        border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
      ),
      child: SafeArea(
        top: false,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Container(
              width: 42,
              height: 4,
              decoration: BoxDecoration(
                color: Colors.white.withValues(alpha: 0.28),
                borderRadius: BorderRadius.circular(999),
              ),
            ),
            const SizedBox(height: 18),
            NetflixMenuItem(
              icon: Icons.notifications_none_rounded,
              title: 'Notifications',
              subtitle: 'Check updates from Netaflix',
              onTap: () => Navigator.pop(context),
            ),
            NetflixMenuItem(
              icon: Icons.download_for_offline_outlined,
              title: 'Downloads',
              subtitle: 'Manage offline videos',
              onTap: () => Navigator.pop(context),
            ),
            NetflixMenuItem(
              icon: Icons.settings_outlined,
              title: 'App Settings',
              subtitle: 'Playback, quality, and account',
              onTap: () => Navigator.pop(context),
            ),
            NetflixMenuItem(
              icon: Icons.help_outline_rounded,
              title: 'Help Center',
              subtitle: 'Support and information',
              onTap: () => Navigator.pop(context),
            ),
          ],
        ),
      ),
    );
  }
}

この中で、メニューの見た目を作っています。

中身を大きく分けると、こうです。

外側の余白
↓
黒い角丸カード
↓
上のつまみ
↓
メニュー項目
↓
タップしたら閉じる

marginで外側の余白を作る

まず、外側の余白を見てみましょう。

margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),

これは、BottomSheetの外側に余白を作る指定です。

位置余白
12
0
12
12

左右と下に少し余白を作ることで、画面いっぱいにべったり広がるのではなく、カードのように浮いた見た目になります。

この余白があるだけで、メニューが少し高級感のあるUIに見えます。


paddingで内側の余白を作る

次に、内側の余白です。

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

これは、メニューカードの中身と端の間に余白を作る指定です。

すべての方向に 18 の余白を入れています。

もし padding がないと、メニュー項目がカードの端に近づきすぎて、窮屈に見えます。

margin:外側の余白
padding:内側の余白

この2つの違いは、UIを作るときによく出てきます。


BoxDecorationでカードの見た目を作る

メニューシートの背景は、BoxDecoration で作っています。

decoration: BoxDecoration(
  color: NetflixColors.cardBlack,
  borderRadius: BorderRadius.circular(22),
  border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),

ここでは、次の3つを指定しています。

指定内容
colorカードの背景色
borderRadius角丸
border薄い境界線

背景色は、真っ黒ではなく NetflixColors.cardBlack を使っています。

color: NetflixColors.cardBlack,

真っ黒よりも少しだけ明るい黒を使うことで、背景との差が出て、カードとして見えやすくなります。


borderRadiusで角丸にする

カードの角丸は、次のコードで指定しています。

borderRadius: BorderRadius.circular(22),

数字が大きいほど、角が丸くなります。

今回の 22 は、かなりしっかり丸い印象です。

動画アプリ風の黒いUIでも、少し角丸を入れると、スマホアプリらしい柔らかさが出ます。

もっと角ばった印象にしたい場合は、次のようにできます。

borderRadius: BorderRadius.circular(8),

もっと丸くしたい場合は、次のようにできます。

borderRadius: BorderRadius.circular(30),

borderで薄い線を入れる

カードには、薄い境界線も入っています。

border: Border.all(color: Colors.white.withValues(alpha: 0.08)),

黒い背景の中で黒いカードを置くと、境界が分かりにくくなることがあります。

そこで、白を少しだけ透明にした線を入れています。

alpha: 0.08 なので、かなり薄い線です。

真っ白な線ではなく
ほとんど見えないくらいの薄い線

これにより、カードの輪郭が自然に見えるようになります。


SafeAreaで下の余白に対応する

メニューシートの中にも、SafeArea があります。

child: SafeArea(
  top: false,
  child: Column(
    ...
  ),
),

ここでは、top: false としています。

メニューは画面下から出るので、上側の安全エリアはあまり気にしなくて大丈夫です。

一方で、下側にはホームインジケーターがある端末もあります。

SafeArea を入れておくことで、メニュー項目が下に寄りすぎるのを防げます。


Columnで縦に並べる

メニューの中身は、Column で縦に並べています。

Column(
  mainAxisSize: MainAxisSize.min,
  children: [
    ...
  ],
),

Column は、Widgetを上から下に並べるためのWidgetです。

今回の中身は、次のような並びです。

つまみ
↓
余白
↓
Notifications
↓
Downloads
↓
App Settings
↓
Help Center

メニュー項目は縦に並ぶので、Column が合っています。


mainAxisSize: MainAxisSize.minとは?

ここで大切なのが、次の指定です。

mainAxisSize: MainAxisSize.min,

これは、Column の高さを中身に合わせる指定です。

もしこれがないと、Column が必要以上に広がる場合があります。

今回のメニューは、画面全体を覆う必要はありません。

中身の分だけ高さがあれば十分です。

そのため、MainAxisSize.min を使っています。

必要な分だけ高さを取る
↓
下から出るメニューらしい見た目になる

上のつまみを作る

メニューの一番上には、短い横線があります。

Container(
  width: 42,
  height: 4,
  decoration: BoxDecoration(
    color: Colors.white.withValues(alpha: 0.28),
    borderRadius: BorderRadius.circular(999),
  ),
),

これは、ユーザーに「このシートは下から出ているメニューです」と伝えるためのつまみのようなものです。

多くのアプリでは、下から出るモーダルの上に、このような短いバーが表示されます。

━━━━

この線があるだけで、下から出たシートらしさが出ます。


widthとheightでつまみの形を作る

つまみのサイズは、次のように決めています。

width: 42,
height: 4,

横幅が 42、高さが 4 なので、細長い線になります。

さらに、角丸を大きくしています。

borderRadius: BorderRadius.circular(999),

999 のように大きい値を入れることで、端が丸いカプセルのような形になります。


NetflixMenuItemとは?

メニュー項目は、NetflixMenuItem という部品で作っています。

NetflixMenuItem(
  icon: Icons.notifications_none_rounded,
  title: 'Notifications',
  subtitle: 'Check updates from Netaflix',
  onTap: () => Navigator.pop(context),
),

このように、アイコン、タイトル、説明文、タップ時の処理を渡しています。

同じ形のメニュー項目を何度も使うので、部品化しておくと便利です。

Notifications
Downloads
App Settings
Help Center

これらはすべて同じ見た目なので、NetflixMenuItem を使い回しています。


NetflixMenuItemのコード

NetflixMenuItem は、次のようなWidgetです。

class NetflixMenuItem extends StatelessWidget {
  const NetflixMenuItem({
    super.key,
    required this.icon,
    required this.title,
    required this.subtitle,
    required this.onTap,
  });

  final IconData icon;
  final String title;
  final String subtitle;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: onTap,
      contentPadding: EdgeInsets.zero,
      leading: Icon(icon, color: NetflixColors.white),
      title: Text(
        title,
        style: const TextStyle(
          color: NetflixColors.white,
          fontWeight: FontWeight.w800,
        ),
      ),
      subtitle: Text(
        subtitle,
        style: const TextStyle(
          color: NetflixColors.muted,
          fontSize: 12,
        ),
      ),
      trailing: const Icon(
        Icons.chevron_right_rounded,
        color: NetflixColors.muted,
      ),
    );
  }
}

ListTile を使うことで、アイコン・タイトル・説明文・右矢印をきれいに並べられます。


ListTileとは?

ListTile は、リストの1行を作るためのWidgetです。

メニューや設定画面でよく使われます。

ListTile(
  leading: Icon(...),
  title: Text(...),
  subtitle: Text(...),
  trailing: Icon(...),
)

それぞれの役割は次の通りです。

指定役割
leading左側に表示するもの
titleメインの文字
subtitle補足説明
trailing右側に表示するもの
onTapタップしたときの処理

今回のメニュー項目では、左にアイコン、中央にタイトルと説明、右に矢印を置いています。

アイコン タイトル
    説明文         >

onTapでメニューを閉じる

各メニュー項目には、次のような処理が入っています。

onTap: () => Navigator.pop(context),

Navigator.pop(context) は、今開いている画面やモーダルを閉じるための処理です。

今回の場合は、下から出ているメニューを閉じます。

メニュー項目をタップする
↓
Navigator.pop(context)
↓
メニューが閉じる

今は教材用なので、どのメニュー項目を押しても閉じるだけです。

実際のアプリでは、次のように別の画面へ移動したり、設定画面を開いたりできます。

onTap: () {
  Navigator.pop(context);
  // ここに次の処理を書く
},

まずメニューを閉じてから、別の処理をする形です。


メニュー項目を追加するには?

メニュー項目を増やしたい場合は、NetflixMenuSheetColumn の中に NetflixMenuItem を追加します。

たとえば、「Account」を追加する場合は、次のようにします。

NetflixMenuItem(
  icon: Icons.account_circle_outlined,
  title: 'Account',
  subtitle: 'Manage your profile and plan',
  onTap: () => Navigator.pop(context),
),

これを、他の NetflixMenuItem の下に追加すれば、メニュー項目が増えます。

Notifications
Downloads
App Settings
Help Center
Account

このように、部品化しておくと、項目の追加がとても簡単です。


メニューの流れを整理しよう

今回のメニュー表示の流れを、もう一度整理します。

1. Home画面の左上にメニューアイコンがある
2. アイコンはGestureDetectorでタップできる
3. タップするとonTapMenuが実行される
4. onTapMenuの中身はopenMenu
5. openMenuでshowModalBottomSheetを呼ぶ
6. showModalBottomSheetの中でNetflixMenuSheetを表示する
7. NetflixMenuSheetにはNetflixMenuItemが並ぶ
8. メニュー項目をタップするとNavigator.popで閉じる

この流れが分かると、下から出るモーダルUIを自分で作れるようになります。


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

今回は、メニューの背景色を少し変えてみます。

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

color: NetflixColors.cardBlack,

これを、少し明るい黒にしたい場合は、たとえば次のようにできます。

color: Color(0xFF1F1F1F),

ただし、Color(0xFF1F1F1F) を使う場合は、const が必要な場所かどうかに注意してください。

たとえば、BoxDecoration の中では次のように書けます。

decoration: BoxDecoration(
  color: const Color(0xFF1F1F1F),
  borderRadius: BorderRadius.circular(22),
  border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),

保存して、メニューの見え方を確認してください。


角丸をカスタマイズしてみよう

メニューカードの角丸は、次のコードで決まっています。

borderRadius: BorderRadius.circular(22),

もっとシャープな印象にしたい場合は、小さくします。

borderRadius: BorderRadius.circular(10),

もっと柔らかくしたい場合は、大きくします。

borderRadius: BorderRadius.circular(30),

アプリ全体の雰囲気に合わせて調整しましょう。


メニュー項目の文字を日本語にしてみよう

たとえば、Notifications を日本語にしたい場合は、次のコードを探してください。

NetflixMenuItem(
  icon: Icons.notifications_none_rounded,
  title: 'Notifications',
  subtitle: 'Check updates from Netaflix',
  onTap: () => Navigator.pop(context),
),

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

NetflixMenuItem(
  icon: Icons.notifications_none_rounded,
  title: 'お知らせ',
  subtitle: 'NETAFLIXからの更新情報を確認します',
  onTap: () => Navigator.pop(context),
),

日本語にすると、授業用アプリとして分かりやすくなります。


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

Q. メニューアイコンを押しても何も出ません。

まず、メニューアイコンに onTapMenu がつながっているか確認します。

GestureDetector(
  onTap: onTapMenu,
  child: const Icon(Icons.menu_rounded),
),

次に、NetflixHeaderonTapMenu が渡されているか確認します。

NetflixHeader(
  selectedCategory: selectedCategory,
  onTapMenu: onTapMenu,
  onSelectCategory: onSelectCategory,
),

さらに、NetflixTopAreaopenMenu が渡されているか確認します。

NetflixTopArea(
  hero: hero,
  selectedCategory: selectedCategory,
  onTapMenu: openMenu,
  onSelectCategory: selectCategory,
),

この流れのどこかが抜けると、タップしてもメニューが表示されません。


Q. メニューが四角く表示されます。

backgroundColor: Colors.transparent が入っているか確認してください。

showModalBottomSheet<void>(
  context: context,
  backgroundColor: Colors.transparent,
  builder: (context) {
    return const NetflixMenuSheet();
  },
);

これがないと、Flutter標準の背景色が出て、角丸カードの外側がきれいに見えないことがあります。


Q. メニューが画面いっぱいに広がります。

Column に次の指定があるか確認してください。

mainAxisSize: MainAxisSize.min,

これがあると、メニューの高さが中身に合わせられます。


Q. メニューを閉じる方法が分かりません。

メニューを閉じるには、次のコードを使います。

Navigator.pop(context);

今回のメニュー項目では、次のように使っています。

onTap: () => Navigator.pop(context),

Q. withValues でエラーが出ます。

Flutterの環境によっては、次の書き方が使えない場合があります。

Colors.black.withValues(alpha: 0.55)

その場合は、次のように書き換えてください。

Colors.black.withOpacity(0.55)

考え方は同じです。


チャレンジ

チャレンジ1:メニューの背景を少し明るくしよう

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

color: NetflixColors.cardBlack,

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

color: const Color(0xFF1F1F1F),

メニューの背景が少し明るくなるか確認してください。


チャレンジ2:角丸を大きくしよう

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

borderRadius: BorderRadius.circular(22),

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

borderRadius: BorderRadius.circular(30),

メニューカードの角がより丸くなります。


チャレンジ3:Notificationsを日本語にしよう

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

NetflixMenuItem(
  icon: Icons.notifications_none_rounded,
  title: 'Notifications',
  subtitle: 'Check updates from Netaflix',
  onTap: () => Navigator.pop(context),
),

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

NetflixMenuItem(
  icon: Icons.notifications_none_rounded,
  title: 'お知らせ',
  subtitle: 'NETAFLIXからの更新情報を確認します',
  onTap: () => Navigator.pop(context),
),

チャレンジ4:Account項目を追加しよう

NetflixMenuSheet の中に、次のメニュー項目を追加します。

NetflixMenuItem(
  icon: Icons.account_circle_outlined,
  title: 'Account',
  subtitle: 'Manage your profile and plan',
  onTap: () => Navigator.pop(context),
),

追加後、メニューを開いて Account が表示されるか確認してください。


チャレンジの答え

チャレンジ1の答え

変更前:

color: NetflixColors.cardBlack,

変更後:

color: const Color(0xFF1F1F1F),

メニューの背景が少し明るくなります。


チャレンジ2の答え

変更前:

borderRadius: BorderRadius.circular(22),

変更後:

borderRadius: BorderRadius.circular(30),

メニューカードの角がより丸くなります。


チャレンジ3の答え

変更前:

NetflixMenuItem(
  icon: Icons.notifications_none_rounded,
  title: 'Notifications',
  subtitle: 'Check updates from Netaflix',
  onTap: () => Navigator.pop(context),
),

変更後:

NetflixMenuItem(
  icon: Icons.notifications_none_rounded,
  title: 'お知らせ',
  subtitle: 'NETAFLIXからの更新情報を確認します',
  onTap: () => Navigator.pop(context),
),

メニューの最初の項目が日本語になります。


チャレンジ4の答え

追加するコードはこちらです。

NetflixMenuItem(
  icon: Icons.account_circle_outlined,
  title: 'Account',
  subtitle: 'Manage your profile and plan',
  onTap: () => Navigator.pop(context),
),

たとえば、Help Center の下に追加すると、次のような並びになります。

Notifications
Downloads
App Settings
Help Center
Account

この節のまとめ

この節では、メニューアイコンをタップしたときに、画面下からモーダルUIを表示する方法を学びました。

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

  • showModalBottomSheet を使うと、画面下から出るメニューを表示できる。
  • backgroundColor: Colors.transparent にすると、メニュー本体の角丸デザインをきれいに見せやすい。
  • barrierColor を使うと、メニューの後ろの画面を暗くできる。
  • builder の中で、表示するWidgetを返す。
  • NetflixMenuSheet は、下から出るメニュー本体。
  • margin は外側の余白、padding は内側の余白。
  • BoxDecoration を使うと、背景色、角丸、境界線をまとめて指定できる。
  • SafeArea を使うと、スマホ下部のホームインジケーターに対応しやすい。
  • ColumnmainAxisSize: MainAxisSize.min を使うと、中身の高さに合わせたメニューにできる。
  • ListTile を使うと、アイコン・タイトル・説明文・右矢印のあるメニュー項目を簡単に作れる。
  • Navigator.pop(context) を使うと、開いているモーダルを閉じられる。

次のステップ

次の節では、Home画面に横スクロールの作品一覧を表示する方法を見ていきます。

動画アプリでは、作品カードが横に並んでいて、左右にスクロールできるUIがよく使われます。

次は、ListView を使って、作品カードを横に並べる方法を学んでいきましょう。

教材トップへ戻る