Thursday, September 7, 2023

Flutter PopupMenuButton デザインの可能性を探る

Flutterは、表現力豊かで美しいユーザーインターフェースを構築するための強力なフレームワークです。その中でもPopupMenuButtonは、限られた画面スペースを有効活用しつつ、ユーザーに追加のアクションやオプションを提供するための不可欠なウィジェットと言えるでしょう。一般的にはAppBarの右端に配置される「三点リーダー」メニューとしてよく知られていますが、その用途はそれだけにとどまりません。リストの各項目に対するコンテキストメニューや、フォーム内の選択肢提示など、様々な場面で活躍します。

しかし、多くの開発者はPopupMenuButtonの基本的な使い方、つまりいくつかのPopupMenuItemをリスト表示するだけに留まっているのではないでしょうか。Flutterウィジェットの真の力は、その驚異的なカスタマイズ性にあります。PopupMenuButtonも例外ではなく、デフォルトの角張った白いメニューから、アプリのブランドイメージやデザイン言語に完全に合致した、洗練されたUIコンポーネントへと昇華させることが可能です。

この記事では、PopupMenuButtonの基本的な実装方法から一歩踏み出し、その見た目を自由自在にカスタマイズするための具体的なテクニックを深く掘り下げていきます。角を丸める基本的な方法から、色、影、表示位置の調整、さらにはメニューアイテム自体のリッチなデザインまで、あなたのアプリを次のレベルへと引き上げるための実践的な知識を提供します。

PopupMenuButtonの基本を理解する

高度なカスタマイズに入る前に、まずはPopupMenuButtonの基本的な構造と必須プロパティをおさらいしましょう。最も重要なのはitemBuilderonSelectedの2つです。

  • itemBuilder: このコールバック関数は、メニューが表示される際に呼び出され、表示すべきメニュー項目のリストを返します。BuildContextを引数に取り、List<PopupMenuEntry<T>>を返す必要があります。通常、このリストにはPopupMenuItemウィジェットが含まれます。
  • onSelected: ユーザーがメニュー項目の一つを選択したときに呼び出されるコールバック関数です。選択されたPopupMenuItemvalueプロパティが引数として渡されます。この値を使って、どの項目が選択されたかを判別し、適切な処理を実行します。
  • child: メニューをトリガーするためのボタンとなるウィジェットです。このchildが指定されていない場合、PopupMenuButtonはデフォルトで三点リーダー(縦)のアイコンを表示します。これは特にAppBaractionsプロパティ内で使用する場合に便利です。

以下は、これらの基本プロパティを使用した最もシンプルな実装例です。


import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Basic PopupMenuButton'),
      ),
      body: Center(
        child: PopupMenuButton<String>(
          // (1) ユーザーが項目を選択したときの処理
          onSelected: (String value) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('Selected: $value')),
            );
          },
          // (2) 表示するメニュー項目を構築する
          itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
            const PopupMenuItem<String>(
              value: 'profile',
              child: Text('Profile'),
            ),
            const PopupMenuItem<String>(
              value: 'settings',
              child: Text('Settings'),
            ),
            const PopupMenuItem<String>(
              value: 'logout',
              child: Text('Logout'),
            ),
          ],
          // (3) メニューをトリガーするボタン
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
            decoration: BoxDecoration(
              color: Colors.blue,
              borderRadius: BorderRadius.circular(8),
            ),
            child: const Text(
              'Show Menu',
              style: TextStyle(color: Colors.white),
            ),
          ),
        ),
      ),
    );
  }
}

このコードでは、`Show Menu`と書かれた青いコンテナをタップすると、「Profile」「Settings」「Logout」の3つの項目を持つポップアップメニューが表示されます。いずれかの項目を選択すると、その項目のvalue('profile', 'settings', 'logout')がonSelectedコールバックに渡され、SnackBarで選択結果が表示されます。この基本的な動作が、すべてのカスタマイズの土台となります。

角を丸めるだけじゃない:`shape`プロパティの深淵

さて、ここからが本題です。PopupMenuButtonの見た目を劇的に変える最も効果的なプロパティがshapeです。このプロパティはShapeBorder型のオブジェクトを受け取り、メニューコンテナの形状を定義します。デフォルトの四角形から、アプリのデザインに合わせた様々な形に変更してみましょう。

`RoundedRectangleBorder`で角を丸める

最も一般的なカスタマイズは、メニューの角を丸めることです。これはRoundedRectangleBorderウィジェットを使用することで簡単に実現できます。borderRadiusプロパティにBorderRadiusを指定するだけです。


