Flutter-表示の更新

ウィジェットの表示の更新についてちょっとまとめてみた。

初めに

トップウィジェットの中身にDropdownMenuItemやToggleButtonなどのウィジェットを配置し、それを操作した時に状態(表示)を変更する。これはよくあるGUI構成だと思う。

ただ、それらウィジェットはStatelessWidget派生なので、何らかの操作後、何もしないと表示(例えばON/OFFの状態は選択状態切り替え後の表示)は更新されない。
これらの表示を更新するためには、新しい状態でウィジェットの再構築をしなければならない。

実行するためによく使われるのはStatefulWidgetで、再構築のためのbuildを実行するにはsetStateを呼び出して再構築マークを付けることになる。

上記の動作に関しては、StatefulWidget派生で独自ウィジェットを作成した場合はもちろん、ダイアログウィジェットのUI要素としてドロップダウンリストを用意した時、その項目を選択後、選択されたものをドロップダウンに表示させるといった物にも当然適用される。

よくあるダイアログを表示するケース

下の例はAlertDialogのサンプルを引用したもの。
AlertDialogはStatelessWidget派生で、また実際に作成されるのはDialog(こちらもStatelessWidget派生)にウィジェットを詰め込んだものになる。

class DialogExample1 extends StatelessWidget {
  const DialogExample1({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => showDialog<String>(
        context: context,
        builder: (BuildContext context) => AlertDialog(
          title: const Text('AlertDialog Title'),
          content: const Text('AlertDialog description'),
          actions: <Widget>[
            TextButton(
              onPressed: () => Navigator.pop(context, 'Cancel'),
              child: const Text('Cancel'),
            ),
            TextButton(
              onPressed: () => Navigator.pop(context, 'OK'),
              child: const Text('OK'),
            ),
          ],
        ),
      ),
      child: const Text('Show Dialog'),
    );
  }
}

この例を引用して、中身のウィジェットにDropdownMenuItemやToggleButtonを配置し、それらの操作に対してウィジェットの表示を変えたいと思っても、上位がStatefulWidgetではないので表示の更新が行われることは基本ない。

ちなみによくある間違い実装

class TestWidget1 extends StatefulWidget {
  const TestWidget1({Key? key}) : super(key: key);

  @override
  State<TestWidget1> createState() => _TestWidget1State();
}

class _TestWidget1State extends State<TestWidget1> {
  int count = 0;
  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => showDialog<String>(
        context: context,
        builder: (BuildContext context) => AlertDialog(
          title: const Text('AlertDialog Title'),
          content: const Text('AlertDialog description'),
          actions: <Widget>[
            TextButton(
              onPressed: () {
                setState(() {  // これでダイアログを更新した(つもり)
                  count++;
                });
              },
              child: Text("count = $count"),
            ),
            TextButton(
              onPressed: () => Navigator.pop(context, 'OK'),
              child: const Text('OK'),
            ),
          ],
        ),
      ),
      child: const Text('Show Dialog'),
    );
  }
}

StatefulWidget派生でウィジェットを構築し、その中のボタン動作でダイアログを表示。
ダイアログの中のボタンを押すことで、ダイアログの中身のテキストを更新しているつもりの実装。

これはsetStateで再構築の指示をしているのはTestWidget1。TestWidget1で管理されていないダイアログは更新対象とはならない。

ダイアログはbuild関数の中で呼ばれているのでTestWidget1の管理下に置かれていると錯覚するかもしれないが、このダイアログはshowDialogメソッドの内部でNavigatorのpushで呼ばれた単独画面になる。そのためTestWidget1の再構築とは切り離されたウィジェットという状態になっている。そのため更新対象とならない。

上の実装を図で示すと以下の様な感じになる。

実装的にはTextButton1①のテキストボタンのアクションからTestWidget1に対してsetStateでウィジェットの更新要求を出しているようなもの。
構造としてのつながりがないのでTextButton①の更新はされないということが分かる。

更新させるには

TextButton1①を更新させるにはどうしたらいいか。

それは更新対象をStatefulWidget派生にしてしまうというのが基本の方法。

新しいウィジェットクラスの実装が面倒というのであれば、更新対象をStatefulBuilderでラッピングしてしまうという方法もある。StatefulBuilderはStatefulWidgetのbuild処理だけを外側から渡せるようにした簡易(汎用)クラスみたいなもの。
これにより、ダイアログ以下にStatefulWidgetを配置しそれに対してウィジェットの更新を指示できる

今回はStatefulBuilderで実装してみる。
ちなみにStatefulBuilderを使ううえで注意したいのはsetStateの適用範囲。

StatefulBuilderをひな形通りに使うと、登録するFunctionの第2引数はコードスニペットでsetStateとされる。
StatefulWidgetのbuildの中で使われる場合、どちらのsetStateを呼び出しているのかきちんと区別しておく必要があるので、できたらStatefulBuilder側の第2引数はsetState以外の名称に変更しておいた方が実装しやすいかもしれない。

