LoginSignup
19
16

More than 1 year has passed since last update.

【Flutter】アプリ製作から学ぶProviderの使い方【図解付き】

Last updated at Posted at 2020-08-29

先日、Flutterの学習がてら、チェスクロックアプリを作成しました。

コメント 2020-08-29 155937.png

コメント 2020-08-29 155815.png

製作期間は1週間ほど。
Swift、Androidならすぐできるのに…とかひとりぼやきつつ、Providerについてもなんとなく理解して実践で使用できましたので参考までに。

図解とコード付けていますので、なんとなく理解した気になれたらチャレンジしてみてください。

Providerについて

Consumerと組み合わせることで、Consumer以下に配置したWidgetのみ更新することが可能。
これにより、全画面描画する必要がなくなり、レンダリング時間の削減につながる

StatefulWidgetの仕組みよりこちらのほうが理解しやすい。
全画面描画しないで済む分パフォーマンスにも優れており、Providerを使うメリットについてはGoogleも認めています。

アプリ全体像

チェスクロックアプリの全体像より、Providerの使い方を解説してみます。
まずは、各Widgetの配置関係を図解してみましたのでざっくり眺めてみましょう(正確ではないです)。

Flutter-Page-2 (1).png

Providerの使い方

  1. Providerパッケージをインストール
  2. MaterialAppの上にProviderを配置
  3. changeNotifierクラスを拡張したクラスを作成
  4. 値を更新したいところでnotifyListeners()を実行
  5. Consumerを配置

順に説明します。
最後にコード全体を掲載しますのでご安心を。

1.Providerパッケージをインストール

pubspec.yaml に以下を追加して、$ flutter pub getを実行。

pubspec.yaml
dependencies:
  provider: ^4.3.2+1

バージョンはこちらで確認ください。
https://pub.dev/packages/provider

2.MaterialAppの上にProviderを配置

今回はClockProviderというクラスをProviderとして登録します。
コードは以下のとおり。

main.dart
class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<ClockProvider>(
          create: (context) => ClockProvider(),
        ),
      ],
      child: GetMaterialApp(
        theme: ThemeData.dark(),
        home: HomePage(),
      ),
    );
  }
}

MultiProvider()は、複数Providerを配置したいときに使用します。
今回はClockProviderひとつだけなので冗長ですが、複数定義したい場合に備えてそのまま使用。

図解にも記載していますが、ポイントは、ProviderはMaterialAppの上に配置すること
でないと、SettingsPageでは使えなくなります。

ちなみに、今回のコードはMaterialAppではなく、GetMaterialAppを使用しています。
こちらは、コンテキストなしにダイアログ表示を行うために使用していますが、こちらの詳細説明は割愛させていただきます(ちゃんと理解してから記事にします)。

3.changeNotifierクラスを拡張したクラスを作成

changeNotifierクラスを拡張したClockProviderというクラスを作成しました。

clock.dart
class ClockProvider with ChangeNotifier {

}

ちなみに、extendsでなく、withの理由はよく分かっていません(多分どちらでもOK)。

4.値を更新したいところでnotifyListeners()を実行

チェスクロックにおいて画面を再描画したいタイミングは多岐にわたります。

  • 起動時の設定時間読み込み完了時
  • 設定時間にリフレッシュしたいとき
  • 画面タップ時(チェスクロックを止める行為)
  • 一時停止したいとき
  • 1秒周期タイマーカウントダウン時
  • 設定画面にて時間設定時

今回は、「1秒周期タイマーカウントダウン時」で説明しますね。

チェスクロックは対局時はどちらかのタイマが動いています。
したがって、プレイヤーの手番に合わせて1秒周期タイマーを作成してカウントダウンしています。

