完成版
このアプリは自分の生年月日を入力することで、残りの寿命を知ることができるアプリです。自分に残された時間を知ることで、より今を大切に生きれるように!という思いを込めて作成しました...
Flutterの全ソースコードと解説を公開しているので、これからFlutterを学ぶ方がハンズオン形式でライトに実装できる内容になっています。
本アプリは以下の流れで作成していきます。
事前準備
レイアウト作成
日付入力と表示
残余名の計算
グラフ表示
事前準備
公式サイトを参考に、Flutterの環境構築を行いましょう。
https://flutter.dev/docs/get-started/install
今回はMacOS/iOSエミュレータ/VSCodeという環境で実装していきますが、Windows/Android/Intellij等の組み合わせでも特に違いはありません。
VSCodeの場合、「Shift+Command+P」でNewProjectを作成できるので、今回は「lifetimer_handson」という名前でプロジェクトを作成していきます。

エミュレータが立ち上がればOKです。もし起動に失敗する場合は、環境構築に不備がある可能性が高いので、公式サイトに戻り再度インストール手順に従って設定を行いましょう。
サンプルプロジェクトの削除
Flutterでは、プロジェクト作成時にデフォルトでサンプル実装が記述されています。一から自分たちでアプリをつくっていく際には不要になるため、main.dart
ファイルのソースコードを全文削除し、中身を次のように変更します。
import 'package:flutter/material.dart';
void main() => runApp(MaterialApp(
theme: ThemeData.dark(),
home: Container()
));

真っ黒の画面が表示されるはずです。ここから段階的にアプリを実装してきましょう。
レイアウト作成
上記の初期実装において、main.dart
で表示していたのは空のContainerでした。今回はここに新たなページを埋め込んでいきます。
まずは新しいディレクトリとファイルを作成します。main.dart
と同じ階層にpages/lifetimer.dart
を作成していきましょう。
import 'package:flutter/material.dart';
class LifeTimerPage extends StatefulWidget {
@override
_LifeTimerPageState createState() => _LifeTimerPageState();
}
class _LifeTimerPageState extends State<LifeTimerPage> {
@override
Widget build(BuildContext context) {
return Container();
}
}
pages/lifetimer.dart
でも空の空のContainerを返していますが、今回はStatefulWidgetという状態を持つことができるクラスでラップしています。
Stateful or Stateless widgets?
このファイルをmain.dart
で使用します。importは以下のように記述することができます。
import 'package:flutter/material.dart';
import './pages/lifetimer.dart';
void main() => runApp(MaterialApp(
theme: ThemeData.dark(),
home: LifeTimerPage(), // 修正
));
home
としてLifeTimerPage()
を呼び出すようにしておきます。もちろんこの状態では空のContainerが返るため画面は黒い状態のままです。
画面の大枠を作成
マテリアルデザインで基本的なレイアウトを作成するため、Scaffoldを利用して空のContainerを置き換えます。
pages/lifetimer.dart
を以下のように書き換えてください。
import 'package:flutter/material.dart';
class LifeTimerPage extends StatefulWidget {
@override
_LifeTimerPageState createState() => _LifeTimerPageState();
}
class _LifeTimerPageState extends State<LifeTimerPage> {
@override
Widget build(BuildContext context) {
return Scaffold( // 修正
appBar: AppBar(title: Text("LifeTimer")),
body: Text("LifeTimerBody"));
}
}

画面にレイアウトが表示されました。
リスト形式による表示
また、ListViewを使用することで、テキストを縦に複数表示することが可能です。
body: ListView(
children: <Widget>[
Text("LifeTimerBody"),
Text("LifeTimerBody"),
Text("LifeTimerBody")
],
),

ちなみに、ListViewはスクロール機能も備えているため、この画面で縦にスクロールするとにゅるにゅるとした画面スクロールを体感することができます。
さらに、PaddingでこのListViewをラップすることにより、CSSでいうところのpadding
を追加できます。
Paddingは前述したContainerでも同様の機能を実装できます。
Flutter — Container Cheat Sheet
body: Padding(
padding: EdgeInsets.all(16.0),
child: ListView(
children: <Widget>[
Text("LifeTimerBody"),
Text("LifeTimerBody"),
Text("LifeTimerBody")
],
),
),

