Flutter でアプリ開発(3. 状態の変化とウィジェットへの反映)

前回 svpl.hatenablog.jp

当たり前のことですが、何かしらの入力があったときは画面が変化するものです。画面を変化させるためには変数を定義して、変更に応じて反映させる必要があります。
今まで使っていた StatelessWidget ではそれができないので、今回は別の方法でやっていきたいと思います。

参考ページ

StatefulWidget を使う

Flutter には StatelessWidget の他に、StatefulWidget というクラスも用意されています。

以下のソースは公式のチュートリアルのものです。
画面にボタンが表示され、押した回数だけカウンターが上がっていきます。

import 'package:flutter/material.dart';

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

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

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: const Text('Increment'),
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Counter(),
        ),
      ),
    ),
  );
}

前回は自作のウィジェットを作成するために、 1 つのクラス(StatelessWidget を継承したクラス)しか定義しませんでしたが、今回は以下のクラスを定義しています。

  • StatefulWidget をオーバーライドした Counter クラス
    • 画面には表示されない
  • State をオーバーライドした _CounterState クラス
    • データを保持する(ここでは _counter 変数)
    • ウィジェットを生成・再生成する

StatelessWidget の時とは違い、build 関数は StatefulWidget には定義されていません。build 関数は State で定義され、データが変更されるたびにウィジェットを生成して表示するようになっています。
アプリが動いている間、State は残り続ける一方、データが変更されるたびに作られたウィジェットは破棄されうることになります。

また、State クラス内で変数を変更するときは setState 関数にコールバックを渡して行う必要があります。そうすることで、値が変更されたことが Flutter に通知され、正しく UI が更新されます。

以下は実行画面です。

f:id:sulp:20220130221424p:plain

ボタンを追加する

先程の例をアレンジして、カウンターを減らすボタンを追加します。
build 関数を以下のように変更しました。

@override
Widget build(BuildContext context) {
  return Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: <Widget>[
      // 追加:Columnを使ってボタンを縦に配置
      Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ElevatedButton(
              onPressed: _increment,
              child: const Text('+ Increment'),
            ),
            //追加:数を減らすボタン
            ElevatedButton(
              onPressed: _decrement,
              child: const Text('- Decrement'),
            )
          ]
      ),
      const SizedBox(width: 16),
      Text('Count: $_counter'),
    ],
  );
}

カウンターを減らす関数は次の通りです。

void _decrement() {
  setState(() {
    _counter--;
  });
}

実行画面です

f:id:sulp:20220130221444p:plain

これでも良いのですが、2 つのボタンを 1 つのウィジェットとしてまとめ、それを子ウィジェットとして作成した方がわかりやすくなります。以下の例では、2 つのボタンを持つ CounterButtons という StatelessWidget を作りました。

以下は、公式チュートリアルをアレンジして作った例です。

import 'package:flutter/material.dart';

class CounterButtons extends StatelessWidget {
  const CounterButtons({
    required this.onIncrement,
    required this.onDecrement,
    Key? key
  }) : super(key: key);

  final VoidCallback onIncrement;
  final VoidCallback onDecrement;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
        children: [
          ElevatedButton(
            onPressed: onIncrement,
            child: const Text('+1 Increment'),
          ),
          ElevatedButton(
            onPressed: onDecrement,
            child: const Text('-1 Decrement'),
          )
        ]
    );
  }
}

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

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

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      ++_counter;
    });
  }

  void _decrement() {
    setState(() {
      _counter--;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        CounterButtons(
          onIncrement: _increment,
          onDecrement: _decrement,
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Counter(),
        ),
      ),
    ),
  );
}

CounterButtons は 2 つのボタンを build 関数で作成します。ボタンを押した時に呼ばれる関数は、コンストラクタで渡されるようにしました。それらの関数を経由してカウンターの値を変更しています。

たった 2 つのボタンを表示したいというだけでは、ありがたみもそんなに感じないですが、もっと UI が複雑になってくると必須だと思います。また、ウィジェットを使い回すこともできます。

画面は前と同じです。

Reverpod を使う

ウィジェットがデータを持ってしまうと、めんどくさいことになりがちなので、別途、状態管理ライブラリを使用することができます。いろいろ選択肢はあるようですが、今回は Riverpod を使ってみます。

pubspec.yaml の dependencies に必要なパッケージ(flutter_hooks / hooks_riverpod)を追加します。今回は hooks_riverpod を使いませんでしたが、後で使いそうなので入れておきました。

dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.18.2
  hooks_riverpod: ^1.0.3

Android Studio の下端の Terminal ボタンを押して、flutter pub get と入力して実行すると、ライブラリがダウンロードされます。

f:id:sulp:20220130221459p:plain

先程のサンプルを、Riverpod を使って書き直しました。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 追加:状態を保持するオブジェクト
final counterProvider = StateProvider((ref) => 0);

// 親クラスを ConsumerWidget に変更
class CounterButtons extends ConsumerWidget {
  const CounterButtons({Key? key}) : super(key: key);

  // build 関数の引数に WidgetRef が追加される
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          ElevatedButton(
            // 変更:counterProvider を経由して値を変更
            onPressed: () => ref.watch(counterProvider.notifier).state++,
            child: const Text('+1 Increment'),
          ),
          ElevatedButton(
            // 変更:同上
            onPressed: () => ref.watch(counterProvider.notifier).state--,
            child: const Text('-1 Decrement'),
          )
        ]
    );
  }
}

// 親クラスを ConsumerWidget に変更
class Counter extends ConsumerWidget {
  const Counter({Key? key}) : super(key: key);

  // build 関数の引数に WidgetRef が追加される
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // counterProvider を経由して値を取得
    final counter = ref.watch(counterProvider);

    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        const CounterButtons(),
        const SizedBox(width: 16),
        // counter の値は自動で反映される
        Text('Count: $counter'),
      ],
    );
  }
}

void main() {
  runApp(
      const ProviderScope(  // 追加
        child:
        MaterialApp(
          home: Scaffold(
            body: Center(
              child: Counter(),
            ),
          ),
        ),
      )
  );
}

StatelessWidget と StatefulWidget のどちらも ConsumerWidget で置き換えています。

StatefulWidget を使ったやり方では、Counter に変数があり、コールバック関数を経由して操作を行っていました。
対して上記のやり方では、グローバル変数の counterProvider を経由して値の変更を行っています。なので Counter を意識せずとも、CounterButtons から直接カウンターの値を変更できます。
また、値が変わっても自動で反映されるのは変わりません。
特殊な書き方をする必要がありますが、プログラムの構造は分かりやすくなります。

Dart 言語で気になったこと

関数定義

以下のどのようにも書けるようです。

// 普通
int add(int a, int b){
  return a + b;
}
// アロー演算子
int add_oneline(int a, int b) => a + b;

// 匿名関数
int Function(int, int) add_lambda = (int a, int b) {
  return a + b;
};
// 匿名関数 + アロー演算子
int Function(int, int) add_lambda_oneline = (int a, int b) => a + b;

型推論を使えば、以下のようにも書けます。

// 型推論
var add_lambda_oneline = (int a, int b) => a + b;

まとめ

ユーザーの入力を受け付けるようになり、ようやくアプリっぽくなってきました。
状態変化の処理は Riverpod のおかげで、相当かんたんに書けるようになりました。これじゃあ StatefulWidget なんていらないじゃないか。入れるのは必須と言って良いかもしれません。勉強量が増えますけど……
次回はここまでの応用で、もっとアプリっぽい見た目の物を作ってみようと思います。

次回 svpl.hatenablog.jp