💻

[Flutter入門(6)] 公式チュートリアルのステップ2の後半:Managing stateを読んでみる

2020/07/08に公開

Flutterの公式サイトに チュートリアル として紹介されているコンテンツのうち、2つ目の Adding interactivity to your Flutter app のページの後半部分にある Managing state の部分を読んで、その内容をまとめてみました。

原文を補完するような形で活用していただければと思います。

はじめに

先にポイントをまとめると、以下のとおり。

  • 状態の管理方法にはいくつかのアプローチがある
  • ウィジェットをデザインする際、どのアプローチを使うかはあなた次第
  • 迷う場合は、親ウィジェットで管理する方法から始めるのがおすすめ

Statefulウィジェットの状態を管理する方法にはいくつかの選択肢があり、どの方法が適切かは状況による。ウィジェットをデザインする際は、そのウィジェットがどのように使われることを期待するかによって適切に管理方法を選択するべし。

最も一般的な管理方法は以下の3つ。

どの方法を選択するかを決める際は、以下の基本原則を参考にするとよい。

  • 状態がユーザーデータに関するものであれば、親ウィジェットに管理してもらうのがよい(例:チェックボックスの選択状態、スライダーの位置情報など)
  • 状態が単純に見た目に関するものであれば、ウィジェット自身で管理するのがよい(例:アニメーションなど)
  • 迷う場合は、親ウィジェットに管理してもらう方法から始めるとよい

以下、簡単な実例を用いて3つの管理方法を比較してみる。例とする仕様は以下のとおり。

  • それぞれの管理方法ごとに、 TapboxA TapboxB TapboxC というシンプルなウィジェットを用意する
  • いずれもタップすると _active という真偽値がトグルする機能を持ち、 _active の状態に応じて見た目が下図のようにグリーン(アクティブ)とグレー(非アクティブ)で変化する

なお、このサンプルでは Container をタップ可能にするために GestureDetector クラスを使用する。

著者注
実際にコードを動かしながら読み進めたい場合は、 flutter create managing_state などとして雛形アプリを作成しておいてください。 managing_state/lib/main.dart の中身を書き換えていくことで、実際にサンプルコードを動かすことができます。

ウィジェット自身が自分の状態を管理する方法

例えば ListView ウィジェットは、コンテンツのサイズに対して表示領域が小さすぎる場合、自動的にスクロール可能な状態としてレンダリングされ、現在のスクロール位置などの状態も ListView 自身が管理する。

ListView を使用する開発者は、 ListView のスクロールに関する振る舞いをわざわざ自分で管理したいとは思わないので、この設計は正しいと言える。

ここで紹介するパターンはこれと同じで、以下に例示する _TapboxAState クラスは、

  • TapboxA ウィジェットの状態を管理する
  • _active という真偽値のプロパティを持ち、この値がボックスの色を決定する
  • _handleTap() メソッドを持ち、このメソッドが _active プロパティの値の変更および setState() をコールすることによるUIの再描画を行う
  • ウィジェットについてのすべてのインタラクティブな操作を実装している

コード例

// lib/main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flutter Demo'),
        ),
        body: Center(
          child: TapboxA(),
        ),
      ),
    );
  }
}

class TapboxA extends StatefulWidget {
  TapboxA({Key key}) : super(key: key);

  
  _TapboxAState createState() => _TapboxAState();
}

class _TapboxAState extends State<TapboxA> {
  bool _active = false;

  void _handleTap() {
    setState(() {
      _active = !_active;
    });
  }

  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _handleTap,
      child: Container(
        child: Center(
          child: Text(
            _active ? 'Active' : 'Inactive',
            style: TextStyle(fontSize: 32.0, color: Colors.white),
          ),
        ),
        width: 200.0,
        height: 200.0,
        decoration: BoxDecoration(
          color: _active ? Colors.lightGreen[700] : Colors.grey[600],
        ),
      ),
    );
  }
}

親ウィジェットが子ウィジェットの状態を管理する方法

親ウィジェットが子ウィジェットの状態を管理して、変更があれば子ウィジェットに伝えるという設計が合理性を持つ状況は多い。

例えば、 IconButton はアイコンをタップ可能なボタンとして使用できるウィジェットだが、 IconButton 自体はStatelessウィジェットである。 IconButton の親ウィジェットが「ボタンが押されたかどうか」を知り、適切なアクションを実行させることができるようになっている。

以下の例では、 TabboxB は自身の状態を親ウィジェットに対して開示(コールバックを使ってアクセスできるように)しており、 TapboxB 自体は何ら状態を管理していない。(Statelessウィジェットである)

一方で ParentWidgetState クラスは、

  • _active によって TapboxB の状態を管理している
  • _handleTapboxChanged() という、ボックスがタップされたときに実行されるメソッドを持っている
  • 状態が変化したら、 setState() を呼び出してUIを更新する

また TapboxB クラスは、

  • StatelessWidget クラスを継承しており、自身は状態を持たない(親が管理してくれるから)
  • タップを検知したら、親にそのことを通知する

コード例