これにより、左上の空間に少し余白が生まれました。
タイトルの作成
画面ボディ部の一番先頭にタイトルを表示していきます。先ほど作成したbody
を以下のように置き換えます。
body: Padding(
padding: EdgeInsets.all(16.0),
child: ListView(
children: <Widget>[
_buildTitle(), // 修正
Text("LifeTimerBody"),
Text("LifeTimerBody")
],
),
),
_buildTitle()
はWidgetを返す関数です。body
の呼び出し元と同じ_LifeTimerPageState
クラス内で、以下の関数を作成します。
Widget _buildTitle() {
return Padding(
padding: EdgeInsets.only(bottom: 10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.mood),
SizedBox(
width: 5.0,
),
Text(
'あなたに残された日数を計算します',
style: TextStyle(fontSize: 18.0),
),
],
),
);
}
Paddingは前述したContainerでラップしているのは前回と同じです。今回はchild
に RowというWidgetを使用しています。これは、複数のWidgetを横並びで配置するためです。MainAxisAlignment.center
を指定することで、children
に定義されているIcon、SizedBox、Textを中央揃えで配置することができます。TextではTextStyleを使用することでフォントサイズやフォントファミリーを指定することが可能です。
また、 PaddingではEdgeInsetsを使用することでpadding
の指定が可能であることは前述の通りですが、今回はonly
を使用することで、bottom
にのみpadding
を適用しています。

