[Flutter]フォーム入力時にカスタマイズ可能なボタンを持つバー付きのキーボードを表示する

モバイルアプリのフォーム入力でよくあるキーボード上部のボタンUIをFlutterでやる記事です。
2021.03.28

キーボード入力中に表示されたキーボードにボタンを追加する場合、iOSだとinputAccessoryViewをたいてい使います。以下のようなユースケースです。

これをFlutterで表現する時にはkeyboard_actionsというパッケージが人気のようです。今回はこのkeyboard_actionsを使ってみたので記事にします。

keyboard_actions

最低限の使い方の例はリポジトリのサンプルコードにあります。

今回説明に使うコードの雛形です。TextFieldを設置してそれに対応するFocusNodeを宣言しています。

import 'package:flutter/material.dart';
import 'package:habity/model/entities/subscription.dart';
import 'package:keyboard_actions/keyboard_actions.dart';

class SubPeriodCreationPage extends StatefulWidget {
  static const routeName = '/sub_period_creation';
  final Subscription sub;

  SubPeriodCreationPage({this.sub});

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

class _SubPeriodCreationPageState extends State<SubPeriodCreationPage> {
  final FocusNode _textNode1 = FocusNode();
  final FocusNode _textNode2 = FocusNode();
  final FocusNode _textNode3 = FocusNode();
  final FocusNode _textNode4 = FocusNode();
  final FocusNode _textNode5 = FocusNode();
  final FocusNode _textNode6 = FocusNode();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('成'),
      ),
      body: KeyboardActions(
        config: _keyboardActionConfig,
        child: Padding(
          padding: const EdgeInsets.all(30.0),
          child: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                TextField(
                  decoration: InputDecoration(hintText: 'TextField1'),
                  focusNode: _textNode1,
                ),
                TextField(
                  decoration: InputDecoration(hintText: 'TextField2'),
                  focusNode: _textNode2,
                ),
                TextField(
                  decoration: InputDecoration(hintText: 'TextField3'),
                  focusNode: _textNode3,
                ),
                TextField(
                  decoration: InputDecoration(hintText: 'TextField4'),
                  focusNode: _textNode4,
                ),
                TextField(
                  decoration: InputDecoration(hintText: 'TextField5'),
                  focusNode: _textNode5,
                ),
                TextField(
                  decoration: InputDecoration(hintText: 'TextField6'),
                  focusNode: _textNode6,
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  get _keyboardActionConfig {
    return KeyboardActionsConfig(
      keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
      keyboardSeparatorColor: Colors.black,
      nextFocus: true,
      actions: [],
    );
  }
}

class SubPeriodCreationPageArguments {
  final Subscription sub;

  SubPeriodCreationPageArguments({this.sub});
}

actionsにfocusNodeを引数に与えたKeyboardActionsItemを渡すことでフォームを前後に移動するボタン、及び完了ボタンが表示されます。

  get _keyboardActionConfig {
    return KeyboardActionsConfig(
      keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
      keyboardSeparatorColor: Colors.black,
      nextFocus: false,
      actions: [
        KeyboardActionsItem(focusNode: _textNode1),
        KeyboardActionsItem(focusNode: _textNode2),
        KeyboardActionsItem(focusNode: _textNode3),
        KeyboardActionsItem(focusNode: _textNode4),
        KeyboardActionsItem(focusNode: _textNode5),
        KeyboardActionsItem(focusNode: _textNode6),
      ],
    );
  }

上下のボタンをタップすることでフォーム間の移動ができるようになっています。この上下のフォームを移動するボタンを非表示にした場合、nextFocusにfalseを渡すことで非表示に出来ます。

これらのキーボードの内、どれかを非表示にしたい場合もあると思います。その場合は表示させたくないTextFieldに渡しているFocusNodeをfocusNode引数に与えたKeyboardActionsItemをactionsに渡さなければ上部のバーは非表示になります。

  get _keyboardActionConfig {
    return KeyboardActionsConfig(
      keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
      keyboardSeparatorColor: Colors.black,
      nextFocus: true,
      actions: [
        // KeyboardActionsItem(focusNode: _textNode1, toolbarButtons: []), コメントアウトしたので、一番上のTextFieldをタップした時はキーボード上部のバーが非表示になっている。
        KeyboardActionsItem(focusNode: _textNode2, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode3, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode4, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode5, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode6, toolbarButtons: []),
      ],
    );
  }

KeyboardActionsConfigというクラスで上部に表示されるバー全体についていくらかコントロールできます。上下移動のボタンは先程説明しましたnextFocusにbool型の値を渡すことで表示・非表示を操作できます。actionsに渡す値でキーボードに表示するバー内のボタンを構成することができます。他にも以下の引数があります。

  • keyboardActionsPlatform
    • プラットフォームの選択。クロスプラットフォームならKeyboardActionsPlatform.ALLになる。
  • keyboardBarColor
    • カスタムキーボードボタンを構成するバー全体の色
  • keyboardSeparatorColor
    • バーの終端の色
  get _keyboardActionConfig {
    return KeyboardActionsConfig(
      keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
      keyboardSeparatorColor: Colors.blue,
      nextFocus: true,
      keyboardBarColor: Colors.red,
      actions: [
        KeyboardActionsItem(focusNode: _textNode1, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode2, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode3, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode4, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode5, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode6, toolbarButtons: []),
      ],
    );
  }

コードを実行すると以下のような表示になります。

KeyboardActionsクラス

KeyboardActionsConfigクラスのコンストラクタ引数について説明しましたが、ここではKeyboardActionsConfigクラスのインスタンスをコンストラクタ引数に渡す必要があるKeyboardActionseクラスについて説明します。

このクラスを使用するには、ウィジェット階層の上位のどこかに追加します。そして、どの子ウィジェットからもKeyboardActionsConfigを追加して、使用したい[KeyboardAction]を設定します。 使用したい[KeyboardAction]で構成します。これらは,ラップされたフォーカスノードが選択されるたびに表示されます.

このクラスは以下のようにサイズを管理しています。

<br /><br />/// We manage resizing ourselves so that:
///
///   1. using scaffold is not required
///   2. content is only shrunk as needed (a problem with scaffold)
///   3. we shrink an additional [_kBarSize] so the keyboard action bar doesn't cover content either.
class KeyboardActions extends StatefulWidget {
  /// Any content you want to resize/scroll when the keyboard comes up

コンストラクタ引数一覧を見ます。configは先程説明したKeyboardActionsConfigを渡す引数です。childはFlutterのその他のWidgetと同じ命名規則に従って子いてWidgetを渡す引数です。渡すことが必須なのはconfigのみです。

  final Widget child;
  final KeyboardActionsConfig config;
  final bool autoScroll;
  final bool enable;
  final bool isDialog;
  final bool tapOutsideToDismiss;
  final double overscroll;
  final ScrollPhysics bottomAvoiderScrollPhysics;
  final bool disableScroll;

コード中で出てきていない引数についても見ていきます。

autoScrollにはbool型の値を渡すことになりますが、これによってコンテンツの自動スクロールの有無を制御できます。

実際にはkeyboard_actionsパッケージ内で定義されているKeyboardAvoidorというクラスのautoScrollに渡されていて、このクラスは更に内部で定義されているBottomAreaAvoiderというクラスのautoScrollという引数に渡されます。これにtrueを渡すことでシステムキーボードを避けるために、子Widgetのサイズを変更します。キーボードによって隠された分だけ挿入されます。

この自動的な制御にはそれなりのコストがかかりますが、利用者側が考えることは少なくて済みます。falseを渡した場合はこの当たりの制御を自力で行う必要がありそうです。試しにautoScrollにfalseを渡してみます。するとキーボード表示時に表示崩れ担ってしまいました。

enableはバーを有効にしたくない場合にfalseを渡します。iPadなど特定のデバイスの時はバーが必要なくなるケースがあり得ます。そのような場合にfalseを渡すことで非表示に出来ます。

isDialogはダイアログ内でkeyboard_actionsを使用している場合にtrueにする必要があります。

tapOutsideToDismissはキーボードの外側をタップした時にキーボードとバーを非表示にする操作を有効にするかどうかを選択できます。

iOSアプリで言う所の以下のような処理の有無を真偽値のみで制御できます。

overscrollはTextFieldにエラーテキストを表示する場合などに余分にスクロールしたい分をdouble型の値で追加できます。

子Widgetを格納するためにSingleChildScrollViewを使用しているBottomAreaAvoiderのスクロール物理を制御したい場合にこの引数を利用します。ListViewでスクロールの制御を行う時、iOSの上下端到達時のアニメーションを再現したりなど、に使用したことがあるかもしれません。このクラスについては以下の記事でアニメーションのサンプル画像とともに丁寧に紹介されていたのでそちらに譲ります。

最後にdisableScrollですが、これは1つのTextFieldだけにKeyboardActionsを使用し、かつ他のコンテンツをスクロールする必要がない場合にtrueを指定します。

KeyboardActionsItem

KeyboardActionsの引数actionsに渡す型、Listで登場するクラスです。ここでバーの中身を制御することになります。プロパティを紹介する前に色々な操作をkeyboard_actionsで実装してみます。

### Doneボタンのテキストは変えずにボタンを押した時の操作のみ変更する

_textNode2を渡しているKeyboardActionsItemのみ変更します。onTapActionという引数にVoidCallbackという型(Function()の関数型エイリアス)の値を渡します。ここでアラートを表示するよう制御しています。

  get _keyboardActionConfig {
    return KeyboardActionsConfig(
      keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
      nextFocus: true,
      actions: [
        KeyboardActionsItem(focusNode: _textNode1, toolbarButtons: []),
        KeyboardActionsItem(
          focusNode: _textNode2,
          onTapAction: () {
            showDialog(
                context: context,
                builder: (context) {
                  return AlertDialog(
                    content: Text("Custom Action"),
                    actions: <Widget>[
                      FlatButton(
                        child: Text("OK"),
                        onPressed: () => Navigator.of(context).pop(),
                      )
                    ],
                  );
                });
          },
        ),
        KeyboardActionsItem(focusNode: _textNode3, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode4, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode5, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode6, toolbarButtons: []),
      ],
    );
  }

実行すると以下のようになります。

見た目と押した時の処理を制御できるボタンの表示

Doneボタンではなくカスタムボタンを設置して押下時の処理もカスタマイズするにはList という型をもつtoolbarButtonsに関数のListを渡します。ButtonBuilderはWidget Function(FocusNode focusNode)の関数型エイリアスになっています。

  get _keyboardActionConfig {
    return KeyboardActionsConfig(
      keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
      nextFocus: true,
      actions: [
        KeyboardActionsItem(focusNode: _textNode1, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode2, toolbarButtons: [
          (node) {
            return GestureDetector(
              onTap: () => node.unfocus(),
              child: Container(
                color: Colors.blue,
                padding: EdgeInsets.all(8.0),
                child: Text(
                  "閉じる",
                  style: TextStyle(color: Colors.white),
                ),
              ),
            );
          },
        ]),
        KeyboardActionsItem(focusNode: _textNode3, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode4, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode5, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode6, toolbarButtons: []),
      ],
    );
  }

バーは以下のような表示になります。

Doneボタンは非表示で上下移動できるボタンのみの表示

このパッケージを使用した当初はREADEME.mdだけ読んで使っていたので非表示の仕方を理解しておらず、toolBarButtonsが空の配列だとDONEボタンが表示されるようだったので、Containerを返してボタンを非表示にしていました。

  get _keyboardActionConfig {
    return KeyboardActionsConfig(
      keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
      nextFocus: true,
      actions: [
        KeyboardActionsItem(focusNode: _textNode1, toolbarButtons: []),
        KeyboardActionsItem(
          focusNode: _textNode2,
          toolbarButtons: [
            (node) {
              return Container(width: 0.0);
            }
          ],
        ),
        KeyboardActionsItem(focusNode: _textNode3, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode4, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode5, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode6, toolbarButtons: []),
      ],
    );
  }

表示は以下のようになりました。

実際にこんなことをする必要はなくて、コンストラクタ引数のdisplayDoneButtonにbool型の値を渡すことで表示・非表示の制御ができます。また、displayArrowsという引数があってこちらでは上下に移動できるボタンの表示・非表示も制御できます。

キーボードとKeyboardActionsで表示しているバーの間にフッターを表示したい場合

footerBuilderを指定します。型に注意する必要があってWidgetではなくサイズが事前に指定されている必要があるPreferredSizeWidgetを与える必要があります。

  get _keyboardActionConfig {
    return KeyboardActionsConfig(
      keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
      nextFocus: true,
      actions: [
        KeyboardActionsItem(focusNode: _textNode1, toolbarButtons: []),
        KeyboardActionsItem(
          focusNode: _textNode2,
          toolbarButtons: [],
          footerBuilder: (_) => PreferredSize(
              child: SizedBox(
                  height: 200,
                  child: Center(
                    child: Text('フッターエリア'),
                  )),
              preferredSize: Size.fromHeight(200)),
        ),
        KeyboardActionsItem(focusNode: _textNode3, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode4, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode5, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode6, toolbarButtons: []),
      ],
    );
  }

表示は以下のようになりました。

カスタムキーボードを使用したい

カラーピッカーなどをキーボードっぽく表示したい場合などにも対応しています。

TextFieldの代わりにKeyboardCustomInputを設置します。状態の更新にはValueNotifierを使っています。

<br />    final _stringNotifier = ValueNotifier<String>('0'); // ValueNotifierの定義

    return KeyboardCustomInput<String>(
      focusNode: _customKeyboardNode2,
      height: 65,
      notifier: _stringNotifier,
      builder: (context, str, hasFocus) {
        return Container(
          alignment: Alignment.center,
          color: hasFocus ? Colors.red : Colors.blue,
          child: Text(
            str,
          ),
        );
      },
    );

上記のようにKeyboardBuildActionsの中身を変更したら、次はKeyboardActionsConfig側を修正します。KeyboardActionsItemのfooterBuilderを使ってカスタムキーボードを表現します。

  get _keyboardActionConfig {
    return KeyboardActionsConfig(
      keyboardActionsPlatform: KeyboardActionsPlatform.ALL,
      nextFocus: true,
      actions: [
        KeyboardActionsItem(focusNode: _textNode1, toolbarButtons: []),
        KeyboardActionsItem(
            focusNode: _customKeyboardNode2,
            footerBuilder: (_) => CustomKeyboard(
                  notifier: _stringNotifier,
                )),
        KeyboardActionsItem(focusNode: _textNode3, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode4, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode5, toolbarButtons: []),
        KeyboardActionsItem(focusNode: _textNode6, toolbarButtons: []),
      ],
    );
  }

// CustomKeyboard側の実装
class CustomKeyboard extends StatelessWidget
    with KeyboardCustomPanelMixin<String>
    implements PreferredSizeWidget {
  final ValueNotifier<String> notifier;

  CustomKeyboard({Key key, this.notifier}) : super(key: key);

  @override
  Size get preferredSize => Size.fromHeight(150);

  @override
  Widget build(BuildContext context) {
    return Container(
      height: preferredSize.height,
      child: Row(
        children: [
          Expanded(
            child: ElevatedButton(
              onPressed: () {
                int value = int.tryParse(notifier.value) ?? 0;
                value--;
                updateValue(value.toString());
              },
              child: FittedBox(
                child: Text(
                  "↓",
                  style: TextStyle(
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
          ),
          Expanded(
            child: ElevatedButton(
              onPressed: () {
                int value = int.tryParse(notifier.value) ?? 0;
                value++;
                updateValue(value.toString());
              },
              child: FittedBox(
                child: Text(
                  "↑",
                  style: TextStyle(
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

動作は以下のようになります。

まとめ

かなり利用されているパッケージのようですが、日本語の記事もあまりなかったので、コードを読んで自作中のアプリで利用するついでに記事にしてみました。Flutterの学習・開発は始めたばかりで説明やその裏にある認識に誤りがあるかもしれません。なにかお気づきの際はコメントやTwitterなどでお知らせください。