analysis_options.yamlをカスタマイズする(2)

昨日の続きで、analysis_options.yamlの項目たちの説明です。 昨日の復習も含めて書いてゆきます。

analyzer:
  strong-mode:
    implicit-casts: false
    implicit-dynamic: false

動的型付け・型のキャストを許可する項目です。どちらもデフォルトはtrue
動的型付け・型のキャストを禁止したい時のみfalseで指定します。

implicit-casts: false

暗黙的な型変換を許可しない設定。

Object o = 'hoge';
String s = o;

ObjectクラスのインスタンスoStringクラスの変数sに代入しようとすると文字列をStringに入れるのでコンパイルは通るのですが、implicit-casts: falseに反して暗黙的な型変換が行われるのでアラートが発生するようになります。
アラートを避けるには、型変換が行われないよう変数の型を揃える必要があります。

String o = 'hoge';
String s = o;

implicit-dynamic: false

動的型付けを許可しない設定。
castsは型変換禁止なので、ObjectStringとなると静的型付だけどアラートが出るし、dynamicは動的型付け禁止なので、型をつけていないとアラートが出るという感じですね。

抜けがちなのは配列やオブジェクトの時かなと思いました。それぞれ要素の型を指定する必要があります。

// 1. hogeはListだけど、Listの中身を指定していないのでアラートが出る
final List hoge = ['hoge'];

// 1. Listの中身をジェネリクスで指定するとアラートが解消される
final List<String> hoge = ['hoge'];

// 2. MapのListだと指定しているけど、Mapの中身を指定していないので
// Missing type arguments for map literalアラートが出る
final List<Map> hoge = [{'fuga': 'piyo'}];

// 2. Mapの要素の型を再帰的に指定するとアラートが解消される
final List<Map<String, String>> hoge = [{'fuga': 'piyo'}];

// 3. 明示的にvarを指定するとアラートは出ない
var hoge = ['hoge'];

// ちなみにそもそもvarと型は共存できない(analysis_options.yamlによらず)
// これはコンパイルエラー
// "Variables can't be declared using both 'var' and a type name."
// var List hoge = ['hoge'];

関数呼び出しの際も、返り値のクラスを指定する必要があるようです。
たとえば、showDialogメソッドで返ってくるのはAwesomeDialogインスタンスなので指定します。

// アラートが出る
showDialog(
  context: context,
  builder: (_) {
    return AwesomeDialog();
  }
);

// 返り値のクラスを指定するとアラートが出なくなる
showDialog<AwesomeDialog>(
  context: context,
  builder: (_) {
    return AwesomeDialog();
  }
);

以上、動的型付け禁止の項目でした!

Dart DevToolsの使い方

ウィジェットを並べていくFlutterは、ウィジェットのネストが深くなりがち。
どのウィジェットのHeight指定がうまくいってないの…?🤔を確認したくなった時とか、ウィジェットの構造を確認したくなった時に便利なのがDart DevToolsです。

DartDevTools - Getting Started

Android StudioVS Codeと、それぞれ説明されていますが、わたしはVSCodeをメインに使っているのでVSCodeのやり方を書いていきます。

DevToolsのインストール、立ち上げ

VSCodeからエミュレータを起動(F5)するとこんなメッセージが出てきます(DevToolsがインストールされていない初回のみ)。

Dart DevTools need to be installed
with 'pub global activate devtools' to use this feature.

DevToolsを使うにはpub global activate devtoolsでインストールしてね、と言われるので、「Activate Dart DevTools」をクリックするとflutter packages getが走ってDevToolsをActivateしてくれます。

しばらく待つとhttp://127.0.0.1:58696/?hide=debugger&port=57931のようなアドレスでブラウザが立ち上がり、ウィジェットツリーをブラウザから確認することができます。

DevToolsが立ち上がらなかった時

なんらかの原因でDevToolsが立ち上がらなかった時は、VSCodeから直接コマンドで立ち上げることができます。

$ flutter packages pub global activate devtools

ポート番号がわからなくなった時

URLはhttp://127.0.0.1で固定ですが、ポート番号は動的に振られてセッションが変わる度(アプリを再起動する度)に切り替わります。
ポート番号がわからなくなった時はVSCodeのステータスバーにある「Dart DevTools」の文字をクリックすると、ブラウザでDevToolsが立ち上がります。

DevToolsの使い方

