RSS2.0

Flutter の showModalBottomSheet() のサンプル

先週触り始めた Flutter (バージョン 0.2.8) ですが、ModalBottomSheet と呼ばれるウィジェットを使うのに苦労したので、動いたコードをサンプルとして載せて置こうと思います。

ModalBottomSheet はスマホの画面の底辺から上に向かってにょきっと出てくるダイアログの一種です。Android や iPhone で文字を入力しようとした時に出てくるキーボードみたいなやつです。
以下のスクリーンショットを見てもらうとイメージしやすいと思います。これが今回のサンプルコードの実行結果です。
flutter_showModalBottomSheet1.pngflutter_showModalBottomSheet2.png
もともと表示されていたウィジェットに重ねて表示されること、画面下からスライドして表示されること、スマホのディスプレイの形状にかかわらず、上辺以外が埋め尽くされた形のダイアログになっていることが特徴です。3 点めの、上辺以外が埋め尽くされた形のダイアログであることが特にユニークな特徴で、最近の iPhone のようにディスプレイの下辺の角が丸まっていたりすると、普通のダイアログウィジェットでは高さを最大にしても角が丸まっている領域まで広がらないのです。非矩形なダイアログを作ることができないのでしょうね。

サンプルコード

下からにょきっとでてくるウィジェットは Flutter では BottomSheet と呼ばれているようで、この BottomSheet をモーダルな状態で表示するための API が showModalBottomSheet(), モーダルでない状態で表示するための API が showBottomSheet() です。Flutter の material.dart で定義されているため、package:flutter/material.dart をインポートすれば使えます。
import 'package:flutter/material.dart';

void main() => runApp(new DemoApp());

class DemoApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      home: new DemoWidget(),
    );
  }
}

class DemoWidget extends StatefulWidget {
  @override
  _DemoWidgetState createState() => new _DemoWidgetState();
}

class _DemoWidgetState extends State<DemoWidget> {
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text("ModalBottomSheet Demo"),
        ),
        body: new Center(
            child: new RaisedButton(
                child: const Text('Open ModalBottomSheet'),
                onPressed: () => _openModalBottomSheet())));
  }

  void _openModalBottomSheet() {
    showModalBottomSheet(
        context: context,
        builder: (BuildContext context) {
          // ModalBottomSheet を押した時のイベントを捕まえるために
          // GestureDetector でラップする
          return new GestureDetector(
              onTap: () {
                // ModalBottomSheet を押した時には何もしないようにする
              },
              child: new Container(
                  // ModalBottomSheet のどこを押してもラップした GestureDetector が
                  // 検知できるように、ラップした Container には色をつけておく
                  color: Colors.white,
                  padding: const EdgeInsets.all(16.0),
                  child: new Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      new Text(
                          '1. Hot Reload in milliseconds to paint your app to life. Use a rich set of fully-customizable widgets to build native interfaces in minutes. '),
                      new Text(
                          '2. Quickly ship features with a focus on native end-user experiences. Layered architecture allows full customization, which results in incredibly fast rendering and expressive and flexible designs.'),
                      // Container 内の残りの空き領域をいっぱいに使いたいウィジェットは
                      // Expanded でラップした FittedBox 内に配置する
                      new Expanded(
                        child: new FittedBox(
                          fit: BoxFit.contain,
                          // otherwise the logo will be tiny
                          child: const FlutterLogo(),
                        ),
                      ),
                      new Text(
                          '3. Flutter’s widgets incorporate all critical platform differences such as scrolling, navigation, icons and fonts to provide full native performance on both iOS and Android. '),
                    ],
                  )));
        });
  }
}

ModalBottomSheet を開く showModalBottomSheet() には、引数 builder として WidgetBuilder 型の値を渡します。この WidgetBuilder 型引数は、普通の State の Widget build(BuildContext context) メソッドのように、BuildContext を引数、Widget を返り値とする無名関数として書いても大丈夫です。
シンプルなコードで書くとこうなります。
showModalBottomSheet(
    context: context,
    builder: (BuildContext context) {
      // ウィジェットを new して return する
      return new Container(
          ...
      )
    }
);

GitHub にコミットされている showModalBottomSheet() のデモを見てみると、以上という感じなのですが、実際に使い込んでみるとハマる箇所がいくつかあったので、それについて補足しておこうと思います。

ModalBottomSheet のどこを押しても閉じてしまう

