Flutter-ポップアップメニュー

Flutter-PopupMenuButtonの内容を改訂する意味あいと、またFlutter-ListTile内要素の位置関係を調べてみたで調べた結果をもとにiOSの以下の様なメニューが実現できないか考えてみた。

 

メニュー項目にListTileを配置し、またカスケードメニューの表示時にトップ項目がカスケード元のメニューと同じ表示になるというもの。
上のはリマインダーのメニュー項目のもの。

普通に実装した場合

PopupMenuButtonの下にPopupMenuItemを配置し、childにListTileを置くといったオーソドックスな構成をした場合、以下の様な感じになる。

ここで、実際のウィジェットの大きさを見てみた。

こちらが第1階層のメニュー。

こちらが表示順序から表示される第2階層のメニュー。

ざっと見ると、Material Design的に以下の様な問題点があると思われる。

  • leadingウィジェットのY位置は、PopupMenuItemとListTileの両方のpaddingが入るのでMaterial Designのスペックに比べ広くなってしまっている。
  • PopupMenuButtonのリップルエフェクトがListTile内だけになってしまう。
  • leadingウィジェットとタイトルとの隙間がMaterial Designの仕様通りになっていない。
    本来の値はMaterial2/3でそれぞれ20/12なのだけど、両方とも28になっていた。
  • 「表示順序」のメニューは、ListTileにsubtitleを指定しているため、高さが64dpになっている。
    Material Designのスペック的には48dp。subtitleがあるため高くなってしまうのだろう。

Material Design的に問題はないけど、ちょっとどうかなと思うところとしては以下の物。

  • カスケードメニューのタイトルとして用意したListTileの表示位置が、PopupMenuButtonの位置と若干異なる位置になる。
    これはMaterial Designのポップアップメニュー上部の枠である8dpのためだと思う。
  • 上記の図には出ていないのだけど、カスケードメニュー側の幅が1段目のメニューの幅と異なる状況になる。この問題については後で記述する。

対応方法

横のPadding調整

まずは簡単なものから。
以下の2点。

  • leadingウィジェットのY位置は、PopupMenuItemとListTileの両方のpaddingが入るのでMaterial Designのスペックに比べ広くなってしまっている。
  • PopupMenuButtonのリップルエフェクトがListTile内だけになってしまう。

これはPopupMenuItem/PopupMenuButton側のエッジを0にしてListTile側のエッジをMenuのエッジにすればいい。

またエッジのサイズはPopupMenuItem内では無指定時symmetricな16dpが使われるのだけど、Material 3は仕様的に12dpになる。
そのため、Material2/3を判別して16dp, 12dpを切り替える必要がある。

実装における補足

PopupMenuItem/PopupMenuButtonのpaddingパラメータはThemeとして保持していないので直接指定をするか、拡張テーマを作って何とかするか、はたまたラッパークラスを作るなどするしかない。

汎用性を考えなければラッパーなんだけど、そこで問題になるのがジェネリックなクラスというのとStatefulWidgetの派生ということ。

ラッパークラスを作ろうと思っても、それなりに手間がかかるという状態になった。

なので、PopupMenuItem/PopupMenuButtonのpaddingにそのまま値を設定するという実装に落ち着いた。

leadingとTitle間の調整

  • leadingウィジェットとタイトルとの隙間がMaterial Designの仕様通りになっていない。
    本来の値はMaterial2/3でそれぞれ20/12なのだけど、両方とも28になっていた。

CheckedPopupMenuItemも同様なのだけど、ListTileのデフォルトテーマの値をそのまま使用していたので、このような結果になった。

ListTileでLeadingとTitle間の調整はちょっと面倒。Flutter-ListTile内要素の位置関係を調べてみたの内容を元に整理すると以下の様になる。

  • minLeadingWidth
    24
  • visualDensity
    horizontalを0
  • horizontalTitleGap
    Material2/3に合わせ20, 12。

カスケードメニューのポップアップ位置の調整

  • カスケードメニューのタイトルとして用意したListTileの表示位置が、PopupMenuButtonの位置と若干異なる位置になる。
    これはMaterial Designのポップアップメニュー上部の枠である8dpのためだと思う。

これは上記の通りで、カスケードするためのPopupMenuButtonでoffsetにconst Offset(0, -8)を設定することで対応することができる。

現在上記の8dpという値は_kMenuScreenPaddingというプライベートな固定値になっているのでそれを流用できないのでハードコーディングでいいと思う。

Subtitleがある場合の高さ

  • 「表示順序」のメニューは、ListTileにsubtitleを指定しているため、高さが64dpになっている。
    Material Designのスペック的には48dp。subtitleがあるため高くなってしまうのだろう。

ListTileの高さ調整はほぼできないと思っていいので、これについてはあきらめるしかないと思う。

denseをtrueにしてvisualDensityでverticalを-4にすることで48dpに納めることはできたのだけど、ListTile側が計算通りに48dpにならず41dpになったので、リップルエフェクトの範囲の問題が再発してしまうことになった。

文字の大きさも小さくなり窮屈な感じがするような気がするので、これに関してはあきらめる方が良いのかもしれない。

上記を考慮した実装後の表示

ほぼほぼ理想通りになったのだけど、第2弾のメニューの幅がちょっとおかしい。

表示する文字によっては正しい表示になる場合もあるのだけど、ならない場合もある。

1段目がPopupMenuButtonで第2段目がPopupMenuItemの違いによる幅計算の問題なのかもしれないが。

第2段目の幅の対応

