[Flutter] hooks_riverpod + StateNotifier で Timer アプリを MVVM で作る

概要

hooks_riverpod + StateNotifier で Timer アプリを作る。

下記を参考に、Riverpod 1.0 に対応し責務ごとにファイル分割した。

Flutter Riverpod tutorial: Timer app
Step-by-step instructions to create your second Riverpod project

環境

  • Flutter 2.8.1
  • hooks_riverpod 1.0.3
  • state_notifier 0.7.2

設計

アプリ概要

以下のようなタイマーアプリが作成される。

MVVM アーキテクチャ

なるべく債務を分けて、ファイル分割したほうが良いと考えている
(やりすぎると複雑になるが。。)。

今回は MVVM アーキテクチャを意識して責務を分ける方針。

  • View : 画面表示を行う pages と widgets
  • ViewModel : State への操作、StateNotifier もここ
  • Model : State を保持

以下のように分割した。

  • lib
    • main.dart
    • app.dart
    • view
      • home_page.dart
      • button_widget.dart
      • timertext_widget.dart
    • view_model
      • button_provider.dart
      • timer_provider.dart
      • button_ext.dart
    • model
      • timer.dart

Enum Extension

MVVM パターンにする場合、enum は Model 層に定義することになる。

enum ButtonState {
  initial,
  started,
  paused,
  finished,
}

以下のよう、View 層となる Widget で enum の値を参照し、 Widget を選択するロジックが書かれている。

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('building ButtonsContainer');
    final state = ref.watch(buttonProvider);
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        if (state == ButtonState.initial) ...[
          StartButton(),
        ],
        if (state == ButtonState.started) ...[
          PauseButton(),
          SizedBox(width: 20),
          ResetButton(),
        ],
        if (state == ButtonState.paused) ...[
          StartButton(),
          SizedBox(width: 20),
          ResetButton(),
        ],
        if (state == ButtonState.finished) ...[
          ResetButton(),
        ],
      ],
    );
  }

enum extension を利用し、ロジックを View から切り離す。

以下のように、 ButtonState の Extension を定義する。 List<Widget> を返す getter を追加した。

extension ButtonStateExt on ButtonState {
  List<Widget> get ButtonIconWidget {
    switch (this) {
      case ButtonState.initial:
        return [StartButton()];
      case ButtonState.started:
        return [PauseButton(),SizedBox(width: 20),ResetButton(),];
      case ButtonState.paused:
        return [StartButton(),SizedBox(width: 20),ResetButton(),];
      case ButtonState.finished:
        return [ResetButton()];
    }
  }
}

View 内からロジックを取り除くことができた(しかし ViewModel が肥大化した・・・)。

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(buttonProvider);

    print('building ButtonsContainer');
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: state.ButtonIconWidget,
    );
  }

チュートリアル

MVVM 外

main.dart

アプリ起動する起点。

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

import 'app.dart';

void main() {
  runApp(
    ProviderScope(
      child: App(),
    ),
  );
}

app.dart

MaterialApp で HomePage() を呼び出す。

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

import 'view/home_page.dart';

class App extends StatelessWidget {
  const App({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: HomePage(),
    );
  }
}

View

View 部分では Page と Widget が該当。

home_page.dart

Widget をまとめる Widget 部分。

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

import 'timertext_widget.dart';
import 'button_widget.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print('building MyHomePage');
    return Scaffold(
      appBar: AppBar(title: Text('Riverpod Timer')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            TimerTextWidget(),
            SizedBox(height: 20),
            ButtonsContainer(),
          ],
        ),
      ),
    );
  }
}

timertext_widget.dart

タイマーの文字を表示する部分。

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

import '../view_model/timertext_provider.dart';

class TimerTextWidget extends HookConsumerWidget {
  const TimerTextWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final timeLeft = ref.watch(timeLeftProvider);

    print('building TimerTextWidget $timeLeft');
    return Text(
      timeLeft,
      style: Theme.of(context).textTheme.headline2,
    );
  }
}

button_container.dart

ボタンを表示させる部分。

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

import '../view_model/timertext_provider.dart';
import '../view_model/button_provider.dart';
import '../view_model/button_ext.dart';

class ButtonsContainer extends HookConsumerWidget {
  const ButtonsContainer({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(buttonProvider);

    print('building ButtonsContainer');
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: state.ButtonIconWidget,
    );
  }
}