// lib/main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flutter Demo'),
        ),
        body: Center(
          child: ParentWidget(),
        ),
      ),
    );
  }
}

class ParentWidget extends StatefulWidget {
  
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  bool _active = false;

  void _handleTapboxChanged(bool newValue) {
    setState(() {
      _active = newValue;
    });
  }

  
  Widget build(BuildContext context) {
    return Container(
      child: TapboxB(
        active: _active,
        onChanged: _handleTapboxChanged,
      ),
    );
  }
}

class TapboxB extends StatelessWidget {
  TapboxB({Key key, this.active: false,  this.onChanged})
      : super(key: key);

  final bool active;
  final ValueChanged<bool> onChanged;

  void _handleTap() {
    onChanged(!active);
  }

  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _handleTap,
      child: Container(
        child: Center(
          child: Text(
            active ? 'Active' : 'Inactive',
            style: TextStyle(fontSize: 32.0, color: Colors.white),
          ),
        ),
        width: 200.0,
        height: 200.0,
        decoration: BoxDecoration(
          color: active ? Colors.lightGreen[700] : Colors.grey[600],
        ),
      ),
    );
  }
}

mix-and-matchアプローチ

mix-and-matchアプローチと呼ばれる手法が最適な場合もありえる。例えば、あるStatefulウィジェットが

ここで紹介する TapboxC の例では、下図のように、タップダウン(押し込み)中はボックスに濃い緑色の枠線を表示し、タップアップすると(押し込みを離すと)枠線を消去してボックスの色を変更するような仕様を考える。

TapboxC_active という状態は親ウィジェットに対して開示するが、 _highlight という別の状態(枠線の表示を司る状態)は自分自身で管理する。これがmix-and-matchアプローチである。

この例では、 _ParentWidgetState クラスと _TapboxCState クラスという2つのStateクラスが登場する。

_ParentWidgetState クラスは、

  • _active 状態を管理する
  • _handleTapboxChanged() メソッドを持ち、ボックスがタップされたときにそれが呼ばれる
  • タップが発生して _active 状態が変化すると、 setState() を読んでUIを更新する

_TapboxCState クラスは、

  • _highlight 状態を管理す
  • GestureDetector クラスを使ってタップイベントを監視し、タップダウンされたら枠線を表示、タップアップされたら枠線を消去する
  • タップダウン、タップアップ、タップキャンセルイベントが発生するか、 _highlight 状態が変化するかしたタイミングで、 setState() を読んでUIを更新する
  • タップイベント発生時、 widget プロパティを使って自ウィジェットの状態を親ウィジェットに伝える

コード例

// lib/main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flutter Demo'),
        ),
        body: Center(
          child: ParentWidget(),
        ),
      ),
    );
  }
}

class ParentWidget extends StatefulWidget {
  
  _ParentWidgetState createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  bool _active = false;

  void _handleTapboxChanged(bool newValue) {
    setState(() {
      _active = newValue;
    });
  }

  
  Widget build(BuildContext context) {
    return Container(
      child: TapboxC(
        active: _active,
        onChanged: _handleTapboxChanged,
      ),
    );
  }
}

class TapboxC extends StatefulWidget {
  TapboxC({Key key, this.active: false,  this.onChanged})
      : super(key: key);

  final bool active;
  final ValueChanged<bool> onChanged;

  _TapboxCState createState() => _TapboxCState();
}

class _TapboxCState extends State<TapboxC> {
  bool _highlight = false;

  void _handleTapDown(TapDownDetails details) {
    setState(() {
      _highlight = true;
    });
  }

  void _handleTapUp(TapUpDetails details) {
    setState(() {
      _highlight = false;
    });
  }

  void _handleTapCancel() {
    setState(() {
      _highlight = false;
    });
  }

  void _handleTap() {
    widget.onChanged(!widget.active);
  }

  Widget build(BuildContext context) {
    // This example adds a green border on tap down.
    // On tap up, the square changes to the opposite state.
    return GestureDetector(
      onTapDown: _handleTapDown, // Handle the tap events in the order that
      onTapUp: _handleTapUp, // they occur: down, up, tap, cancel
      onTap: _handleTap,
      onTapCancel: _handleTapCancel,
      child: Container(
        child: Center(
          child: Text(widget.active ? 'Active' : 'Inactive',
              style: TextStyle(fontSize: 32.0, color: Colors.white)),
        ),
        width: 200.0,
        height: 200.0,
        decoration: BoxDecoration(
          color: widget.active ? Colors.lightGreen[700] : Colors.grey[600],
          border: _highlight
              ? Border.all(
                  color: Colors.teal[700],
                  width: 10.0,
                )
              : null,
        ),
      ),
    );
  }
}

この実装例とは逆に、 _active 状態を自身で管理して、 _hightlight 状態を親ウィジェットに開示する方法も考えられるが、直感的ではない。タップボックスウィジェットを利用する開発者のことを考えれば、彼らはボックスがアクティブかどうかに関心はあるが、枠線がどのように表示されるかには関心がないはずだからだ。そうであれば、枠線の表示制御はタップボックス自身が管理してくるほうがよいだろう。

GitHubで編集を提案

Discussion