いろいろ備忘録日記

主に .NET とか Go とか Flutter とか Python絡みのメモを公開しています。

Flutterメモ-11 (InheritedWidgetとValueNotifier)

概要

Flutter をやり始めて最初によくわからなかったのが InheritedWidget でした。

api.flutter.dev

なんか、他のウィジェットとは違う感じだし、何に使うのこれ?って感じでした。

公式のドキュメント見ると、とても便利!みたいな書き方されているのですが、いまいち意味がわからずw

以下、自分が理解したレベルでのメモです。

StatefulWidgetなサンプル

まずは、おさらい。StatefulWidgetを使った状態変更のサンプルです。こんなクソUIがあったとします。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'stateful1',
      theme: ThemeData.dark(),
      home: Home(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Body(),
    );
  }
}

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

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

class _BodyState extends State<Body> {
  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.center,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          MyText(),
          MyButton(),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Text(
      'text',
      style: Theme.of(context).textTheme.headline3,
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {},
      child: const Text('PUSH'),
    );
  }
}

起動すると、こんな感じ。テキストとボタンが一個あるだけです。

f:id:gsf_zero1:20210815203857p:plain

まだ、状態を持っていないので、ボタン押しても何も発生しません。なので、以下のようにUIが変化するように調整。

Bodyウィジェットより上のウィジェットは変化無いので割愛。

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

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

class _BodyState extends State<Body> {
  late String _text;

  @override
  void initState() {
    _text = 'text';
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.center,
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          MyText(
            text: _text,
          ),
          MyButton(
            onPressed: () {
              setState(() {
                _text = _text.startsWith('t')
                    ? _text.toUpperCase()
                    : _text.toLowerCase();
              });
            },
          ),
        ],
      ),
    );
  }
}

class MyText extends StatelessWidget {
  final String text;
  const MyText({
    Key? key,
    required this.text,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Text(
      text,
      style: Theme.of(context).textTheme.headline3,
    );
  }
}

class MyButton extends StatelessWidget {
  final Function() onPressed;

  const MyButton({
    Key? key,
    required this.onPressed,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      child: const Text('PUSH'),
    );
  }
}

_BodyState に、状態をもたせて、下位のウィジェットにはアクションが発生した際に呼んでもらうためにコールバックを渡してUIを変更しています。flutter create して生成されるものと変わらないですね。

状態変化させるようにしたので、ボタンを押す度にテキストが変わります。

f:id:gsf_zero1:20210815203857p:plain

f:id:gsf_zero1:20210815204928p:plain

setStateで状態更新を呼び出すやり方はシンプルでとても分かりやすいですね。

でも、実際にUI組みだすと、こんな浅い階層構造ではなくてもっともっと深い構造を作ることになります。

その場合に、このやり方だと「データを持っている上位ウィジェット」から「対象となる下位ウィジェット」までコールバックなりデータをバケツリレーしないといけなくなります。

これは階層が深くなるとちょっと面倒。。。です。

てことで InheritedWidget

こんなときに InheritedWidget さんが役にたちます。

このウィジェットさんは、「UIに関して何もしません」。ただデータを保持してくれるだけです。

ただ、以下の特徴を持っています。

  • 下位ウィジェットから直近のInheritedWidgetに O(1) でアクセスできる
  • うまく使うと限定した範囲だけUIのリビルドを発生させることができる

下の特徴については今回扱いません。上の特徴についてサンプルつくってみます。

InheritedWidget は、データを保持したい階層の部分に差し込むようにして設定します。

たとえば、上のサンプルでは、Bodyウィジェットで状態を持っていて、下位のMyTextとMyButtonがその状態データに依存しています。

なので、Bodyウィジェットの build の中で差し込みます。

んで、真上のInheritedWidgetに状態データを持たせて、下位ウィジェットから利用するようにします。

InheritedWidget自体は abstract なクラスとなっているので、必ず継承したウィジェットを作って利用することになります。

作り方は若干ボイラープレートな感じがあって、以下のようにつくります。

class BodyDataHolder extends InheritedWidget {
  final String text;

  BodyDataHolder({
    Key? key,
    required this.text,
    required Widget child,
  }) : super(key: key, child: child);

  /// 簡易にアクセスできるようにするためのヘルパーメソッド
  /// 
  /// 本当は中でdependOnInheritedWidgetOfExactTypeと
  /// getElementForInheritedWidgetOfExactTypeを使い分けて
  /// 変更伝播をする・しないを切り分けるべきだけど、今回は割愛
  static BodyDataHolder of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<BodyDataHolder>()!;
  }

  @override
  bool updateShouldNotify(covariant InheritedWidget oldWidget) {
    // データが更新されているかどうかを判定するメソッド
    // 更新されている場合は true を、それ以外は false を返す
    //
    // このサンプルでは細かい制御は必要ないので常に false を返すようにしている
    return false;
  }
}

なんかややこしいですねw

とりあえず、こういう形のものだと思っておいてください。

これを以下のように差し込んで、下位のウィジェットは InheritedWidget からデータを取得して利用するようにします。

class _BodyState extends State<Body> {
  late String _text;