これに関してはPopupMenuButtonのウィジェットの幅を取り出して、それを制約条件に2段目のウィジェットを作るというやり方にして見た。

                        itemBuilder: (context) {
                          final size = context.size?.width ?? 0; // 親のサイズ
                          return [
                            // これタイトル
                            PopupMenuItem(
                              padding: edge,
                              child: ConstrainedBox(
                                constraints: BoxConstraints(minWidth: size),

上記の様にcotextから親のサイズを取り出し、それを制約条件にする感じ。

こんないい感じになった。

最終実装

  Widget build(BuildContext context) {
    final m = AppManager.a.m;

    const edge = EdgeInsets.zero; // ハードコーディングされているのと同じ設定
    return Scaffold(
      appBar: GoSettings.appBar(title: m.c1Title),
      body: Column(
        children: [
          Card(
            child: Text(m.c1Help),
          ),
          const SizedBox(height: 10),
          Theme(
            // popupmenu内のListTileのテーマを設定する
            data: Theme.of(context).copyWith(
                listTileTheme: Theme.of(context).listTileTheme.copyWith(
                      contentPadding: EdgeInsets.symmetric(
                          horizontal: Theme.of(context).useMaterial3 ? 12 : 16),
                      minLeadingWidth: 24,
                      horizontalTitleGap:
                          Theme.of(context).useMaterial3 ? 12 : 20,
                      visualDensity: Theme.of(context)
                          .listTileTheme
                          .visualDensity
                          ?.copyWith(horizontal: 0.0),
                    )),
            child: Align(
              alignment: Alignment.topRight,
              child: PopupMenuButton<String>(
                position: PopupMenuPosition.under,
                onSelected: (value) {
                  debugPrint(value);
                },
                itemBuilder: (_) {
                  return [
                    PopupMenuItem<String>(
                      padding: edge,
                      value: "display list",
                      child: ListTile(
                          leading: const SizedBox(width: 24),
                          title: Text(m.c1m1), // リスト表示
                          trailing: const Icon(Icons.info_outline)),
                    ),
                    PopupMenuItem<String>(
                      padding: edge,
                      value: "select reminder",
                      child: ListTile(
                          leading: const SizedBox(width: 24),
                          title: Text(m.c1m2), // リマインダーを選択F
                          trailing: const Icon(Icons.check_circle_outline)),
                    ),
                    PopupMenuItem<Never>(
                      padding: edge,
                      child: PopupMenuButton<int>(
                        padding: edge,
                        offset: const Offset(
                            0, -8), // y 方向は_kMenuVerticalPaddingの負の値
                        onSelected: (value) {
                          setState(() {
                            if (value < SortOrder.values.length) {
                              _order = SortOrder.values[value];
                            } else if (value == SortOrder.accending) {
                              _isAccending = true;
                            } else if (value == SortOrder.decending) {
                              _isAccending = false;
                            }
                          });
                          Navigator.of(context).pop();
                        },
                        child: ListTile(
                          leading: const Icon(Icons.expand_less),
                          title: Text(m.c1m3), // 表示順序
                          subtitle: Text(_order.msg(m)), // 選択中の順序
                          trailing: const Icon(Icons.swap_vert),
                        ),
                        itemBuilder: (context) {
                          final size = context.size?.width ?? 0; // 親のサイズ
                          return [
                            // これタイトル
                            PopupMenuItem(
                              padding: edge,
                              child: ConstrainedBox(
                                constraints: BoxConstraints(minWidth: size),
                                child: ListTile(
                                  leading: const Icon(Icons.expand_more),
                                  title: Text(m.c1m3), // 表示順序
                                  subtitle: Text(_order.msg(m)), // 選択中の順序
                                  trailing: const Icon(Icons.swap_vert),
                                ),
                              ),
                            ),
                            ...SortOrder.values.map((e) {
                              return CheckedPopupMenuItem(
                                checked: _order == e,
                                enabled: _order != e,
                                value: e.index,
                                padding: edge,
                                child: Text(e.msg(m)), // 手動・期限・作成日・優先順位・タイトル
                              );
                            }).toList(),
                            if (!_order.isManual) ...[
                              const PopupMenuDivider(),
                              CheckedPopupMenuItem(
                                checked: _isAccending,
                                value: SortOrder.accending,
                                padding: edge,
                                child: Text(m.c1m3o1), // 昇順
                              ),
                              CheckedPopupMenuItem(
                                checked: !_isAccending,
                                value: SortOrder.decending,
                                padding: edge,
                                child: Text(m.c1m3o2), // 降順
                              ),
                            ],
                          ];
                        },
                      ),
                    ),
                  ];
                },
              ),
            ),
          ),
        ],
      ),
    );
  }
}

ちょっと長いけどこんな感じ。

Text内のメッセージは、日本語/英語切り替え可能なように文字列そのままではなく、メソッドから取り出していて、コメントに表示する文字を書いている。またメニューのチェック状態とかの情報は、Stateクラス内で保持しているので、上のものを粗ママ利用すると文法エラーになるので注意が必要。

積み残し

カスケードを表示する際に、leadingアイコンを第1目はIcons.expand_less、第2段目はIcons.expand_moreに変更しているのだけど、iOSではその部分アニメーション表示している。

そういう意味でアニメーション化したいのだけど、PopupMenu系で使っているアニメーション情報をプログラム側で取得できないので、この部分はアイコンの変更でお茶を濁した感じにしてしまった。

やってやれないことはないのだろうけど、Flutterパッケージ側のソースを変更する必要があるので面倒かな。

コメント

タイトルとURLをコピーしました