直感的に使えるUIですが、よく使う機能を少しだけ。

  • Debug Paint
    オンにするとウィジェットの枠線を引いたり、mainAxisAlignmentの向きを表示したりしてくれます。
    モック作りではおそらく一番使うところ。

  • Select Widget Mode
    オンにすると、ブラウザでウィジェットを選択することでエミュレータウィジェットも選択することができるようになります。
    Chromeデベロッパーツールで要素を選択できるのに似ているイメージ。

  • Refresh Tree
    エミュレータはホットリロードが効きますが、DevToolsはホットリロードが効かないので手動でリロードするためのボタン。
    Navigator.pushなども反映されないので画面遷移した時も同様にリフレッシュする必要があります。

注意点としては、stackされているScaffoldが全てツリー表示されるので、違うウィジェットツリーを見ながら「あれ?ない??🤔」となりがちなこと。
ありがちなミスだと思うのでお気をつけて、快適なFlutter開発をお楽しみください!🎉

analysis_option.yamlをカスタマイズする

VSCodeやAndroidStudioを使っていると、Dart Analysis Serverが構文エラーをチェックしてくれます。非常に便利な機能ですが、これをカスタマイズできるのがanalysis_option.yaml

Customize Static Analysis - Dart

pubspec.yamlと同じディレクトリに置いておくだけでAnalyserをカスタマイズすることができます。
ちなみに、analysis_option.yamlの冒頭でincludeすることもできるそうで、ベースとなるルールをincludeして、それをプロジェクトごとにカスタマイズするという使い方も便利そうです。

include: package:pedantic/analysis_options.yaml

上述のDartのサイトに詳しく解説があるのですが、理解の為に少しずつ訳していこうと思います。

今日の項目はtype checks
Dartは動的型付言語ですが、より厳しくチェックをしたい場合、静的型付でないとエラーを出すことができます。

analyzer:
  strong-mode:
    implicit-casts: false
    implicit-dynamic: false

implicitは「暗黙的な」という意味だそう。
つまり、ひとことでいうと、暗黙的な型変換を許可しないよ、という設定。
implicit-castsimplicit-dynamicは別々に設定することができます。
どちらもデフォルトはtrue

  • implicit-casts
    値がfalseの場合、暗黙的に特定の型にキャストしません。
    公式で例示されているのは下記のコード。
Object o = 'hoge';
String s = o; // Implicit downcast

Object oに入っているのはhogeという文字列なので、Stringの変数であるsoを入れてもDartコンパイラは許してくれますが、implicit-castsをfalseにしていると暗黙的な型変換を行うと、エラーが表示されるようになります(エラーは表示のみで、コンパイラは通ります)。

  • implicit-dynamic
    値がfalseの場合、静的型を決定できないときに動的型を選択しません。

これはちょっと具体例が理解できていないので、続きはまた明日書きます!

FlutterでDialogの表示を変更できなかったお話

今日はFlutterのダイアログのお話。

ダイアログの中にテキストフィールドを準備して、バリデーション違反があったらエラーメッセージを追加したいということがありました。

ダイアログの表示自体はSimpleDialogAlertDialogを使えばできるのですが、表示の変更ができずにハマりました。

以下がコード例。
簡単に、AppBarにあるアイコンをタップするとダイアログが表示されて、ダイアログ内のボタンをタップすると表示をトグルするようなコードにしています。

class _AwesomePageState extends State<AwesomePage> {
  bool _isAwesome;
  String _awesomeText;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Everyday Awesome'),
        actions: <Widget>[
          PopupMenuButton(
            onSelected: (String value) {
              showDialog(
                context: context,
                builder: (BuildContext context) => SimpleDialog(
                      title: const Text('It says'),
                      children: <Widget>[
                        const Text('Flutter is awesome.'),
                        RaisedButton(
                          child: const Text('u a Awesome!'),
                          onPressed: () {
                            setState(() {
                              _isAwesome = !_isAwesome;
                              _awesomeText = _isAwesome
                                  ? 'u a Awesome!'
                                  : 'please tap Awesome..';
                            });
                          },
                        ),
                      ],
                    ),
              );
            },
            itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
                  const PopupMenuItem(child: const Text('A'), value: 'a'),
                ],
          )
        ],
      ),
      body: Center(
        child: const Text('Awesome'),
      ),
    );
  }
}

ボタンをタップする度にonPressedの中でsetState()して_awesomeTextが変更されて「u a Awesome!」となるはずですが、何度タップしても変更されません。
ボタンだけでなく、タイトルやテキストを変更しようとしても同じ。
おかしいな、と思ってSimpleDialogのコードを読んでみると、理由がわかりました。