showModalBottomSheet() を使う側としては、表示された BottomSheet の外(黒く透けて下の画面が見えている領域)を押すと BottomSheet が閉じ、BottomSheet の内側を押しても何も起きないことを期待すると思います。BottomSheet 内部にいろいろなウィジェットを置いて、簡易的な画面として実装したいという場合です。
ところが、showModalBottomSheet() の仕様としては BottomSheet の内側を押しても閉じてしまう動作になっています。本当にエラーメッセージを表示したいだけでどこを押してもすぐ閉じるようにしたいというケースもあるでしょうが、UI が作り込まれれば作り込まれるほどそういうケースは少なくなっていくと思います。
この、BottomSheet を押した時に BottomSheet が閉じないようにする、というようなオプションが showModalBottomSheet() に用意されている訳でもないため、なぜこのような動作になっているのか正直疑問が残ります。

これを回避するためには、showModalBottomSheet() に渡す WidgetBuilder が作るウィジェットを、さらに GestureDetector でラップしておく必要があります。
showModalBottomSheet(
    context: context,
    builder: (BuildContext context) {
      // ModalBottomSheet を押した時のイベントを捕まえるために
      // GestureDetector でラップする
      return new GestureDetector(
          onTap: () {
            // ModalBottomSheet を押した時には何もしないようにする
          },
          // 表示したい BottomSheet 内のウィジェットを child とする
          child: new Container(
            ...
          ),
      )
    }
);
GestureDetector は child にとったウィジェットに対するユーザー入力を検知するイベントリスナーのようなもので、BottomSheet 内に配置したいウィジェットをラップすることで、BottomSheet 本体へのタップイベントなどを自分で実装することができます。デフォルトでは BottomSheet 本体が押された場合にも BottomSheet が閉じる動作になっているので、押された時のイベント処理を空にしておけば閉じることもなくなります。上記のように、GestureDetector の onTap に空の無名関数を設定しておけばいいわけです。

GestureDetector でラップしても配置したウィジェット以外でのイベントをキャッチできない

showModalBottomSheet() で表示するウィジェットを GestureDetector でラップすることで、ラップしたウィジェットへのユーザー操作を検知できるようになりました。しかしここにも罠があって、GestureDetector が検知できるイベントがラップした個々のウィジェットの領域だけなのです。今回のサンプルコードでは BottomSheet 内部の Container に 16.0 ピクセルの padding が設定されていますが、この padding 領域のタップイベントは検知してくれません。また、BottomSheet 内に表示されている Flutter のロゴ画像の右側にも、画像サイズの都合による空き領域がありますが、ここのタップイベントも GestureDetector は検知してくれないのです。

