🏁

【Flutter】DropdownButtonFormFieldについて深掘り

2023/12/16に公開

この記事は2023年のFlutter Advent Calendar(カレンダー 2)の16日目の記事です。


ちょっとタイトルと違いますが、「DropdownButtonFormField」でドロップダウン(プルダウン)実装しようとしている方は「DropdownMenu」というWidgetも、使い勝手の良さそうなので、一度調べてみてもいいかもしれません。

はじめに

日頃の業務で何気なく実装しているドロップダウン(プルダウン)
よくあるUIであるにも関わらず「あれ? こういう場合、どうするんだっけ??」ってありませんか。
私はあります😅

今回は「DropdownButtonFormField」にフォーカスして、
「あれ? こういう場合、どうするんだっけ??」の整理や、「DropdownButtonFormFieldの実装方法」について考えたいと思います。
※ 私なりに「ベストな実装方法」を模索した形です。

書いていること

  • ドロップダウン(プルダウン)を実装するWidgetについて
    • DropdownButtonFormFieldの特徴
  • よく実装したくなるパターンについて
  • ドロップダウン(プルダウン)の共通化について検討

検証環境

Flutter 3.16.4 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 2e9cb0aa71 (3 days ago) • 2023-12-11 14:35:13 -0700
Engine • revision 54a7145303
Tools • Dart 3.2.3 • DevTools 2.28.4

動作確認をしたリポジトリ

https://github.com/beeeyan/flutter_app_sample/tree/feature/pulldown

ドロップダウン(プルダウン)が実装できるWidgetについて(そもそもの話)

ドロップダウン(プルダウン)実装時に「DropdownButtonFormField」をよく利用するのですが、
「DropdownButtonFormField」以外にも、ドロップダウン(プルダウン)が実装できるDropdownButtonを継承したWidgetは存在します。

(私も正直脳死になっていたところがあるのですが)
ここでは、ドロップダウン(プルダウン)を実現する、公式のWidgetを見直しておきます。

それぞれの挙動
例

ソースコード

https://github.com/beeeyan/flutter_app_sample/blob/feature/pulldown/lib/feature/drop_down/presentation/drop_down_type_page.dart#L7-L104

一番上が「DropdownButton」
公式リファレンス : https://api.flutter.dev/flutter/material/DropdownButton-class.html

二つ目が「DropdownButtonFormField」
公式リファレンス : https://api.flutter.dev/flutter/material/DropdownButtonFormField-class.html

三つ目が「DropdownMenu」
公式リファレンス : https://api.flutter.dev/flutter/material/DropdownMenu-class.html

一言で言うと、「FormFieldを継承している」かどうかの違いが存在します。
プロパティの違いを整理すると以下のようになります。

DropdownButtonにだけあるもの
Widget? underline;
字のごとくunderlineの設定部分なのですが、驚くべきことにWidgetです。

DropdownButtonFormFieldにだけあるもの
InputDecoration? decoration,
void Function(T?)? onSaved
{String? Function(T?)? validator}
AutovalidateMode? autovalidateMode,
大半はFormFieldが継承して増えたプロパティになってます。

onSavedなどはForm Widgetのフォームが送信されたとき(_formKey.currentState!.save();が実行されたとき)に呼ばれる関数を指定するプロパティです。

分かりやすかった説明
Flutter TextFormField って何?

デフォルトの値が異なるもの
bool isDense
DropdownButtonFormFieldだと「true」
DropdownButtonだと「false」です。
「ボタンの高さを下げる。」プロパティになっています。
変更すると縦幅に影響します。

選定についての整理①

上記の情報を元に、DropdownButton vs DropdownButtonFormFieldの話をすると、
以下の2点でどちらを使うかは決まってくるかと思います。

  • 「InputDecoration」を使いたいか。
  • Form Widgetを利用したいか。

直感的な見た目の話をすると、枠をつけたい場合は「InputDecoration」が利用できる「DropdownButtonFormField」になります。