clock.dart
   void countDown(Timer timer, ClockModel clock) {
    if (clock.currentSeconds >= 1) {
      // まずはカウントダウン
      clock.currentSeconds = clock.currentSeconds - 1;

      if (clock.currentSeconds <= 5) {
        // 音声「ビープ音」
        _audio.play(
          "leftin5_au.mp3",
          volume: 0.3,
          mode: PlayerMode.LOW_LATENCY,
        );
      } else if (clock.currentSeconds <= 10) {
        // 音声「ピ」
        _audio.play(
          "leftin10.mp3",
          volume: 0.3,
          mode: PlayerMode.LOW_LATENCY,
        );
      }
    }
    if (clock.currentSeconds < 1) {
      isFinished = true;
      print('clock._currentSeconds < 1');

      showAlertDialog();

      timer.cancel();
    }

    // 秒数をyy:mm:ssに変換
    clock.clock = secondsToClockString(clock.currentSeconds);

    // 変更通知
    notifyListeners();
  }

カウントダウンによりcurrentSecondsおよびclockを更新後に、notifiyListeners()で変更通知
これで、Consumerを配置した個所のWidgetが再描画されます

3.Consumerを配置

まずはConsumerの書き方から。

Consumer<ClockProvider>(builder: (context, model, child) { 
// notifyListeners()実行で、この中身の処理が再実行される。
}

今回は、main.dartに配置したConsumerで説明。
Player1とPlayer2で共通化したcreateRaisedButton()を作成しました。

Consumerを配置していますので、変更通知があれば、Consumerの中身が再描画されます。

main.dart
  Widget createRaisedButton(context, {int player}) {
    print('createRaisedButton');
    return Consumer<ClockProvider>(builder: (context, model, child) { // ①
      return RaisedButton(
        child: Text(
          model.clocks[player].clock,
          style: TextStyle(
            color:
                model.clocks[player].isMyTurn ? Colors.white : Colors.black45, //②
            fontSize: 68,
          ),
        ),
        color:
            model.clocks[player].isMyTurn ? Colors.orangeAccent : Colors.grey,
        onPressed: () => (model.clocks[player].isMyTurn | model.isReflesh)
            ? Provider.of<ClockProvider>(context, listen: false)
                .toggleClock(player)
            : null,
      );
    });
  }

modelは、名称変更可能です。
clockProvなどもっと分かりやすい名前をつけてもいいかも。

①でConsumerを配置したことにより、以下のmodel部分の値が更新された値で描画されます。

  • model.clocks[player].clock
  • model.clocks[player].isMyTurn
  • model.isReflesh

Flutter-Page-3 (2) (1).png

たとえば、②の部分。
更新されたisMyTurnの値に応じて、ボタンの色を変更しています。

各プレイヤーのisMyTurnの値とアプリ図の関係はご覧の通り。

  • (player1, player2)=(false, false)のとき
    コメント 2020-08-29 140603.png
    開始前や一時停止時はこうなります。

  • (player1, player2)=(true, false)のとき
    コメント 2020-08-29 154737.png

  • (player1, player2)=(false, true)のとき
    コメント 2020-08-29 154749.png

以上、こんな感じでProviderの仕組みを使って無事更新されるようになりました。

コード全体はこちら。

main.dart
import 'package:ameba_fischer/clock.dart';
import 'package:ameba_fischer/settings_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized(); // 向き指定より最初に書かないと起動時にエラーが出る

  // 向きを横固定
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight,
  ]).then((_) {
    runApp(new MyApp());
  });
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<ClockProvider>(
          create: (context) => ClockProvider(),
        ),
      ],
      child: GetMaterialApp(
        theme: ThemeData.dark(),
        home: HomePage(),
      ),
    );
  }
}

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

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

/// メイン画面
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  AppLifecycleState _notification;

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.inactive) {
      print('inactive');
    } else if (state == AppLifecycleState.resumed) {
      print('resumed');
    } else if (state == AppLifecycleState.paused) {
      print('paused');
      // ホームボタンで時間停止
      // Provider.of<ClockProvider>(context, listen: false).pause();
    } else if (state == AppLifecycleState.detached) {
      print('detached');
    }

    setState(() {
      _notification = state;
    });
  }

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

/// ヘッダー ナビゲーションメニュー
class Header extends StatelessWidget with PreferredSizeWidget {
  // AppBarの高さ調整 - kToolbarHeightは通常の高さ定数
  @override
  Size get preferredSize => Size.fromHeight(kToolbarHeight - 10);