class SimpleDialog extends StatelessWidget {
// ...(略)
}

StatelessWidget!!
setState()しても意味ないはずでした!
念の為AlertDialogDialogクラスを見てみても、どちらも同じくStatelessWidget

ガイドラインをそう読み込んだわけではないのですが、Material Designガイドラインを読むに、ダイアログはあくまでも警告・エラーを表示したり、選択可能なリストを表示したりといったすごくシンプルなUIを目的としているみたい。
じゃあどうすれば…というと、FlutterによるFull-Screen Dialogの実装例があるのを先輩に教えて頂きました。

ちょっと長いですが、要は、StatefulWidgetを作って、SimpleDialogの代わりに呼び出す、ということのよう。
実装するとこんな感じになりました。

class _AwesomePageState extends State<AwesomePage> {
  bool _isAwesome;
  String _awesomeText;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Everyday Awesome'),
        actions: <Widget>[
          PopupMenuButton(
            onSelected: (String value) {
              showDialog(
                context: context,
                builder: (_) {
                  return AwesomeDialog();
                },
              );
            },
            itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
                  const PopupMenuItem(child: const Text('A'), value: 'あ'),
                ],
          )
        ],
      ),
      body: Center(
        child: const Text('Awesome'),
      ),
    );
  }
}

class AwesomeDialog extends StatefulWidget {
  @override
  _AwesomeDialogState createState() => _AwesomeDialogState();
}

class _AwesomeDialogState extends State<AwesomeDialog> {
  bool _isAwesome;
  String _awesomeText;

  @override
  void initState() {
    super.initState();
    _isAwesome = false;
    _awesomeText = 'please tap Awesome..';
  }

  Widget _buildAwesomeButton() {
    return RaisedButton(
      child: Text(_awesomeText),
      onPressed: () {
        setState(() {
          _isAwesome = !_isAwesome;
          _awesomeText = _isAwesome ? 'u a Awesome!' : 'please tap Awesome..';
        });
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return SimpleDialog(
      title: const Text('It says'),
      children: <Widget>[
        const Text('Flutter is awesome.'),
        _buildAwesomeButton(),
      ],
    );
  }
}

showDialog関数のbuilderSimpleDialogではなく、AwesomeDialogを渡しています。
AwesomeDialogも返すウィジェットSimpleDialogですが、stateを持ったRaisedButtonを外だしにして渡すことで、stateの変更が反映されるようになりました。

では、最初の例でもRaisedButtonを外だししたり、stateにしてしまえばよいのでは?🤔と思ったけれど、これはうまくいかない。
この辺りはまたいずれ調べてみようと思います!

FlutterでEXCEPTION CAUGHT BY RENDERING LIBRARYが出た時の対応方法

久々Flutter。
今日のお題はEXCEPTION CAUGHT BY RENDERING LIBRARYエラー。
読んで字のごとく、レンダリングに関するエラーです。

例として、こんな画面を考えてみます。

  • スクロールを検知して_currentIndexを設定
  • floatingActionButtonタップで_awesomeListから_currentIndexの要素を消す
    (画面からも_awesomeListウィジェットを削除)
class _AwesomeState extends State<Awesome> {
  int _currentIndex = 0;
  int _awesomeWidgetHeight = 500;
  List<Widget> _awesomeList = [
    // なんかすごくいいリスト
  ];

  Widget somethingAwesomeSingleScrollView() {
   // なんかすごくいいリストを使ってすごくいいスクロールビューを返す
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('タイトル'),
      ),
      body: NotificationListener<ScrollNotification>(
        child: somethingAwesomeSingleScrollView(),
        onNotification: (ScrollNotification scrollInfo) {
          setState(() {
            _currentIndex = (scrollInfo.metrics.pixels / _awesomeWidgetHeight).toInt();
          });
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: Text('Awesome!'),
        onPressed: () {
          setState(() {
            _awesomeList.removeAt(_currentIndex);
          });
        },
      ),
    );
  }
}

※ スクロールの度にonNotificationが発火するのでめちゃ重です。

おおむねうまく動作するのですが、_awesomeListの最後の要素を消そうとすると表題のエラーが出てしまい、うまくいきません。

I/flutter (21487): ══╡ EXCEPTION CAUGHT BY RENDERING LIBRARY ╞═════════════════════════════════════════════════════════
I/flutter (10000): The following assertion was thrown during performLayout():
I/flutter (10000): Build scheduled during frame.
I/flutter (10000): While the widget tree was being built, laid out, and painted, a new frame was scheduled to rebuild
I/flutter (10000): the widget tree. This might be because setState() was called from a layout or paint callback. If a
I/flutter (10000): change is needed to the widget tree, it should be applied as the tree is being built. Scheduling a
I/flutter (10000): change for the subsequent frame instead results in an interface that lags behind by one frame. If
I/flutter (10000): this was done to make your build dependent on a size measured at layout time, consider using a
I/flutter (10000): LayoutBuilder, CustomSingleChildLayout, or CustomMultiChildLayout. If, on the other hand, the one
I/flutter (10000): frame delay is the desired effect, for example because this is an animation, consider scheduling the
I/flutter (10000): frame in a post-frame callback using SchedulerBinding.addPostFrameCallback or using an
I/flutter (10000): AnimationController to trigger the animation.

大事なのはここ。

While the widget tree was being built, laid out, and painted, a new frame was scheduled to rebuild the widget tree.
This might be because setState() was called from a layout or paint callback.

直訳すると、こんな感じ。

ウィジェットツリーが構築・レイアウト・描画されている間に、ウィジェットツリーがリビルドされる新しいフレーム(?)がスケジュールされました。
これはsetState()がレイアウトまたは描画コールバックから呼ばれたせいかもしれません。

※ Flutterのウィジェットツリーをまだ理解していないのですが、こちらに詳しいです。

今回おそらくこんな流れでエラーが発生したのだろうと考えています。