class TestWidget1 extends StatefulWidget {
  const TestWidget1({Key? key}) : super(key: key);

  @override
  State<TestWidget1> createState() => _TestWidget1State();
}

class _TestWidget1State extends State<TestWidget1> {
  int count = 0;
  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => showDialog<String>(
        context: context,
        builder: (BuildContext context) => AlertDialog(
          title: const Text('AlertDialog Title'),
          content: const Text('AlertDialog description'),
          actions: <Widget>[
            StatefulBuilder(
              builder: (context, hoge) {
                return TextButton(
                  onPressed: () {
                    hoge(() {
                      count++;
                    });
                  },
                  child: Text("count = $count"),
                );
              }
            ),
            TextButton(
              onPressed: () => Navigator.pop(context, 'OK'),
              child: const Text('OK'),
            ),
          ],
        ),
      ),
      child: const Text('Show Dialog'),
    );
  }
}

今度はこのような例になる。

こちらでは①のTextButtonで更新マークを設定しているのはその上のStatefulBuilderになる。これでTextButton①が更新されるということになる。

上の例を参考に、状態が変更した場合どこを再表示させるかを考え、ウィジェットの構築をするのが良いということになる。

補足

補足1

ウィジェットの再構築の情報を保持しているのはElement側になっている。
StatefulWidgetはそのElement側に再構築マークを設定可能なインターフェースを保持しているともいえるかもしれない。

また一部状態更新系のパッケージではStatelessWidgetでも更新に対応しているものがある。

watch系のメソッドを使うやつ。
こういうのはStatefulWidget経由で再構築マークを建てているのではなく、Element側に直接ウィジェットをビルドする必要ありのマークを設定しウィジェットを更新している。

「ウィジェット更新を行う」という動作に関しては、利用するパッケージ等を考慮し、統一した運用をした方が混乱しないかもしれない。

これはあくまで個人的な考えだけど。

補足2

StatefulWidgetに関しての説明ビデオのこの部分、自分が想定していた動作と違う感じになっているのが分かった。

ビデオの説明ではStatefulWidgetの再構築で違う初期化データで構築されたウィジェットでもステート側が保持している情報は変更されないというイメージだった。実際サンプルを作成してみたのだけど、その通りの動きだった。

それが以下のソース。

import 'package:flutter/material.dart';

class State1 extends StatefulWidget {
  const State1({Key? key}) : super(key: key);

  @override
  State<State1> createState() => _State1State();
}

class _State1State extends State<State1> {
  String name = "hoge";

  late TextEditingController controller;

  @override
  void initState() {
    super.initState();
    controller = TextEditingController()..text = name;
  }

  @override
  void dispose() {
    super.dispose();
    controller.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: const Text("State1"),
        ),
        body: Column(
          children: [
            TextField(
              controller: controller,
              onSubmitted: (value) => setState(() {
                name = value;
              }),
            ),
            State2(name),
          ],
        ));
  }
}

class State2 extends StatefulWidget {
  const State2(this.name, {Key? key}) : super(key: key);

  final String name;

  @override
  State<State2> createState() => _State2State();
}

class _State2State extends State<State2> {
  int counter = 0;
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
        onTap: () => setState(() {
              counter++;
            }),
        child: Text("${widget.name} $counter"));
  }
}

_State1State側でsetStateを行い、下位側のウィジェットの再更新をしても、_State2State側のcounterは0初期化されなかった。
動かすと、名前を変更してもカウンターの値はそのまま。

_State2State側を再作成するには、_State1State側でState2を作る際にkeyにユニークな値を与え、異なるウィジェットだということを明示することが必要だった。

上記動作については、自分が保持している経験則から逸脱している部分なので注意しなければいけない個所だと認識した。

補足3

StatefulBuilderは「a platonic widget」?と書かれている。

「a platonic widget」とは「何?」と思った。これについてはgithub側のissuesに「単語の定義が明確ではないのでは」ということで登録されている。
その後、ドキュメントを修正しますという内容でクローズされているのだけど、すべてのドキュメントが修正されているわけではないようだった。

関連issuesを調べていくと、「a platonic widget」は「This widget is a simple inline alternative to defining a ウィジェット名」に切り替えられている(ものもある)。

「a platonic widget」は、あるウィジェットのインラインで使用できる代替定義という形になるのだろうか。

これのさらに補足

用語の定義は難しい。

仕様書など書いている途中はその時の気持ちでいろいろな用語を使ってしまう。
同じ意味合いで使われた用語かもしれないけど、実際に記述された文字が異なってしまうと、それを読んだ人が異なった意味合いでとらえてしまうので、仕様書として問題になってしまうということがある。

自分で読み返しても、どういった意図で使ったのかわからなくなる場合が時々あったし。

基本的に仕様書を書いた後や途中でも見直し、同じ意図で書いた用語は合わせる、また難しい内容であればその用語の意味を別途書くようにしていた。

コメント

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