PopupMenuButton(
  // ... 他のプロパティ
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.all(Radius.circular(20.0)),
  ),
  itemBuilder: (context) => [
    // ... メニュー項目
  ],
)

この例では、すべての角の半径を20.0に設定し、全体的に柔らかい印象のメニューを作成しています。BorderRadiusは非常に柔軟で、特定の角だけを丸めることも可能です。


// 左上と右下だけを丸める
shape: RoundedRectangleBorder(
  borderRadius: BorderRadius.only(
    topLeft: Radius.circular(30.0),
    bottomRight: Radius.circular(30.0),
    topRight: Radius.circular(5.0),
    bottomLeft: Radius.circular(5.0),
  ),
),

このようにBorderRadius.onlyを使えば、非対称でユニークな形状のメニューもデザインできます。

`BeveledRectangleBorder`で角を削る

角を丸めるのではなく、削ぎ落としたようなシャープなデザインにしたい場合はBeveledRectangleBorderが役立ちます。使い方はRoundedRectangleBorderとほぼ同じで、borderRadiusプロパティで角の削ぎ落とし具合を制御します。


PopupMenuButton(
  // ... 他のプロパティ
  shape: BeveledRectangleBorder(
    borderRadius: BorderRadius.all(Radius.circular(15.0)),
  ),
  itemBuilder: (context) => [
    // ... メニュー項目
  ],
)

この形状は、工業的、あるいは少しレトロな雰囲気のデザインと相性が良いかもしれません。

`StadiumBorder`でカプセル型メニューを作る

両端が完全に丸まったカプセル型の形状を作成したい場合は、StadiumBorderを使用します。このウィジェットはborderRadiusを指定する必要がなく、自動的に最適な形状を生成してくれます。


PopupMenuButton(
  // ... 他のプロパティ
  shape: const StadiumBorder(),
  itemBuilder: (context) => [
    // ... メニュー項目
  ],
)

モダンで親しみやすいUIを作成する際に非常に便利です。

枠線を追加・カスタマイズする (`side`プロパティ)

上記のShapeBorderクラスの多くは、sideというプロパティを持っています。これを使うと、メニューのコンテナに枠線を追加できます。BorderSideオブジェクトを渡すことで、枠線の色、太さ、スタイルを自由に設定できます。


PopupMenuButton(
  // ... 他のプロパティ
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.all(Radius.circular(12.0)),
    side: BorderSide(color: Colors.orange, width: 2), // 枠線を追加
  ),
  itemBuilder: (context) => [
    // ... メニュー項目
  ],
)

この例では、半径12の角丸形状に、オレンジ色で太さ2の枠線を追加しています。これにより、メニューの視認性を高めたり、デザインのアクセントとして活用したりすることができます。

外観をさらに磨き上げる高度なカスタマイズ

形状の変更に慣れたら、次は色、影、位置といった要素を調整して、さらに洗練されたデザインを目指しましょう。

背景色と影のコントロール (`color` & `elevation`)

メニューの背景色はcolorプロパティで簡単に変更できます。デフォルトの白から、アプリのテーマカラーや、背景とのコントラストを考慮した色に変更してみましょう。

また、elevationプロパティはメニューが「浮き上がって」見える度合い、つまり影の濃さを制御します。値を大きくするほど影が濃く、広くなり、メニューが手前にあるかのような立体感を演出します。逆に0に設定すれば、影のないフラットなデザインになります。


PopupMenuButton(
  color: Colors.grey[850], // 背景色をダークグレーに
  elevation: 16, // 影を濃くする
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.all(Radius.circular(15.0)),
  ),
  itemBuilder: (context) => [
    PopupMenuItem(
      value: 'share',
      // テキストの色も背景色に合わせて変更
      child: Text('Share', style: TextStyle(color: Colors.white)),
    ),
    PopupMenuItem(
      value: 'delete',
      child: Text('Delete', style: TextStyle(color: Colors.white)),
    ),
  ],
)

この例では、ダークモードのUIにマッチするような、濃いグレーの背景色と強調された影を持つメニューを作成しています。背景色を変更した際は、メニュー項目のテキスト色も見やすいように調整することを忘れないでください。

表示位置を微調整する (`offset`プロパティ)

