案例介绍

起初我们来到这个世界, 是因为我们不得不来, 最后我们离开这个世界, 是因为我们不得不走. 出生和死亡都是我们没有把握的事情, 但是我们能把握的是从出生到死亡这段时间. 中国目前的人均寿命是 76.25 年, 算上程序员通宵透支的生命, 大约是 75 年, 也就是 30 * 30 个月. 本案例是用 Flutter 记录并计算生命的剩余时间:

APP 首页 时间选择器

项目地址: https://github.com/zhouzaihang/life_countdown

实现思路

  • 页面分为三块, 第一部分显示时间, 第二部分显示时间格子, 第三部分显示剩余天数
APP 首页
  • 在第一部分右边设置一个按钮, 点击时弹出时间选择器更改出生时间
时间选择器

代码

完成的代码见 Github

life.dart

首先新建 life.dart , 编写第一个类 Life. 这个类主要有三个功能:

  • 根据生日自动和当前时间计算逝去的生命和剩余的岁月

  • 使用 SharedPreferences 存储数据到本地, 方便下次打开应用的时候自动从本地读取数据

  • 使用 ScopedModel 作为状态管理, 在生日修改后, 更新所有相关的组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import 'package:scoped_model/scoped_model.dart';
import 'package:shared_preferences/shared_preferences.dart';

class Life extends Model {
int life = 900;
String _key;
DateTime _birthDay = DateTime(2000, 1, 1);

DateTime get birthDay => _birthDay;

set birthDay(DateTime value) {
_birthDay = value;
_setBirthday();
notifyListeners();
}

static int dateDifference(DateTime date1, DateTime date2) {
int result = date1.day - date2.day >= 0 ? 0 : -1;
return (date1.year - date2.year) * 12 + date1.month - date2.month + result;
}

Life(String key) {
this._key = key;
_initBirthday();
}

int pastLife() {
return dateDifference(DateTime.now(), this._birthDay);
}

int remainingLife() {
return life - pastLife();
}

Future _initBirthday() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
String birth = prefs.getString(_key) ?? null;
if (birth != null) {
this._birthDay = DateTime.parse(birth);
}
notifyListeners();
}

Future<bool> _setBirthday() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
return prefs.setString(_key, _birthDay.toString());
}
}

构建首页

首先分析下首页的页面可以使用 MaterialScaffold 脚手架组件构建, 第一部分使用 APPBar, 第二三部分作为 body 部分. 另外在上面构建life 类的时候, 用到了 ScopedModel 进行状态管理. 所以这里在所有组件的根节点上先创一个 ScopedModel 组件, 使得后面的所有组件都能够直接通过 model 内的 life 对象且, 当对应内容修改时, 能够自动更新视图. 关于状态管理组件 ScopedModel 介绍参考这篇文章 ScopedModel 和官方文档.

具体实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import 'package:flutter/material.dart';
import 'package:life_countdown/life.dart';
import 'package:scoped_model/scoped_model.dart';

void main() {
runApp(ScopedModel(
child: MyApp(),
model: Life("me"),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Life Countdown',
theme: ThemeData(
primaryColor: Colors.white,
),
home: HomePage(),
);
}
}

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

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

class _HomePageState extends State<HomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Padding(),
);
}
}

构建 AppBar

第一部分主要是一个居中的日期和一个按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
appBar: AppBar(
title: Text(
DateTime.now().toString().substring(0, 10),
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
fontFamily: "Barriecito"),
),
centerTitle: true,
elevation: 0,
actions: <Widget>[
IconButton(
icon: Icon(Icons.settings),
onPressed: () {
showDefaultYearPicker(context, ScopedModel.of<Life>(context));
})
],
),

这里的字体是自定义的, Flutter 使用自定义字体的方法:

  1. 将文件放在项目目录下, 例如项目的 asset 下:

资源

  1. pubspec.yaml 下编写资源名字和相对路径:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
flutter:
assets:
- asset/iron_man.png
# - images/a_dot_ham.jpeg

# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.

# For details regarding adding assets from package dependencies, see
# https://flutter.dev/assets-and-images/#from-packages

# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
fonts:
- family: Barriecito
fonts:
# https://fonts.google.com/specimen/Barriecito?selection.family=Barriecito
- asset: asset/Barriecito-Regular.ttf
  1. 在相应的地方使用 fontFamily 调用:
1
2
3
4
5
6
Text(
"zhzh.xyz",
style: TextStyle(
fontWeight: FontWeight.bold,
fontFamily: "Barriecito"),
),