新規会員登録画面など、「周りのTextFiledのデザインと合わせたい」という場合も、同じ「InputDecoration」が利用できるという点では「DropdownButtonFormField」がいいかと思ってます。

選定についての整理②

「選定についての整理①」で書いたコメントをすぐに否定する形ですが、
「枠をつけたい」だけだと、現状はDropdownMenuなども視野に入ります。

こちらの方が「Material 3」を意識したもので後発です。
(2022年後半くらいから存在している : GithubのHistory)

DropdownButtonのリファレンスにDropdownMenuへの動線が用意されているくらいで
、こちらの方が今日話したいような「よく実装したくなるパターン」を網羅している節があります。
DropdownButtonのリファレンスでDropdownMenuについて言及されている箇所(しかもかなり直後)

選定するかどうかのポイントは
「Material 3」に準拠したデザインが馴染むかどうか
「Form Widget」を利用しているか否か

あたりになるかと思います。
後述しますが、デザイン部分が克服できるのなら個人的にかなりおすすめです。

その他

ドロップダウン(プルダウン)を実装するWidgetとして似ているもの
(見落とし全然あるかもしれません、、これら全部を系統立てて比べるだけで一つの記事になりましたね、、)

用途的には、「新規会員登録」などドロップダウンというよりは、備え付けのメニューに利用されることが多いイメージ。

  • PopupMenuButton
  • MenuAnchor
    • 「PopupMenuButton」のアップデートの位置付け「Material 3」に準拠
    • 用途についての訳文 : 「サブメニューの "アンカー "をマークするために使われるウィジェットで、メニューを配置するために使われる矩形を定義する」

よく実装したくなるパターン

ここからは、ひたすらに「よくやりたくなる実装」を羅列します。
(onChangeの処理が雑なのは目を瞑ってもらえると幸いです)

横幅を制限する

基本は、デザイン準拠でSizedBoxでいいとは思ってます。

提案としては、横幅の拡大が許される(ドロップダウンの横に別要素を並べていない)なら、
ドロップダウン内のテキスト量が多いパターンを考えて、IntrinsicWidthConstrainedBoxの組み合わせる方法です。
※ 今回は以降も「IntrinsicWidthConstrainedBoxパターン」で実装します。

ソースコード

IntrinsicWidth(
  child: ConstrainedBox(
    constraints: const BoxConstraints(
      minWidth: 200,
    ),
    child: DropdownButtonFormField(
      items: itemList
          .map(
            (item) => DropdownMenuItem(
              value: item,
              child: Text(
                item.name,
              ),
            ),
          )
          .toList(),
      onChanged: (value) => print(value),
    ),
  ),
),

枠をつける

枠をつけるだけなら、InputDecorationOutlineInputBorderつけたらOKです!
ただ、ここまでだと縦幅が大きく感じるため(個人的感覚の話)よく「contentPadding」も追加しています。
同じ画面に他にもTextFiledが並んでいるなら、他のFieldとInputDecorationのデザインは合わせるのがいいと思います。

contentPaddingなし
contentPaddingなし

contentPaddingあり
contentPaddingなし

    IntrinsicWidth(
      child: ConstrainedBox(
        constraints: const BoxConstraints(
          minWidth: 200,
        ),
        child: DropdownButtonFormField(
        decoration: const InputDecoration(
          // デフォルトだと縦の余白が大きく感じる
          contentPadding: EdgeInsets.symmetric(horizontal: 10),
          border: OutlineInputBorder(),
        ),
          items: itemList
              .map(
                (item) => DropdownMenuItem(
                  value: item,
                  child: Text(
                    item.name,
                  ),
                ),
              )
              .toList(),
          onChanged: (value) => print(value),
        ),
      ),
    ),

(横道) 縦幅を指定するとき

縦幅の指定方法ですが、Sizedboxなどでラップする場合と「itemHeight」でDropdownButtonFormFieldで指定する場合の二つが考えられます。

私の意見としては、ドロップダウンに最低限必要な余白が定義されているので「itemHeight」で変更した方が「ドロップダウンを崩さずに実装できる」という意味ではいいかと思ってます。