実行することで、先頭のテキストが書き換わっていることを確認できます。
日付入力と表示
Flutterで日時を入力する方法は複数存在しますが、今回はDateTimePickerFormFieldというライブラリを利用していきましょう。ライブラリを利用するためにはpubspec.yaml
で対象のバージョンを記述する必要があります。
...
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.2
datetime_picker_formfield: ^0.1.8 # 追加
...
pubspec.yaml
で依存関係に追加したライブラリは、dartファイルから呼び出すことができるようになります。
また、日付を扱う上で欠かせないフォーマッターを利用するため、DateFormatを使用していきますが、これはintl.dart
に含まれています。合わせて2つのパッケージをインポートしていきましょう。
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:datetime_picker_formfield/datetime_picker_formfield.dart';
class LifeTimerPage extends StatefulWidget {
@override
_LifeTimerPageState createState() => _LifeTimerPageState();
}
...
今回の追加実装ではカレンダー入力操作、入力した値の保持、入力情報の画面表示を同時に記載しています。
class _LifeTimerPageState extends State<LifeTimerPage> {
// 日時フォーマット
final formats = {
InputType.both: DateFormat("yyyy-MM-dd HH:mm:ss"),
InputType.date: DateFormat('yyyy-MM-dd'),
InputType.time: DateFormat("HH:mm"),
};
DateTime birthDate;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("LifeTimer")),
body: Padding(
padding: EdgeInsets.all(16.0),
child: ListView(
children: <Widget>[
_buildTitle(),
_buildBirthDateInputField(),
_buildBirthTextField()
],
),
),
);
}
Widget _buildTitle() {
return Padding(
padding: EdgeInsets.only(bottom: 10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.mood),
SizedBox(
width: 5.0,
),
Text(
'あなたに残された日数を計算します',
style: TextStyle(fontSize: 18.0),
),
],
),
);
}
Widget _buildBirthDateInputField() {
return Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: DateTimePickerFormField(
inputType: InputType.date,
format: formats[InputType.date],
editable: true,
decoration: InputDecoration(
labelText: '生年月日を入力してください', hasFloatingPlaceholder: false),
onChanged: (birthDate) => _setBirthDate(birthDate),
),
);
}
void _setBirthDate(DateTime _birthDate) {
setState(() {
birthDate = _birthDate;
print(birthDate);
});
}
Widget _buildBirthTextField() {
return Padding(
padding: EdgeInsets.all(5.0),
child: Text("生年月日: ${_getBirthDate()}"),
);
}
String _getBirthDate() {
return birthDate != null
? formats[InputType.date].format(birthDate)
: '入力待ち';
}
}
_buildBirthDateInputField()
では、DateTimePickerFormFieldでカレンダーの入力を促しています。カレンダーがモーダルで表示されるライブラリとなっており、日付の指定後にonChanged
が発火します。onChanged
の中では_setBirthDate
関数が呼ばれていて、State
を継承した_LifeTimerPageState
クラスに存在するプロパティbirthDate
を書き換えます。この時setState()を使用しなければならないことに注意してください。
最後に、_buildBirthTextField()
を呼び出すことで、入力したカレンダーの日付が画面に出力されることが確認できます。
現在日時のカウントアップ
DateTimeとTimerを利用することで、現在時刻を取得し画面に表示します。Timerを使用するために、dart:async
をインポートします。
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:datetime_picker_formfield/datetime_picker_formfield.dart';
import 'dart:async'; // 追加
class LifeTimerPage extends StatefulWidget {
@override
_LifeTimerPageState createState() => _LifeTimerPageState();
}
class _LifeTimerPageState extends State<LifeTimerPage> {
// 日時フォーマット
final formats = {
InputType.both: DateFormat("yyyy-MM-dd HH:mm:ss"),
InputType.date: DateFormat('yyyy-MM-dd'),
InputType.time: DateFormat("HH:mm"),
};
// 日時
DateTime birthDate;
DateTime now; // 追加
// 追加
@override
void initState() {
super.initState();
Timer.periodic(Duration(seconds: 1), (timer) {
setState(() {
now = new DateTime.now();
});
});
}
今回はWidgetが作成される時に呼ばれるinitState()
の中で、1秒ごとに現在時刻を更新するTimerを仕込みます。
画面に現在時刻を表示するWidgetの追加実装は以下の通りです。
...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("LifeTimer")),
body: Padding(
padding: EdgeInsets.all(16.0),
child: ListView(
children: <Widget>[
_buildTitle(),
_buildBirthDateInputField(),
_buildBirthTextField(),
_buildNowTextField() // 追加
],
),
),
);
}
...
// 追加
Widget _buildNowTextField() {
return Padding(
padding: EdgeInsets.all(5.0),
child: Text("現在時刻: ${_getNow()}"),
);
}
// 追加
String _getNow() {
return now != null ? formats[InputType.both].format(now) : "なし";
}

無事画面に表示されていることが確認できました。
残余名の計算
ここからは残りの寿命を計算するロジックを追加してきます。
平均寿命から算出される残余命時間の計算式
日本人の平均寿命は83.98歳です。(2016年度調査)
厳密には異なりますが、仮に1年を365日として83年を計算すると、
83*365日=30295日
0.98年は357日16時間48分
とみなすことで、
生まれてから30652日16時間48分後が推定命日
と言い換えることができます。
この計算式を実装していきましょう。以前に作成した_setBirthDate
を削除し、以下のように_setBirthAndExpectedDeathDate
へと変更して、birthDate
同様expectedDeathDate
を更新します。
...
// 日時
DateTime birthDate;
DateTime now;
DateTime expectedDeathDate; // 追加
...
Widget _buildBirthDateInputField() {
return Padding(
padding: EdgeInsets.symmetric(vertical: 10.0),
child: DateTimePickerFormField(
inputType: InputType.date,
format: formats[InputType.date],
editable: true,
decoration: InputDecoration(
labelText: '生年月日を入力してください', hasFloatingPlaceholder: false),
onChanged: (birthDate) => _setBirthAndExpectedDeathDate(birthDate), // 修正
),
);
}
void _setBirthAndExpectedDeathDate(DateTime _birthDate) { // 修正
// var averageDeathAge = 83.98; // year: 83, day: 357, hour: 16, minute: 48
var averageDaethDuration = Duration(days: 30652, hours: 16, minutes: 48);
setState(() {
birthDate = _birthDate;
expectedDeathDate = birthDate.add(averageDaethDuration);
});
}
...
// 追加
Widget _buildExpectedDateTextField() {
return Padding(
padding: EdgeInsets.all(30.0),
child: Column(
children: <Widget>[
Text("平均寿命から計算されるあなたの推定命日"),
SizedBox(
height: 5.0,
),
Text(
"${_getExpectedDeathDate()}",
style: TextStyle(fontSize: 30.0, color: Colors.red[300]),
),
],
),
);
}
// 追加
String _getExpectedDeathDate() {
return expectedDeathDate != null
? formats[InputType.date].format(expectedDeathDate)
: "入力待ち";
}