デフォルトでは、ポップアップメニューはトリガーとなったボタンのすぐ下、または上に表示されます。この表示位置を微調整したい場合にoffsetプロパティが役立ちます。Offsetオブジェクトを渡すことで、水平方向(dx)および垂直方向(dy)のオフセットを指定できます。

  • `dx`: 正の値は右へ、負の値は左へ移動します。
  • `dy`: 正の値は下へ、負の値は上へ移動します。

PopupMenuButton(
  // ... 他のプロパティ
  offset: const Offset(0, 50), // ボタンの下に50ピクセルの間隔を空けて表示
  itemBuilder: (context) => [
    // ... メニュー項目
  ],
  child: Icon(Icons.settings),
)

この例では、設定アイコンの真下から50ピクセル下にずらしてメニューを表示しています。これにより、トリガーボタンとメニューが重なるのを防いだり、特定のUIレイアウトに合わせたりすることが可能です。

メニューアイテムのデザイン (`PopupMenuItem` & `PopupMenuDivider`)

メニュー全体の外観だけでなく、個々の項目もカスタマイズできます。PopupMenuItemchildプロパティには、Textだけでなく、あらゆるウィジェットを配置できるのです。

例えば、アイコンとテキストを組み合わせることで、より直感的で分かりやすいメニューを作成できます。


PopupMenuButton<String>(
  onSelected: (value) { /* ... */ },
  itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
    const PopupMenuItem<String>(
      value: 'edit',
      child: Row(
        children: <Widget>[
          Icon(Icons.edit, color: Colors.blue),
          SizedBox(width: 8),
          Text('Edit'),
        ],
      ),
    ),
    const PopupMenuItem<String>(
      value: 'share',
      child: Row(
        children: <Widget>[
          Icon(Icons.share, color: Colors.green),
          SizedBox(width: 8),
          Text('Share'),
        ],
      ),
    ),
    // メニュー項目をグループ化するための区切り線
    const PopupMenuDivider(),
    const PopupMenuItem<String>(
      value: 'delete',
      enabled: false, // この項目は選択不可にする
      child: Row(
        children: <Widget>[
          Icon(Icons.delete_outline, color: Colors.grey),
          SizedBox(width: 8),
          Text('Delete (Disabled)'),
        ],
      ),
    ),
  ],
)

このコードでは、以下のテクニックを使用しています。

  1. アイコンとテキストの組み合わせ: `Row`ウィジェットを使ってアイコンとテキストを横に並べています。
  2. 区切り線: `PopupMenuDivider`をリストに含めることで、関連する項目を視覚的にグループ化するための水平線を追加しています。
  3. 項目の無効化: `PopupMenuItem`の`enabled`プロパティを`false`に設定すると、その項目はグレーアウトされ、タップできなくなります。特定の条件下でのみアクションを許可したい場合に便利です。

実用的な実装パターン

これまでに学んだカスタマイズ技術を、実際のアプリケーションでよく見られるパターンに適用してみましょう。

`AppBar`のアクションとして利用する

おそらく最も一般的な使用例は、`AppBar`の`actions`に「その他」メニューを配置するパターンです。この場合、`child`プロパティを指定せず、デフォルトの三点リーダーアイコンを使用するのが一般的です。


Scaffold(
  appBar: AppBar(
    title: const Text('AppBar Menu'),
    actions: <Widget>[
      PopupMenuButton<String>(
        onSelected: (value) {
          // Handle selection
        },
        // メニューがAppBarの下に隠れないようにオフセットを調整
        offset: const Offset(0, 40), 
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(10),
        ),
        itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
          const PopupMenuItem<String>(
            value: 'settings',
            child: Text('Settings'),
          ),
          const PopupMenuItem<String>(
            value: 'about',
            child: Text('About'),
          ),
        ],
      ),
    ],
  ),
  body: const Center(
    child: Text('Content goes here.'),
  ),
)

ここでは、`AppBar`に配置したメニューの形状をカスタマイズし、`offset`を使って`AppBar`の高さ分だけ下にずらして表示しています。

状態に応じた動的なメニュー項目

`itemBuilder`は、メニューが開かれるたびに呼び出される関数です。この特性を利用して、アプリケーションの状態に応じて表示するメニュー項目を動的に変更することができます。例えば、ユーザーのログイン状態によってメニュー内容を切り替えるような実装が可能です。


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

  @override
  _DynamicMenuExampleState createState() => _DynamicMenuExampleState();
}

class _DynamicMenuExampleState extends State<DynamicMenuExample> {
  bool _isLoggedIn = false;