この問題を回避するため、GestureDetector でラップする Container に色を設定しました(笑
return new GestureDetector(
     onTap: () {
       // ModalBottomSheet を押した時には何もしないようにする
     },
     child: new Container(
         // ModalBottomSheet のどこを押してもラップした GestureDetector が
         // 検知できるように、ラップした Container には色をつけておく
         color: Colors.white,
...
イベントを検知するためにウィジェットに色を設定するというのはなんともバッドノウハウのように聞こえますが、紛れもなくバッドノウハウだと思います。私もこの事実に気づいた時には驚きました。

Container に色が設定されているかどうかで動作が変わるということは、Flutter が Andorid や iOS のネイティブコードを自動生成する際に Container の設定によって作られるコンポーネントの構成が変わるということなんでしょう。
最初は Container の height と width に double.infinity を設定すれば、Container が BottomSheet 内いっぱいに広がって、どこを押しても GestureDetector がイベントを検知してくれるんじゃないかと試していましたが、だめでした。もしかしたら色を設定する以外にも回避方法があるかもしれませんが、調べきれないのでわかりません。

まあこれは正直バグだと思うので、早く直ってほしいですね。

ModalBottomSheet は Flexible ではない

これは showModalBottomSheet() に限らずFlutter を使っているとあるあるだと思いますが、何も考えずに BottomSheet 内のウィジェットを作っていると、BoxConstraint  がどうとか performResize() がどうとかいうエラーが発生します。ひどい時には黄色と黒のしましま領域ができたり、赤っぽい背景にびっしりエラーのスタックトレースが表示されたりします。この理由を知らなければ、御多分に洩れず showModalBottomSheet() を使い込むことは難しいので、ざっくりと書いておきたいと思います。私もまだ Flutter のすべてを理解できたわけではないため、あくまでざっくりとです。

Flutter のウィジェットは 2 種類に分かれます。サイズが固定されているものと、そうでないものです。

サイズが固定されているウィジェットは RaisedButton や、TextField、ListView そして showModalBottomSheet() によって作られる BottomSheet などです。これらはたいてい width や height といった設定で好きなサイズを指定できたり、ウィジェット自体のサイズが決まっていたりします。ListView は要素の分だけスクロールするのでサイズがないように感じられますが、ListView の表示領域は固定されていて、その内部を描きわけているだけのようです。

サイズが固定されていないウィジェットは、Column や Row、Flex といった Flexible に属するものです。これは数えるほどしかありません。これらのウィジェットにはサイズが決められておらず、内包する要素の分だけサイズ(viewport)が広がっていきます。

この、サイズが固定されているウィジェットとそうでないものを一緒に使おうとする場合には、各ウィジェットの実際の大きさがどうなっているかに注意する必要があります。

例えばサイズが固定されている ListView の中に、サイズが固定されていない Column を配置したとします。Column 内のウィジェットが Text の 1 つ 2 つならば、事実上 ListView の表示領域に収まるため、問題ないかもしれません。しかし、いくつもウィジェットを追加し続けていくと、いつかは ListView 内に収まりきらなくなる時がきます。大きさの決まっているウィジェットの中に、大きさの定まっていないウィジェットを入れようとして、実際に入りきらなくなるとエラーになるのです。

そうなると、サイズが固定されているウィジェット内部で Column や Row を使う場合には、残りの空き領域をいっぱいに使うにはどうすればいいのかという疑問がでると思います。今回のサンプルコードでも showModalBottomSheet() によって作られる BottomSheet 内部に Text ウィジェット等を縦並びに配置したいため、Column を使っています。Text ウィジェットはフォントのサイズによって高さが決まりますが、3 つの Text ウィジェットを配置した余りの領域がどれだけあるのか知らなければ、最後のウィジェットをぴったり収められなくなります。

この空き領域いっぱいに配置しているのが、サンプルコードの以下の FlutterLogo になります。スクリーンショットで見ると、Flutter のロゴ画像に該当する部分です。
new Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    new Text(
        '1. Hot Reload in milliseconds to paint your app to life. Use a rich set of fully-customizable widgets to build native interfaces in minutes. '),
    new Text(
        '2. Quickly ship features with a focus on native end-user experiences. Layered architecture allows full customization, which results in incredibly fast rendering and expressive and flexible designs.'),
    // Container 内の残りの空き領域をいっぱいに使いたいウィジェットは
    // Expanded でラップした FittedBox 内に配置する
    new Expanded(
      child: new FittedBox(
        fit: BoxFit.contain,
        // otherwise the logo will be tiny
        child: const FlutterLogo(),
      ),
    ),
    new Text(
        '3. Flutter’s widgets incorporate all critical platform differences such as scrolling, navigation, icons and fonts to provide full native performance on both iOS and Android. '),
  ],
)
FlutterLogo はロゴ画像なので、サイズが決まっていますが、これを Column 内の空き領域いっぱいに配置するため、Expanded でラップしています。この方法については、Column のドキュメントに記載されているサンプルコードのとおりです。
また、直接 FlutterLogo を Expanded でラップせずに、FlutterLogo を FittedBox でラップした上で、さらに Expanded でラップしていることに注目してください。Expanded によって確保された Column の残りの空き領域に合わせ、FittedBox がロゴ画像を大きく拡大しています。FittedBox を通さずに直接 Expanded でラップすると、Column 内の空き領域はきちんと確保されるものの、Flutter ロゴは画像のオリジナルサイズで表示されます。今回のサンプルコードでは私の手元の環境で動かす限り問題ありませんが、表示する画像の大きさや動かすスマホの画面解像度によってはエラーが起きるかもしれません。

最後に

showModalBottomSheet() でだいぶはまってしまったのでこんな記事になってしまいましたが、Flutter 自体はなかなかよくできたプラットフォームだと思います。

つまづく箇所もちょいちょいあるものの、マルチプラットフォームのネイティブアプリをなかなかの開発速度で作っていけますし、用意されている API もかゆいところまできちんと考えられえて設計されているものが多いです。デザイン部分を Dart 2 のコードで書かなければならないというのは、padding や色を調整したいデザイナーさんにとっては敷居が高いかもしれませんが、現状のスマホアプリの開発では共通する課題だと思います。どうしてもデザイン部分以外のコードを見ざるをえなくなりますから、そういう意味では WEB アプリでの HTML や CSS というのはなかなか活気的だったんだなーと改めて感じてしまいます。

また、showModalBottomSheet() のように癖の強い API については、まだまだ Flutter もベータ版なので、これから良くなっていけばいいなと思います。Kotlin や Swift といった背後にある技術をいかに統合していけるかというのが、Flutter に大きく期待されていることだと思います。
  FlutterDart  コメント (0)  2018/04/25 16:41:09


公開範囲:
プロフィール HN: ももかん
ゲーム作ったり雑談書いたり・・・していた時期が私にもありました。
カレンダー
<<2018, 5>>
293012345
6789101112
13141516171819
20212223242526
272829303112