寿命パラメータの表示
推定命日がわかったので、現在時刻との差分から残りの時間をカウントダウンしていきます。日単位、時間単位、分単位、秒単位でそれぞれ計算してみましょう。
...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("LifeTimer")),
body: Padding(
padding: EdgeInsets.all(16.0),
child: ListView(
children: <Widget>[
_buildTitle(),
_buildBirthDateInputField(),
_buildBirthTextField(),
_buildNowTextField(),
_buildExpectedDateTextField(),
_buildLeftTimeParams() // 追加
],
),
),
);
}
...
// 追加
Widget _buildLeftTimeParams() {
return Padding(
padding: EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: EdgeInsets.only(bottom: 10.0),
child: Text("推定死亡日まで残り",
style: TextStyle(decoration: TextDecoration.underline)),
),
Padding(
padding: EdgeInsets.only(left: 20.0, bottom: 3.0),
child: Text(
"日計算 :${_getExpectedDeathInDays()} 日",
style: TextStyle(fontSize: 20.0),
),
),
Padding(
padding: EdgeInsets.only(left: 20.0, bottom: 3.0),
child: Text(
"時計算 :${_getExpectedDeathInHours()} 時間",
style: TextStyle(fontSize: 20.0),
),
),
Padding(
padding: EdgeInsets.only(left: 20.0, bottom: 3.0),
child: Text(
"分計算 :${_getExpectedDeathInMinutes()} 分",
style: TextStyle(fontSize: 20.0),
),
),
Padding(
padding: EdgeInsets.only(left: 20.0, bottom: 3.0),
child: Text(
"秒計算 :${_getExpectedDeathInSeconds()} 秒",
style: TextStyle(fontSize: 20.0),
),
),
],
),
);
}
// 追加
String _getExpectedDeathInDays() {
var leftTimeDuration = _calcLifeTimeDuration();
return leftTimeDuration != null ? leftTimeDuration.inDays.toString() : "-";
}
// 追加
String _getExpectedDeathInHours() {
var leftTimeDuration = _calcLifeTimeDuration();
return leftTimeDuration != null ? leftTimeDuration.inHours.toString() : "-";
}
// 追加
String _getExpectedDeathInMinutes() {
var leftTimeDuration = _calcLifeTimeDuration();
return leftTimeDuration != null
? leftTimeDuration.inMinutes.toString()
: "-";
}
// 追加
String _getExpectedDeathInSeconds() {
var leftTimeDuration = _calcLifeTimeDuration();
return leftTimeDuration != null
? leftTimeDuration.inSeconds.toString()
: "-";
}
// 追加
Duration _calcLifeTimeDuration() {
return expectedDeathDate != null ? expectedDeathDate.difference(now) : null;
}
_calcLifeTimeDuration()
では推定命日から現在時刻の差分をDuration
として取得していて、各パラメータごとの残り時間を表示しています。
表示結果は以下のようになります。