添加时间选择器

第一部分右边的按钮是可以弹出时间选择器进行生日设置并保存且更新视图, Flutter 内置了时间选择器, 可以使用 showDatePicker 构建, 完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void showDefaultYearPicker(BuildContext context, Life life) async {
final DateTime dateTime = await showDatePicker(
context: context,
initialDate: life.birthDay,
firstDate: DateTime(1950, 1),
lastDate: DateTime.now(),
builder: (BuildContext context, Widget child) {
return Theme(
data: ThemeData.dark(),
child: child,
);
},
);
if (dateTime != null && dateTime != life.birthDay) {
life.birthDay = dateTime;
}
}

构建 body

Body 由第二部分: 一个网格和第三部分: 一个表示剩余时间的 Text 组件.

网格组件

新建 grid.dart 文件, 然后创建一个有状态组件 Grid, 把 life 作为传入组件的必要参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:life_countdown/life.dart';

class Grid extends StatefulWidget {
Grid({Key key, @required this.life}) : super(key: key);

final Life life;

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

class _GridState extends State<Grid> {
@override
Widget build(BuildContext context) {
return Column(
children: rowList(),
);
}
}

设置一个 List 用于存放网格的随机颜色:

1
2
3
4
5
6
7
8
9
10
11
static List<int> colors = [
0xFFFFFFFF,
0xAAD4DFE6,
0xAA8EC0E4,
0xAACADBE9,
0xAA6AAFE6,
0xAAA5DFF9,
0xAAFEEE7D,
0xAAFAB1CE,
0xAAFFDA8E
];

网格是由一个一个小格子组成, 这里使用 Border 组件实现. 还需要定义一个根据对应的位置生成 Border 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static BorderSide _borderThin =
new BorderSide(width: 0.5, color: Color(0xFFE0E3DA));
static BorderSide _borderBold =
new BorderSide(width: 1.0, color: Color(0xFFE0E3DA));

Border generateBorder(int x, int y) {
BorderSide leftBorder = _borderThin;
BorderSide topBorder = _borderThin;
BorderSide rightBorder = _borderThin;
BorderSide bottomBorder = _borderThin;

if (x == 0) {
topBorder = _borderBold;
} else if (x == this.life.life / 30 - 1) {
bottomBorder = _borderBold;
}

if (y == 0) {
leftBorder = _borderBold;
} else if (y == 29) {
rightBorder = _borderBold;
}

return new Border(
left: leftBorder,
top: topBorder,
right: rightBorder,
bottom: bottomBorder,
);
}

网格可以看成是由 30 行组成, 每一行有 30border 组件, 先定义生成一行的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
List<Widget> rowDetail(rowIndex) {
List<Widget> result = new List<Widget>();
int pastMonth = widget.life.pastLife();
int month = rowIndex * 30;
for (int i = 0; i < 30; i++) {
int currentMonth = month + i;
result.add(Container(
width: width,
height: width,
decoration: BoxDecoration(
border: widget.generateBorder(rowIndex, i),
color: currentMonth < pastMonth
? Color(0xFF5E5E5F)
: currentMonth == pastMonth
? Colors.red
: currentMonth < pastMonth + 10
? Colors.white
: Color(Grid.colors[
rng.nextInt(18) > 8 ? 0 : rng.nextInt(9)])),
));
}
return result;
}

再定义生成所有行的代码:

1
2
3
4
5
6
7
8
9
List<Widget> rowList() {
List<Widget> result = new List<Widget>();
for (int i = 0; i < widget.life.life / 30; i++) {
result.add(Row(
children: rowDetail(i),
));
}
return result;
}

最后把生成的所有行使用 Column 组件包起来就OK了, 最后完整的 grid.dart 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:life_countdown/life.dart';

class Grid extends StatefulWidget {
Grid({Key key, @required this.life}) : super(key: key);

final Life life;

static List<int> colors = [
0xFFFFFFFF,
0xAAD4DFE6,
0xAA8EC0E4,
0xAACADBE9,
0xAA6AAFE6,
0xAAA5DFF9,
0xAAFEEE7D,
0xAAFAB1CE,
0xAAFFDA8E
];

static BorderSide _borderThin =
new BorderSide(width: 0.5, color: Color(0xFFE0E3DA));
static BorderSide _borderBold =
new BorderSide(width: 1.0, color: Color(0xFFE0E3DA));

Border generateBorder(int x, int y) {
BorderSide leftBorder = _borderThin;
BorderSide topBorder = _borderThin;
BorderSide rightBorder = _borderThin;
BorderSide bottomBorder = _borderThin;

if (x == 0) {
topBorder = _borderBold;
} else if (x == this.life.life / 30 - 1) {
bottomBorder = _borderBold;
}

if (y == 0) {
leftBorder = _borderBold;
} else if (y == 29) {
rightBorder = _borderBold;
}

return new Border(
left: leftBorder,
top: topBorder,
right: rightBorder,
bottom: bottomBorder,
);
}

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

class _GridState extends State<Grid> {
@override
Widget build(BuildContext context) {
final width = (MediaQuery.of(context).size.width - 2) / 30;
var rng = new Random();

List<Widget> rowDetail(rowIndex) {
List<Widget> result = new List<Widget>();
int pastMonth = widget.life.pastLife();
int month = rowIndex * 30;
for (int i = 0; i < 30; i++) {
int currentMonth = month + i;
result.add(Container(
width: width,
height: width,
decoration: BoxDecoration(
border: widget.generateBorder(rowIndex, i),
color: currentMonth < pastMonth
? Color(0xFF5E5E5F)
: currentMonth == pastMonth
? Colors.red
: currentMonth < pastMonth + 10
? Colors.white
: Color(Grid.colors[
rng.nextInt(18) > 8 ? 0 : rng.nextInt(9)])),
));
}
return result;
}

List<Widget> rowList() {
List<Widget> result = new List<Widget>();
for (int i = 0; i < widget.life.life / 30; i++) {
result.add(Row(
children: rowDetail(i),
));
}
return result;
}

return Column(
children: rowList(),
);
}
}

然后在 main.dart 里引用 grid.dart 代码, 并在调用 Text 组件实现第三部分功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
ScopedModelDescendant<Life>(
builder: (BuildContext context, Widget child, Life model) {
return Column(
children: <Widget>[
Grid(life: model),
Padding(
padding: const EdgeInsets.only(top: 40, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 40),
child: Container(
height: 48,
child: Image.asset("asset/iron_man.png")),
),
Text(
model.remainingLife().toString() +
" / " +
model.life.toString(),
style: TextStyle(
fontSize: 48, fontFamily: "Barriecito")),
],
),
),
Text(
"Stay Hungry \t Stay Foolish",
style: TextStyle(
fontFamily: "Barriecito",
fontWeight: FontWeight.bold,
fontSize: 24),
),
],
);
},
),