※ itemHeightはモバイルアプリでは48.0で設定されてます。
過去試した限りはSizedboxを使うと「itemHeight」で設定されているものより小さくすることも可能でした。
「itemHeight」よりもどうしても小さくしたい場合はSizedboxを利用してください。

ドロップダウンの選択肢の中に、文章量が多いものが存在する。

APIなどでドロップダウンの中身を取得するとき、文章量が想定よりも多くて困ったことってないでしょうか?
私はあります😅(ここいらへんって、みなさんチキンレースしてるんですかね?)

ベストな方法か分かりませんが、今回は以下の仕様で対応してみます。

  • ドロップダウンが表示された際は全文が表示される。
  • 選択後のテキストに以下の条件をつける
    • 横幅を制限する
    • TextOverflow.ellipsisを付与する。

文章量が多いものが存在する

ソースコード

IntrinsicWidth(
  child: ConstrainedBox(
    constraints: const BoxConstraints(
      minWidth: 200,
    ),
    child: DropdownButtonFormField(
      decoration: const InputDecoration(
        // デフォルトだと縦の余白が大きく感じる
        contentPadding: EdgeInsets.symmetric(horizontal: 10),
        border: OutlineInputBorder(),
      ),
      selectedItemBuilder: (context) {
        // 調整がシビア
        final maxWidth = MediaQuery.sizeOf(context).width - 70;
        return nameList.map((item) {
          return ConstrainedBox(
            constraints: BoxConstraints(
              minWidth: 200,
              maxWidth: maxWidth,
            ),
            child: Text(
              item.name,
              overflow: TextOverflow.ellipsis,
            ),
          );
        }).toList();
      },
      items: nameList
          .map(
            (item) => DropdownMenuItem(
              value: item,
              child: Text(
                item.name,
              ),
            ),
          )
          .toList(),
      onChanged: (value) => print(value),
    ),
  ),
),

実装の仕方としてはselectedItemBuilderを利用して、選択後の表示だけ変えました。
itemsの方を「横幅制限」かつ「overflow: TextOverflow.ellipsis,」をすると、ドロップダウンを開いたときも制限された状態で表示されてしまいます
(個人的にできれば、開いたときは全文が表示されるようになって欲しかった)

参考
DropdownButtonで選択されたテキストのスタイルをドロップダウンの開閉によって変える

maxWidthの計算がなんとも言えないです、、いつも70を引けばいいってわけではないと思うので、そちらはご注意ください。
(みなさんチキンレースしてるのか、または他の方法で回避してるのか、、すごく知りたい)

itemsに相当する部分をAPIから取得していて、表示にラグありエラーになる。

私はローディング表示したり、nullチェックしたりして調整していたのですが、
今回改めてDropdownButtonFormField調べた結果「disabledHint」というプロパティもあったので紹介です。
itmesをnullの際、disabledHintに書かれている文言が表示されます。

選択肢の一つにはできるかと思います!

表示例
disable

※ 上記を表示したソースコード

  IntrinsicWidth(
    child: ConstrainedBox(
      constraints: const BoxConstraints(
        minWidth: 200,
      ),
      child: DropdownButtonFormField(
        decoration: const InputDecoration(
          // デフォルトだと縦の余白が大きく感じる
          contentPadding: EdgeInsets.symmetric(horizontal: 10),
          border: OutlineInputBorder(),
        ),
        disabledHint: const Text('選択できません'),
        items: null,
        onChanged: (value) => print(value),
      ),
    ),
  ),

選択肢を検索できるようにしたい(× DropdownButtonFormField)

DropdownButtonFormFieldではテキスト入力ができないので、おそらくできません。
DropdownMenuではかなり簡単にできます。(ここだけでも利用を考えてもいいくらい)

enableFilterrequestFocusOnTapを有効化するだけです。

filter