  1. (前提: _awesomeListを消したことでウィジェットツリーの再描画が行われる)
  2. _awesomeListの最後の要素を消したことで画面サイズが変更になる
  3. 画面サイズが変更されたことでスクロールが起きてonNotification内のsetState()が走る
  4. _awesomeList要素削除によるウィジェットツリーの再描画中に新しく再描画処理が走る
    setState()内の処理ではなく、メッセージ通りsetState()が走ること自体が原因

なので、onNotificationをこんな風に変更します。

class _AwesomeState extends State<Awesome> {
  int _currentIndex = 0;
  int _awesomeWidgetHeight = 500;
  List _awesomeList = [];

  Widget somethingAwesomeSingleScrollView() {
   // なんかすごくいいスクロールビューを返す
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('タイトル'),
      ),
      body: NotificationListener<ScrollNotification>(
        child: somethingAwesomeSingleScrollView(),
        onNotification: (ScrollNotification scrollInfo) {
          // calculateCurrentIndexは現在の要素を示すIndex
          int calculateCurrentIndex = (scrollInfo.metrics.pixels / _awesomeWidgetHeight).toInt();
          // _currentIndexが最後の要素でない場合のみ_currentIndexを更新する
          // _calculateIndexは0始まりなので、_awesomeList.length - 1の必要はない
          if (_awesomeList.length != calculateIndex) {
            setState(() {
              _currentIndex = calculateIndex;
            });
          }
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: Text('Awesome!'),
        onPressed: () {
          setState(() {
            _awesomeList.removeAt(_currentIndex);
          });
        },
      ),
    );
  }
}

ここで注意するのは、if (_awesomeList.length != calculateIndex)が必要な理由。_awesomeList.removeAt(_currentIndex)が走った瞬間はまだ画面幅は再描画されていないということ。
_awesomeList.removeAt(_currentIndex);が走り、_awesomeList.lengthが1マイナスになっても、calculateIndexはあくまでスクロール量(画面高さ)から計算しているので、_awesomeListの要素が消された瞬間はまだ_awesomeListがあった時のIndexを示しています。
それからスクロール(画面高さを縮小)して、calculateIndex_awesomeListの最後の要素を示すようになった時(=スクロールが完了した時)に初めてsetState()が呼ばれる、という処理になり、エラーが解消されます。

すごくちょっとしたことなのですが、ネイティブアプリ(フロント?)に慣れないとこの辺りが中々理解できなかったので、まとめました。
ネイティブアプリのレンダリング周りはとても奥が深くて面白そうです!

Dartのカスケード記法(..)はRubyでいうtap

時々見かけるDartの謎な記法、カスケード記法。
どういうものかというと、..shuffle()みたいに、.をふたつ重ねる記法です。

  void main(){
    var hoge = [1,2,3];
    print((hoge..shuffle()).toString());
  }

Rubyを始めたばかりの頃も思ったのですが、こういうのって読み方がわからなくて調べるのに苦労しますよね…。カスケード記法というそうです。

cascadeとは 小滝、階段状に連続する滝、滝状に垂れたレース(など)、(菊などの)懸崖(けんがい)作り、縦つなぎ - Weblio辞書