class StartButton extends ConsumerWidget {
  const StartButton({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return FloatingActionButton(
      onPressed: () {
        ref.read(timerProvider.notifier).start();
      },
      child: Icon(Icons.play_arrow),
    );
  }
}

class PauseButton extends ConsumerWidget {
  const PauseButton({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return FloatingActionButton(
      onPressed: () {
        ref.read(timerProvider.notifier).pause();
      },
      child: Icon(Icons.pause),
    );
  }
}

class ResetButton extends ConsumerWidget {
  const ResetButton({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return FloatingActionButton(
      onPressed: () {
        ref.read(timerProvider.notifier).reset();
      },
      child: Icon(Icons.replay),
    );
  }
}

ViewModel

timertext_provider.dart

タイマーの操作部分。TimerModel の StateNotifier と StateNotifierProvider を部分。

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../view/button_widget.dart';
import '../model/timer.dart';

final timerProvider = StateNotifierProvider<TimerNotifier, TimerModel>(
  (ref) => TimerNotifier(),
);

final _timeLeftProvider = Provider<String>((ref) {
  return ref.watch(timerProvider).timeLeft;
});

final timeLeftProvider = Provider<String>((ref) {
  return ref.watch(_timeLeftProvider);
});

class TimerNotifier extends StateNotifier<TimerModel> {
  TimerNotifier() : super(_initialState);

  static const int _initialDuration = 10;
  static final _initialState = TimerModel(
    _durationString(_initialDuration),
    ButtonState.initial,
  );

  final Ticker _ticker = Ticker();
  StreamSubscription<int>? _tickerSubscription;

  static String _durationString(int duration) {
    final minutes = ((duration / 60) % 60).floor().toString().padLeft(2, '0');
    final seconds = (duration % 60).floor().toString().padLeft(2, '0');
    return '$minutes:$seconds';
  }

  void start() {
    if (state.buttonState == ButtonState.paused) {
      _restartTimer();
    } else {
      _startTimer();
    }
  }

  void _restartTimer() {
    _tickerSubscription?.resume();
    state = TimerModel(state.timeLeft, ButtonState.started);
  }

  void _startTimer() {
    _tickerSubscription?.cancel();

    _tickerSubscription =
        _ticker.tick(ticks: _initialDuration).listen((duration) {
      state = TimerModel(_durationString(duration), ButtonState.started);
    });

    _tickerSubscription?.onDone(() {
      state = TimerModel(state.timeLeft, ButtonState.finished);
    });

    state = TimerModel(_durationString(_initialDuration), ButtonState.started);
  }

  void pause() {
    _tickerSubscription?.pause();
    state = TimerModel(state.timeLeft, ButtonState.paused);
  }

  void reset() {
    _tickerSubscription?.cancel();
    state = _initialState;
  }

  @override
  void dispose() {
    _tickerSubscription?.cancel();
    super.dispose();
  }
}

button_provider.dart

ButtonState のプロバイダ。責務的に、後述する button_ext.dart をここにまとめてよかったかもしれない。

import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../view_model/timertext_provider.dart';
import '../model/timer.dart';

final _buttonState = Provider<ButtonState>((ref) {
  return ref.watch(timerProvider).buttonState;
});

final buttonProvider = Provider<ButtonState>((ref) {
  return ref.watch(_buttonState);
});

button_ext.dart

ButtonState の Enum Extensionを記述。
View 層を読み込むため、あえて button_provider と分離。

import 'package:flutter/material.dart';

import '../view/button_widget.dart';
import '../model/timer.dart';

extension ButtonStateExt on ButtonState {
  List<Widget> get ButtonIconWidget {
    switch (this) {
      case ButtonState.initial:
        return [StartButton()];
      case ButtonState.started:
        return [PauseButton(),SizedBox(width: 20),ResetButton(),];
      case ButtonState.paused:
        return [StartButton(),SizedBox(width: 20),ResetButton(),];
      case ButtonState.finished:
        return [ResetButton()];
    }
  }
}

Model

timer.dart

タイマーのモデルを定義。

import 'dart:async';

class Ticker {
  Stream<int> tick({required int ticks}) {
    return Stream.periodic(
      Duration(seconds: 1),
      (x) => ticks - x - 1,
    ).take(ticks);
  }
}

class TimerModel {
  const TimerModel(this.timeLeft, this.buttonState);
  final String timeLeft;
  final ButtonState buttonState;
}

enum ButtonState {
  initial,
  started,
  paused,
  finished,
}

まとめ

Flutter Enum Extension 便利。
ただ、Model 層か ViewModel 層のどっちに書くべきか迷っている。

コードは以下にまとめた。

GitHub - runble1/flutter_timer_mvvm
Contribute to runble1/flutter_timer_mvvm development by creating an account on GitHub.

参考

Not Found – Medium
We could not find what you were looking for on Medium.
Dart enum extensionは便利だぞ - Qiita
はじめに例えばこんなModelがあったとき。class Article { const Article({ required this.title, required this.descript…

コメント

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