ソースコード

  DropdownMenu<Item>(
    enableFilter: true,
    requestFocusOnTap: true,
    label: const Text(
      'DropdownMenu',
    ),
    onSelected: (value) {
      setState(() {
        dropdownMenuValue = value;
      });
    },
    dropdownMenuEntries: itemList
        .map(
          (item) => DropdownMenuEntry(
            value: item,
            label: item.name,
          ),
        )
        .toList(),
  ),

今のところfilterの仕方をカスタマイズするプロパティとかはなさそうです。
(やりたくなりそうなものですが)

共通化について

Flutterでどこまで共通ウィジェットにすべきか、私の中でまだ明確な答えは出せていないのですが、「ほとんどデザインが変わらない」「よく実装したくなるパターン」を網羅することを前提に、今回は一つのWidgetにしてみます。

共通化についての記事
Flutter はどこまで共通ウィジェットを作るのが正解なのか 〜ButtonStyleButton〜

実は、ここの部分が今回ドロップダウンにフォーカスして記事を書こうと思ったきっかけだったりするのですが、
毎回、DropdownButtonFormFieldを共通化しようとしたとき、String型のListで実装してしまうのが不満でした。(自分に対する不満)

DropdownButtonFormField自身はジェネリクスでどんなListでも対応可能なのに、なぜString型のListにしなければならないのか!!

公式ソースコード
https://github.com/flutter/flutter/blob/c65cab8fa3c13c481849e5fbaed572fc3e53c08b/packages/flutter/lib/src/material/dropdown.dart#L877-L886

String型のListにしてしまうと、例えば、API側で「ラベルじゃなくてID」が欲しいって場合に、「元々のListの中からラベルに合うオブジェクトを探す」余計な処理を書かなければいけなくなります。

せっかく、いろんなオブジェクトが利用可能なのに
Stringで無理矢理実装しなければいけないのなら、それこそ「共通化」すべきではない気がします。

今回はStringのListで共通化しないようにしてみます。

方針

「扱いたいデータ」と「ラベル」を別で扱うDropdownItemクラスを定義します。
「扱いたいデータ」の方はObject型としてしまいます。

https://github.com/beeeyan/flutter_app_sample/blob/feature/pulldown/lib/util/dropdown_item.dart#L1-L5

DropdownItemクラスを介して処理をすることで、
ラベルはラベルで表示し、「扱いたいデータ」は扱いデータで処理するという寸法です。

実装

共通化したWidgetのソースコード

https://github.com/beeeyan/flutter_app_sample/blob/f44255f8ea636943d77394165ca55afea105a958/lib/common_widget/custom_dropdown_button_form_field.dart#L4-L78

上記もジェネリクスを利用してます。

利用側では、DropdownItemクラスへの変換とジェネリクスの指定をして利用します。
onChange側では「扱いたいデータ」(今回であればfreezedで作成したItem)をそのまま「Item」で利用可能です

DropdownItemクラスへの変換

    final itemList2 = items.map(
      (itemMap) {
        final item = Item.fromJson(itemMap);
        return DropdownItem(item, item.name);
      },
    ).toList();

利用時の実装

  CustomDropdownButtonFormField<Item>(
    itemList: itemList2,
    onChanged: (value) {
      if (value != null) {
        print(value.name);
      }
    },
  ),

単なる一案でしかないですが、
これで「元々のListの中からラベルに合うオブジェクトを探す」処理は必要なくなります。
※ 今回記事を書くにあたって検討したもので、実績はないのでご参考までに。

そのままonChangeで受け取ったオブジェクトを処理したらいいです。

ちなみにDropdownItemクラスの考えは、DropdownMenuDropdownMenuEntryにちょっと似てます。
(私はObject型にしてしまいました)

※ 公式のソースコード
https://github.com/flutter/flutter/blob/c65cab8fa3c13c481849e5fbaed572fc3e53c08b/packages/flutter/lib/src/material/dropdown_menu.dart#L44-L56

終わりに

ドロップダウン(プルダウン)については過去に実装したものを、ずっと流用していたのですが、
この機会に実装方法見直せてよかったです。

次のプロジェクトではDropdownMenuの利用を積極的に考えたいと思います。

もし、お役に立てるようであれば幸いです。

Discussion