  void _toggleLogin() {
    setState(() {
      _isLoggedIn = !_isLoggedIn;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Dynamic Menu'),
        actions: [
          PopupMenuButton<String>(
            onSelected: (value) {
              if (value == 'toggle_login') {
                _toggleLogin();
              }
            },
            itemBuilder: (BuildContext context) {
              // 状態に応じてメニュー項目を構築
              return <PopupMenuEntry<String>>[
                const PopupMenuItem<String>(
                  value: 'profile',
                  child: Text('Profile'),
                ),
                if (_isLoggedIn) // ログインしている場合のみ表示
                  const PopupMenuItem<String>(
                    value: 'orders',
                    child: Text('My Orders'),
                  ),
                const PopupMenuDivider(),
                PopupMenuItem<String>(
                  value: 'toggle_login',
                  child: Text(_isLoggedIn ? 'Logout' : 'Login'),
                ),
              ];
            },
          ),
        ],
      ),
      body: Center(
        child: Text(
          _isLoggedIn ? 'Welcome back!' : 'Please log in.',
          style: Theme.of(context).textTheme.headlineMedium,
        ),
      ),
    );
  }
}

この`StatefulWidget`の例では、`_isLoggedIn`という状態変数を持っています。「Login/Logout」メニュー項目を選択するとこの状態が切り替わります。`itemBuilder`内では、`_isLoggedIn`が`true`の場合にのみ「My Orders」という項目をリストに追加しています。このように、コレクション`if`構文を使うことで、宣言的かつ簡潔に動的なUIを構築できます。

アプリ全体での一貫性を保つ:テーマの活用

アプリケーション内の複数の場所で同じデザインのPopupMenuButtonを使用する場合、毎回`shape`や`color`プロパティを個別に設定するのは冗長であり、メンテナンス性も低下します。このような場合は、`ThemeData`を使ってアプリ全体に共通のスタイルを適用するのがベストプラクティスです。

`MaterialApp`の`theme`プロパティに`ThemeData`を設定し、その中の`popupMenuTheme`プロパティに`PopupMenuThemeData`を定義します。


void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Theme Demo',
      theme: ThemeData(
        primarySwatch: Colors.teal,
        // PopupMenuButtonのグローバルテーマを定義
        popupMenuTheme: PopupMenuThemeData(
          color: Colors.teal[50], // 背景色
          elevation: 8, // 影
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12.0),
            side: BorderSide(color: Colors.teal.shade200, width: 1.5),
          ),
          textStyle: TextStyle(color: Colors.teal[900], fontSize: 16), // テキストスタイル
        ),
      ),
      home: const ThemedPopupMenuPage(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Themed Menu')),
      body: Center(
        // ここではshapeなどを指定する必要がない
        child: PopupMenuButton<int>(
          itemBuilder: (context) => [
            const PopupMenuItem(value: 1, child: Text('Option 1')),
            const PopupMenuItem(value: 2, child: Text('Option 2')),
          ],
          child: const Text('Show Themed Menu'),
        ),
      ),
    );
  }
}

この設定により、`ThemedPopupMenuPage`内のPopupMenuButtonは、個別にスタイルプロパティを指定しなくても、`popupMenuTheme`で定義されたデザイン(ティール系の背景色、丸みを帯びた枠線付きの形状など)が自動的に適用されます。これにより、アプリ全体でUIの一貫性を保ちつつ、コードの重複を大幅に削減できます。

まとめ:デザインの可能性は無限大

この記事では、FlutterのPopupMenuButtonが単なる機能的なウィジェットではなく、アプリの個性を表現するための強力なデザインツールであることを示しました。

基本的な角の丸め方から始め、shapeプロパティを使った様々な形状の実現、colorelevationによる雰囲気作り、offsetによる精密なレイアウト調整、そしてPopupMenuItemの`child`を駆使したリッチな項目デザインまで、多岐にわたるカスタマイズ手法を探求しました。さらに、テーマを活用することで、これらのデザインをアプリ全体で効率的に管理する方法も学びました。

デフォルトのスタイルに満足することなく、ここで紹介したテクニックを組み合わせ、ぜひあなたのアプリならではの、ユニークで使いやすいPopupMenuButtonを創造してみてください。細部にまでこだわることで、ユーザー体験は格段に向上し、アプリ全体の品質も高まるはずです。Flutterが提供する柔軟性を最大限に活用し、デザインの可能性を追求し続けましょう。


0 개의 댓글:

Post a Comment