  @override
  Widget build(BuildContext context) {
    return AppBar(
      title: Text('Chess Clock'),
      actions: [
        FlatButton(
          child: Icon(Icons.settings),
          onPressed: () async {
            Provider.of<ClockProvider>(context, listen: false).pause();
            print(await Navigator.push(
              context,
              MaterialPageRoute<void>(
                builder: (context) => SettingsPage(),
              ),
            ));
          },
        ),
        SizedBox(
          width: 12,
        ),
        Consumer<ClockProvider>(builder: (context, model, child) {
          return FlatButton(
            child: model.isPausing ? null : Icon(Icons.pause),
            onPressed: () {
              // 一時停止
              Provider.of<ClockProvider>(context, listen: false).pause();
            },
          );
        }),
        SizedBox(
          width: 12,
        ),
        FlatButton(
          child: Icon(Icons.refresh),
          onPressed: () {
            // 初期状態に戻す
            Provider.of<ClockProvider>(context, listen: false).reflesh();
          },
        ),
      ],
    );
  }
}

class Body extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Row(
          children: [
            Expanded(
              child: SizedBox(
                height: 260,
                child: createClock(context, player: 0),
              ),
            ),
            SizedBox(
              width: 12,
            ),
            Expanded(
              child: SizedBox(
                height: 260,
                child: createClock(context, player: 1),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget createClock(context, {int player}) {
    print('createClock');
    return (Provider.of<ClockProvider>(context).getSettingClocksLength() > 0
        ? createRaisedButton(context, player: player)
        : RaisedButton(
            onPressed: null,
          ));
  }

  Widget createRaisedButton(context, {int player}) {
    print('createRaisedButton');
    return Consumer<ClockProvider>(builder: (context, model, child) {
      return RaisedButton(
        child: Text(
          model.clocks[player].clock,
          style: TextStyle(
            color:
                model.clocks[player].isMyTurn ? Colors.white : Colors.black45,
            fontSize: 68,
          ),
        ),
        color:
            model.clocks[player].isMyTurn ? Colors.orangeAccent : Colors.grey,
        onPressed: () => (model.clocks[player].isMyTurn | model.isReflesh)
            ? Provider.of<ClockProvider>(context, listen: false)
                .toggleClock(player)
            : null,
      );
    });
  }
}
clock.dart
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'package:audioplayers/audio_cache.dart';

enum PauseType { PAUSE, PLUS }

class ClockModel {
  final String id;
  String clock;
  int currentSeconds;
  int plusSeconds;
  bool isMyTurn = false;
  Timer timer;

  ClockModel(
      {this.id,
      this.clock,
      this.currentSeconds,
      this.plusSeconds,
      this.isMyTurn,
      this.timer});

  Map toJson() => {
        'id': id,
        'clock': clock,
        'currentSeconds': currentSeconds,
        'plusSeconds': plusSeconds,
        'isMyTurn': isMyTurn,
        'timer': timer,
      };

  ClockModel.fromJson(Map json)
      : id = json['id'],
        clock = json['clock'],
        currentSeconds = json['currentSeconds'],
        plusSeconds = json['plusSeconds'],
        isMyTurn = json['isMyTurn'],
        timer = json['timer'];
}

class ClockProvider with ChangeNotifier {
  List<ClockModel> clocks = [];
  List<ClockModel> settingClocks = [];

  bool isReflesh = true;
  bool isPausing = true;
  bool isFinished = false;
  final _audio = AudioCache();

  ClockProvider() {
    _audio.loadAll([
      'button68_kati.mp3',
      'leftin5_au.mp3',
      'leftin10.mp3',
    ]);

    this.isReflesh = true;
    this.isPausing = true;
    this.isFinished = false;

    initialState();
  }

  void initialState() {
    print('initialState()');
    syncDataWithProvider();
  }

  void initClocks() {
    clocks = [
      ClockModel(
          id: '1',
          clock: '00:05:00',
          currentSeconds: 300,
          plusSeconds: 5,
          isMyTurn: false),
      ClockModel(
          id: '2',
          clock: '00:05:00',
          currentSeconds: 300,
          plusSeconds: 5,
          isMyTurn: false),
    ];
  }

  void initSettingClocks() {
    settingClocks = [
      ClockModel(
          id: '1',
          clock: '00:05:00',
          currentSeconds: 300,
          plusSeconds: 5,
          isMyTurn: false),
      ClockModel(
          id: '2',
          clock: '00:05:00',
          currentSeconds: 300,
          plusSeconds: 5,
          isMyTurn: false),
    ];
  }

  int getSettingClocksLength() {
    print('settingClocks.length:${settingClocks.length}');
    return settingClocks.length;
  }

  void setInitialSharedPrefrences() {
    // 設定のみ初期化
    initSettingClocks();
    // sharedPreferencesにデータセーブ
    updateSharedPrefrences();
  }

  Future updateSharedPrefrences() async {
    List<String> myClocks =
        settingClocks.map((f) => json.encode(f.toJson())).toList();

    SharedPreferences prefs = await SharedPreferences.getInstance();

    await prefs.setStringList('ClockDataType_settings', myClocks);

    print('updateSharedPrefrences: $myClocks');

    notifyListeners();
  }

  /// 設定したクロックと同期
  Future syncDataWithProvider() async {
    print('syncDataWithProvider');

    SharedPreferences prefs = await SharedPreferences.getInstance();
    var result = prefs.getStringList('ClockDataType_settings');

    print('result:$result');

    if (result != null) {
      clocks = result.map((f) => ClockModel.fromJson(json.decode(f))).toList();
      settingClocks =
          result.map((f) => ClockModel.fromJson(json.decode(f))).toList();
    } else {
      // タイマ:5分、フィッシャー:5秒設定
      initClocks();
      initSettingClocks();
    }

    notifyListeners();
  }

  /// 一時停止
  void pause() {
    cancelTimer('1', PauseType.PAUSE);
    cancelTimer('2', PauseType.PAUSE);
    isPausing = true;

    notifyListeners();
  }

  /// 時間リフレッシュ
  void reflesh() {
    print('reflesh');
    cancelTimer('1', PauseType.PAUSE);
    cancelTimer('2', PauseType.PAUSE);

    clocks = null;

    isReflesh = true;
    isPausing = true;

    // 再初期化
    syncDataWithProvider();

    notifyListeners();
  }

  ClockModel findById(String id) {
    for (ClockModel clock in clocks) {
      if (clock.id == id) return clock;
    }
    return null;
  }

  /// クロックを押したときの処理
  void toggleClock(int player) {
    // 音声「カチ」
    _audio.play('button68_kati.mp3');

    ClockModel clockSelf;
    ClockModel clockEnemy;

    //クロック押した側がSelf, 押された側がEnemy.
    switch (player) {
      case 0:
        clockSelf = clocks[0];
        clockEnemy = clocks[1];
        break;
      case 1:
        clockSelf = clocks[1];
        clockEnemy = clocks[0];
        break;
      default:
        break;
    }

    if (isPausing && clockSelf.isMyTurn) {
      // 停止中はタイマ再開
      restartTimer(clockSelf.id);
    } else {
      cancelTimer(clockSelf.id, PauseType.PLUS);
      restartTimer(clockEnemy.id);

      clockSelf.isMyTurn = false;
      clockEnemy.isMyTurn = true;
    }

    isReflesh = false;
    isPausing = false;

    notifyListeners();
  }

  /// クロック停止
  void cancelTimer(String id, PauseType pauseType) {
    ClockModel clock = findById(id);
    if (clock == null) return;

    // タイマ動作中なら、クロック更新後にタイマ停止
    if (clock.timer != null) {
      // フィッシャールール - プラス時間を積算
      if (pauseType == PauseType.PLUS) {
        clock.currentSeconds = clock.currentSeconds + clock.plusSeconds;
      }
      // 秒数をyy:mm:ssに変換
      clock.clock = secondsToClockString(clock.currentSeconds);
      clock.timer.cancel();
    }
  }

  /// クロック再開
  void restartTimer(String id) {
    ClockModel clock = findById(id);
    clock.timer = countTimer(id);
  }

  /// タイマーカウント
  Timer countTimer(String id) {
    Timer timer;
    if (id == '1') {
      timer = Timer.periodic(
        Duration(seconds: 1),
        _onTimer1,
      );
    } else {
      timer = Timer.periodic(
        Duration(seconds: 1),
        _onTimer2,
      );
    }
    return timer;
  }

  /// タイマ周期イベント1
  void _onTimer1(Timer timer) {
    ClockModel clock = findById('1');
    countDown(timer, clock);
  }

  /// タイマ周期イベント2
  void _onTimer2(Timer timer) {
    ClockModel clock = findById('2');
    countDown(timer, clock);
  }

  void countDown(Timer timer, ClockModel clock) {
    if (clock.currentSeconds >= 1) {
      // まずはカウントダウン
      clock.currentSeconds = clock.currentSeconds - 1;

      if (clock.currentSeconds <= 5) {
        // 音声「ビープ音」
        _audio.play(
          "leftin5_au.mp3",
          volume: 0.3,
          mode: PlayerMode.LOW_LATENCY,
        );
      } else if (clock.currentSeconds <= 10) {
        // 音声「ピ」
        _audio.play(
          "leftin10.mp3",
          volume: 0.3,
          mode: PlayerMode.LOW_LATENCY,
        );
      }
    }
    if (clock.currentSeconds < 1) {
      isFinished = true;
      print('clock._currentSeconds < 1');

      showAlertDialog();

      timer.cancel();
    }

    // 秒数をyy:mm:ssに変換
    clock.clock = secondsToClockString(clock.currentSeconds);

    // 変更通知
    notifyListeners();
  }

  /// 秒数をyy:mm:ssに変換
  String secondsToClockString(int leftSeconds) {
    final hours = (leftSeconds / 3600).floor().toString().padLeft(2, '0');
    final minutes = (leftSeconds / 60).floor().toString().padLeft(2, '0');
    final seconds = (leftSeconds % 60).floor().toString().padLeft(2, '0');

    if ((leftSeconds / 3600).floor() != 0) {
      return '$hours:$minutes:$seconds';
    }
    return '$minutes:$seconds';
  }

  /// yy:mm:ssを秒数に変換
  int clockToSecondsString(String date) {
    List clock = date.split(':');
    int seconds;

    if (clock.length == 3) {
      seconds = int.parse(clock[0]) * 3600 +
          int.parse(clock[1]) * 60 +
          int.parse(clock[2]);
    } else if (clock.length == 2) {
      seconds = int.parse(clock[0]) * 60 + int.parse(clock[1]);
    } else {
      seconds = int.parse(clock[0]);
    }

    return seconds;
  }

  /// 時間切れダイアログ表示
  void showAlertDialog() {
    Get.dialog(SimpleDialog(
      title: Text('時間切れ'),
      children: <Widget>[
        FlatButton(
          child: Text('OK'),
          onPressed: () => {Get.back()},
        ),
      ],
    ));
  }
}

 
設定画面については、一部省略させていただきます。

settings_page.dart
import 'package:flutter/material.dart';
import 'package:ameba_fischer/clock.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';

enum Answers { YES, NO }

class SettingsPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () {
        Navigator.pop(context, '戻った');
        return Future.value(false);
      },
      child: Scaffold(
        appBar: AppBar(
          title: Text('設定'),
          actions: [
            FlatButton(
              child: Icon(Icons.refresh),
              onPressed: () {
                Provider.of<ClockProvider>(context, listen: false)
                    .setInitialSharedPrefrences();
              },
            ),
          ],
        ),
        body: SettingsPageHome(),
      ),
    );
  }
}

class SettingsPageHome extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Consumer<ClockProvider>(builder: (context, model, child) {
          // 省略(各種設定処理)
        }),
      ),
    );
  }
}

C言語上がりには、ところどころオブジェクト指向的な考えができていないところがあります(書き方もよう分からん)。
より良いコードにするにはどうすればよいか優しくレクチャーいただけますと幸いです。

19
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
16