最后完整的 main.dart 文件的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
import 'package:flutter/material.dart';
import 'package:life_countdown/grid.dart';
import 'package:life_countdown/life.dart';
import 'package:scoped_model/scoped_model.dart';

void main() {
runApp(ScopedModel(
child: MyApp(),
model: Life("me"),
));
}

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Life Countdown',
theme: ThemeData(
primaryColor: Colors.white,
),
home: HomePage(),
);
}
}

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

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

class _HomePageState extends State<HomePage> {
void showDefaultYearPicker(BuildContext context, Life life) async {
final DateTime dateTime = await showDatePicker(
context: context,
initialDate: life.birthDay,
firstDate: DateTime(1950, 1),
lastDate: DateTime.now(),
builder: (BuildContext context, Widget child) {
return Theme(
data: ThemeData.dark(),
child: child,
);
},
);
if (dateTime != null && dateTime != life.birthDay) {
life.birthDay = dateTime;
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
DateTime.now().toString().substring(0, 10),
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
fontFamily: "Barriecito"),
),
centerTitle: true,
elevation: 0,
actions: <Widget>[
IconButton(
icon: Icon(Icons.settings),
onPressed: () {
showDefaultYearPicker(context, ScopedModel.of<Life>(context));
})
],
),
body: Padding(
padding: const EdgeInsets.all(1.0),
child: ScopedModelDescendant<Life>(
builder: (BuildContext context, Widget child, Life model) {
return Column(
children: <Widget>[
Grid(life: model),
Padding(
padding: const EdgeInsets.only(top: 40, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: EdgeInsets.only(right: 40),
child: Container(
height: 48,
child: Image.asset("asset/iron_man.png")),
),
Text(
model.remainingLife().toString() +
" / " +
model.life.toString(),
style: TextStyle(
fontSize: 48, fontFamily: "Barriecito")),
],
),
),
Text(
"Stay Hungry \t Stay Foolish",
style: TextStyle(
fontFamily: "Barriecito",
fontWeight: FontWeight.bold,
fontSize: 24),
),
],
);
},
),
),
);
}
}