つなぐ、という意味っぽい。
メソッドチェーン的なものかな?と思いましたが、なんかちょっと違う。
例にあげたshuffleの定義を覗いてみます。

void shuffle([Random random]);

shufflevoidで、呼び出し元のオブジェクト(レシーバ)を破壊的に変更して、戻り値は返しません。
なので、カスケード記法を使わないとこうなります。

void main(){
  var hoge = [1,2,3];
  // print(void)はできないのでコンパイルエラー
  // print(hoge.shuffle());

  // hoge自体を破壊的に変更してからprintすると期待通りの挙動になる
  hoge.shuffle();
  print(hoge); 
}

ただ、何度も呼び出そうと思うと行数も増えてしまうので、できればメソッドチェーンの形で書きたい。
そんな時のカスケード記法。

void main(){
  var hoge = [1,2,3];
  print(hoge..shufle()); 
  // 破壊的に変更した後のhogeを返してくれる
}

Rubyでいうと、tapでしょうか。

hoge = [1,2,3]
p hoge.tap{|item| item.shuffle!}
# shuffle!はレシーバ自体を返すので、tapを使う必要は本来ないです

Dartを書いていて、Rubyでいうとtapかあ、と嬉しくなったお話でした☺️

Flutterで画面遷移を実装する

昨日の続き。BottomNavigationBarの画面遷移を実装します。

といいつついきなりBottomNavigationBarはさておいて、画面遷移の方法はどうやら大きくわけて3つあるようです。

  1. Navigatorクラスを使ってpush/pop
  2. ウィジェットの切り替えを行う
  3. PageControllerを実装してがんばる

みっつめの方法はあまりメジャーじゃないっぽい?(お作法がまだよくわからない)ので、ひとつめとふたつめの方法を見ていきます。

Navigatorクラスを使う

ひとつめ、Navigatorクラスを使うパターン。
main.dartmain()で呼び出しているStatelessWidgetで、いままではこんな感じでホームとなるStatefulWidgetを呼び出していました。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MyApp',
      home: MyHomePage(),
    );
  }
}

このhomeを消して、ここでrouteを設定します。
routeをウィジェットの中に直書きするのは少し違和感。
ウィジェットをスタックしていくから仕方ないのでしょうか🤔

ともあれこんな風に書き換えます。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MyApp',
      initialRoute: '/',
      routes: {
        '/': (context) => MyHomePage(),
        '/awesomePage': (context) => AwesomePage(),
      },
    );
  }
}

routesプロパティの中に呼び出し名(route)と呼び出すStatefulWidget名をセットで記載します。

initialRouteがアプリ起動時に呼び出されるroute。通常はスプラッシュ画面を呼び出すところですね。
今回はMyHomePageを起動時に呼び出したかったので、initialRouteである'/'MyHomePageを紐づけました。

RaisedButtonをタップした時なんかに、ここでつけた名前をつかって画面遷移ができます。

RaisedButton(
  onPressed: () => {Navigator.pushNamed(context, '/awesomePage')},
),

ウィジェットの切り替えを行う

昨日書いたBottomNavigationBarに画面遷移を実装したものがこれ。

class _MyHomePageState extends State<MyHomePage> {
  int _currentIndex = 0;

  @override
  Widget build(BuildContext context){
    return Scaffold(
      appBar: AppBar(
        title: Text('MyHomePage'),
      ),
      body: _currentIndex == 0 ? Text('Home') : Text('Settings'),
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: 0,
        onTap: (int index) {
          setState((){
            this._currentIndex = index;
          });
        },
        items: [
          new BottomNavigationBarItem(
            icon: new Icon(Icons.home),
            title: new Text("Home")
          ),
          new BottomNavigationBarItem(
            icon: new Icon(Icons.settings),
            title: new Text("Setting")
          ),
        ],
      ),
    );
  }
}

BottomNavigationBarをtapするとMyHomePageStatecurrentIndexを変更、currentIndexの変更でbodyを書き換えています。
ここでは単純にTextウィジェットを表示しているだけですが、ContainerCustomScrollViewなどを返します。

とても単純なのですが、これだと画面から非表示になったウィジェットは解放されるのでStatefulWidgetを使う場合には(Stateが解放されて)意図した動きにならない。
ので、Stackウィジェットを使って画面を持ちつつ、Offstageで表示切り替えをして、MaterialAppNavigatorを使う、というのがちゃんとした方法のようです。
こちらで詳しく解説されています

以上、画面遷移の方法でした!