概要
hooks_riverpod + StateNotifier で Timer アプリを作る。
下記を参考に、Riverpod 1.0 に対応し責務ごとにファイル分割した。
環境
- 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);
});
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 層のどっちに書くべきか迷っている。
コードは以下にまとめた。
コメント