  @override
  void initState() {
    _text = 'text';
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.center,
      child: BodyDataHolder(
        text: _text,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            MyText(),
            MyButton(
              onPressed: () {
                setState(() {
                  _text = _text.startsWith('t')
                      ? _text.toUpperCase()
                      : _text.toLowerCase();
                });
              },
            ),
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Text(
      BodyDataHolder.of(context).text,
      style: Theme.of(context).textTheme.headline3,
    );
  }
}

すこーしだけ、変化したのが分かりますでしょうか。MyTextウィジェットさんが上からデータを貰わなくなって、中で InheritedWidget からデータを取得して表示するようになっています。

ということは、もっと深い階層になっても、このやり方でいつでも上位のデータにアクセスできるようになっているということですね。

ただ、MyButtonさんの方は何も変化していません。相変わらず、コールバックを渡して setState 呼ぶようにしています。

InheritedWidgetさん単独では、setStateのような「UIを再ビルドせよ」という動きが出来ないからです。

どうせなら、InheritedWidgetがもっているデータが変化したら、そのまま関連するウィジェット達のUIも再ビルドしたいですよね。

てことで、次に InheritedWidget さんと ValueNotifer さんを組み合わせます。

てことで、InheritedWidget + ValueNotifer

ValueNotifier さんは、一つの値を自身の中に持って、それが変化したら通知を行うクラスさんです。

api.flutter.dev

親クラスが ChangeNotifier となっていて、こっちは任意のタイミングで通知を発行できます。ValueNotifierはChangeNotifierの特化版という感じですね。

上述した BodyDataHolder では、コンストラクタに渡したデータを変化させることは不可能でした。今度はValueNotifierとして保持するようにしてみます。

class BodyDataHolder extends InheritedWidget {
  final text = ValueNotifier('text');

  BodyDataHolder({
    Key? key,
    required Widget child,
  }) : super(key: key, child: child);

  static BodyDataHolder of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<BodyDataHolder>()!;
  }

  @override
  bool updateShouldNotify(covariant InheritedWidget oldWidget) {
    return false;
  }
}

フィールドの部分が少し変わりましたね。元々 String だったところが ValueNotifier に変わりました。

これで、データが変わったら ValueNotifier さんが通知をしてくれるようになったので、変化通知を受けとれるようになります。

んで、実際のウィジェット側も通知を受け取れるようにするのですが、ValueNotifierの変更通知を扱ってくれる ValueListenableBuilder というウィジェットが専用であります。

api.flutter.dev

この子を使うことで、ValueNotifierが変更通知を出したら、自動でUIの再ビルドが走るようにできます。

Bodyウィジェット達を InheritedWidget + ValueNotifier + ValueListenableBuilder を利用するように変更したのが以下です。

class BodyDataHolder extends InheritedWidget {
  final text = ValueNotifier('text');

  BodyDataHolder({
    Key? key,
    required Widget child,
  }) : super(key: key, child: child);

  static BodyDataHolder of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<BodyDataHolder>()!;
  }

  @override
  bool updateShouldNotify(covariant InheritedWidget oldWidget) {
    return false;
  }
}

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

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

class _BodyState extends State<Body> {
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.center,
      child: BodyDataHolder(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            MyText(),
            MyButton(),
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: BodyDataHolder.of(context).text,
      builder: (context, String value, _) {
        return Text(
          value,
          style: Theme.of(context).textTheme.headline3,
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        final curr = BodyDataHolder.of(context).text.value;
        final next =
            curr.startsWith('t') ? curr.toUpperCase() : curr.toLowerCase();

        BodyDataHolder.of(context).text.value = next;
      },
      child: const Text('PUSH'),
    );
  }
}

MyTextとMyButtonが上から何も貰わなくなって、InheritedWidget から取得した ValueNotifier の値を見るようになりました。

それにより、Bodyウィジェットでは何も状態データを持つことがなくなってしまいましたね。

状態を保つ必要がないということは、StatefulWidgetである必要はないということです。

つまり、以下のようにできます。

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

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.center,
      child: BodyDataHolder(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            MyText(),
            MyButton(),
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: BodyDataHolder.of(context).text,
      builder: (context, String value, _) {
        return Text(
          value,
          style: Theme.of(context).textTheme.headline3,
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        final curr = BodyDataHolder.of(context).text.value;
        final next =
            curr.startsWith('t') ? curr.toUpperCase() : curr.toLowerCase();

        BodyDataHolder.of(context).text.value = next;
      },
      child: const Text('PUSH'),
    );
  }
}

Bodyウィジェットが StatefulWidget から StatelessWidget になりましたが、ボタンをタップするとテキストの値はちゃんと変わります。

状態の部分を InheritedWidget + ValueNotifier で管理するようにしたからですね。

最終版

てことで、最終版は以下のようになりました。

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'InheritedWidget + ValueNotifier',
      theme: ThemeData.dark(),
      home: Home(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Body(),
    );
  }
}

class BodyDataHolder extends InheritedWidget {
  final text = ValueNotifier('text');

  BodyDataHolder({
    Key? key,
    required Widget child,
  }) : super(key: key, child: child);

  static BodyDataHolder of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<BodyDataHolder>()!;
  }

  @override
  bool updateShouldNotify(covariant InheritedWidget oldWidget) {
    return false;
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.center,
      child: BodyDataHolder(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            MyText(),
            MyButton(),
          ],
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: BodyDataHolder.of(context).text,
      builder: (context, String value, _) {
        return Text(
          value,
          style: Theme.of(context).textTheme.headline3,
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        final curr = BodyDataHolder.of(context).text.value;
        final next =
            curr.startsWith('t') ? curr.toUpperCase() : curr.toLowerCase();

        BodyDataHolder.of(context).text.value = next;
      },
      child: const Text('PUSH'),
    );
  }
}

参考情報

medium.com

とても詳しく解説してくださってます。


過去の記事については、以下のページからご参照下さい。

  • いろいろ備忘録日記まとめ

devlights.github.io

サンプルコードは、以下の場所で公開しています。

  • いろいろ備忘録日記サンプルソース置き場

github.com

github.com

github.com