この状態で、1秒ごとに秒計算の数値がカウントダウンされるようになります。毎秒減り続ける寿命に焦る気持ちを抑えつつ実装を進めていきましょう。
グラフ表示
いよいよ大詰めです。今回は残りの寿命をパーセンテージで表していきます。
まずは表示するグラフの大きさを画面サイズから決定したいと思います。小さなデバイスでも適切なサイズで表示できるようにするためです。MediaQueryを使用して、width
の大きさを取得し、_buildRadialProgress
関数に値を渡します。
...
import '../widgets/lifetimer_painter.dart';
...
@override
Widget build(BuildContext context) {
double width = MediaQuery.of(context).size.width; //
return Scaffold(
appBar: AppBar(title: Text("LifeTimer")),
body: Padding(
padding: EdgeInsets.all(16.0),
child: ListView(
children: <Widget>[
_buildTitle(),
_buildBirthDateInputField(),
_buildBirthTextField(),
_buildNowTextField(),
_buildExpectedDateTextField(),
_buildLeftTimeParams(),
_buildRadialProgress(width), // 追加
],
),
),
);
}
...
Widget _buildRadialProgress(double deviceWidth) {
double circleSize = deviceWidth * 0.7;
double textSize = deviceWidth * 0.065;
return Container(
padding: EdgeInsets.all(4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CustomPaint(
foregroundPainter: LifeTimerPainter(
lineColor: Colors.grey,
completeColor: Colors.blueAccent,
completePercent: _calcLeftTimePersent(),
width: 8.0),
child: Container(
padding: EdgeInsets.all(10),
height: circleSize,
width: circleSize,
child: RaisedButton(
color: Colors.blue,
shape: CircleBorder(),
child: Row(
children: <Widget>[
Icon(
Icons.directions_run,
size: textSize,
),
Text(
"${_getLeftTimePercentStr()}",
style: TextStyle(fontSize: textSize),
),
],
),
onPressed: () {},
),
),
),
],
),
);
}
...
String _getLeftTimePercentStr() {
if (expectedDeathDate == null) {
return "- %";
}
return _calcLeftTimePersent().toStringAsFixed(8) + "%";
}
double _calcLeftTimePersent() {
if (expectedDeathDate == null) {
return 0.0;
}
Duration livingDuration = expectedDeathDate.difference(now);
Duration averageDeathDuration =
Duration(days: 30652, hours: 16, minutes: 48);
int livingDurationInMillis = livingDuration.inMilliseconds;
int averageDeathDurationInMillis = averageDeathDuration.inMilliseconds;
return livingDurationInMillis * 100 / averageDeathDurationInMillis;
}
...
_buildRadialProgress
関数ではCustomPaintを使用して図を描画しています。foregroundPainter
で指定しているLifeTimerPainter
はCustomPainterを継承した自作クラスです。新たなフォルダとファイル名で新クラスを作成します。今回はwidgets/lifetimer_painter.dart
という名前にします。以下のようにソースコードを実装してください。
import 'package:flutter/material.dart';
import 'dart:math';
class LifeTimerPainter extends CustomPainter {
Color lineColor;
Color completeColor;
double completePercent;
double width;
LifeTimerPainter(
{this.lineColor, this.completeColor, this.completePercent, this.width});
@override
void paint(Canvas canvas, Size size) {
Paint line = Paint()
..color = lineColor
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..strokeWidth = width;
Paint complete = Paint()
..color = completeColor
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke
..strokeWidth = width;
Offset center = Offset(size.width / 2, size.height / 2);
double radius = min(size.width / 2, size.height / 2);
canvas.drawCircle(center, radius, line);
double arcAngle = 2 * pi * (completePercent / 100);
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), -pi / 2,
arcAngle, false, complete);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}
以上で実装は完了です。最終的な画面は次のようになります。

まとめ
基本的な機能とWidgetを中心に作成してきましたが、Flutterで頻繁に登場する多くのWidgetを利用できるハンズオンになっているのではないかと思います。今回登場したWidgetには公式ページへのリンクを記載しています。Flutterは公式サイトの解説が充実しているため、これから本格的にスマホアプリを作る際には、ぜひご一読することをおすすめします。
ここまで読んでいただきありがとうございました。
ソースコードはGitHubで公開しています。
https://github.com/shunp/lifetimer_handson