先日、Flutterの学習がてら、チェスクロックアプリを作成しました。
製作期間は1週間ほど。
Swift、Androidならすぐできるのに…とかひとりぼやきつつ、Providerについてもなんとなく理解して実践で使用できましたので参考までに。
図解とコード付けていますので、なんとなく理解した気になれたらチャレンジしてみてください。
##Providerについて
Consumerと組み合わせることで、Consumer以下に配置したWidgetのみ更新することが可能。
これにより、全画面描画する必要がなくなり、レンダリング時間の削減につながる。
StatefulWidgetの仕組みよりこちらのほうが理解しやすい。
全画面描画しないで済む分パフォーマンスにも優れており、Providerを使うメリットについてはGoogleも認めています。
アプリ全体像
チェスクロックアプリの全体像より、Providerの使い方を解説してみます。
まずは、各Widgetの配置関係を図解してみましたのでざっくり眺めてみましょう(正確ではないです)。
Providerの使い方
- Providerパッケージをインストール
-
MaterialApp
の上にProviderを配置 -
changeNotifier
クラスを拡張したクラスを作成 - 値を更新したいところで
notifyListeners()
を実行 -
Consumer
を配置
順に説明します。
最後にコード全体を掲載しますのでご安心を。
###1.Providerパッケージをインストール
pubspec.yaml
に以下を追加して、$ flutter pub get
を実行。
dependencies:
provider: ^4.3.2+1
バージョンはこちらで確認ください。
https://pub.dev/packages/provider
###2.MaterialAppの上にProviderを配置
今回はClockProvider
というクラスをProviderとして登録します。
コードは以下のとおり。
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
というクラスを作成しました。
class ClockProvider with ChangeNotifier {
…
}
ちなみに、extendsでなく、withの理由はよく分かっていません(多分どちらでもOK)。
###4.値を更新したいところでnotifyListeners()を実行
チェスクロックにおいて画面を再描画したいタイミングは多岐にわたります。
- 起動時の設定時間読み込み完了時
- 設定時間にリフレッシュしたいとき
- 画面タップ時(チェスクロックを止める行為)
- 一時停止したいとき
- 1秒周期タイマーカウントダウン時
- 設定画面にて時間設定時
今回は、「1秒周期タイマーカウントダウン時」で説明しますね。
チェスクロックは対局時はどちらかのタイマが動いています。
したがって、プレイヤーの手番に合わせて1秒周期タイマーを作成してカウントダウンしています。
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の中身が再描画されます。
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
たとえば、②の部分。
更新されたisMyTurn
の値に応じて、ボタンの色を変更しています。
各プレイヤーのisMyTurn
の値とアプリ図の関係はご覧の通り。
以上、こんな感じでProviderの仕組みを使って無事更新されるようになりました。
コード全体はこちら。
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,
);
});
}
}
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()},
),
],
));
}
}
設定画面については、一部省略させていただきます。
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言語上がりには、ところどころオブジェクト指向的な考えができていないところがあります(書き方もよう分からん)。
より良いコードにするにはどうすればよいか優しくレクチャーいただけますと幸いです。