Flutter学习(五)——实践Todo-list(2)

理论知识学习之后,马上来一些实践练习。可以对知识点的掌握更加透彻哦。本文在之前实践项目 Todo-list 的基础上,增加前面学习的路由与导航相关的知识。

数据准备

TodoModel

更新之前创建的 TodoModel 模型。增加了 belongTo 属性,定位其属于哪个清单。

1
2
3
4
5
6
7
8
9
10
11
12
13
class TodoModel {
String title; // 标题
String subTitle; // 描述
bool isChecked; // 是否完成
String belongTo; // 属于哪个列表,用于后续扩展 Todo-list 功能

TodoModel({
required this.belongTo,
this.title = "",
this.subTitle = "",
this.isChecked = false
});
}

AppStorage

创建了一个存储单例类,用于管理所有的 Todo 数据。

内置了 Today 和 Inbox 两个清单,每个清单下拥有自己的 Todos。本文暂不考虑切换清单的操作,只是在 Today 清单下,进行 Todo 的添加和已完成的变化。本文主要还是关注页面传值和跳转。

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
class AppStorage {
AppStorage._internal();

factory AppStorage() => _instance;

static late final AppStorage _instance = AppStorage._internal();

String currentListType = "Today";
List<String> showList = ["Today", "Inbox"];
Map<String, List<TodoModel>> showDatas = <String, List<TodoModel>>{"Today": [], "Inbox": []};

// 返回当前的所有 Todos。并做了一个分组,将新增的
List currentList() {
List<TodoModel> list = showDatas[currentListType] ?? [];
List<TodoModel> todos = List.empty(growable: true);
List<TodoModel> done = List.empty(growable: true);
for (TodoModel item in list) {
if (item.isChecked) {
done.add(item);
} else {
todos.add(item);
}
}
List result = List.empty(growable: true);
result.addAll(todos);
if (done.isNotEmpty) {
result.add("已完成");
result.addAll(done);
}
return result;
}

void addListType(String listType) {
showList.add(listType);
}
// 增加一个 Todo
void addTodo(TodoModel model) {
showDatas[currentListType]?.insert(0, model);
}
}

颜色扩展

增加了一个颜色管理的类,主要用于管理本项目中用到的一些颜色。

增加了一个 Color 转 MaterialColor 的方法,用于主体颜色设置。

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
class CustomColors {
/// 主体色
static final MaterialColor themeColor = _createMaterialColor(const Color(0xFF000000));

/// 主背景色
static const Color mainBgColor = Color(0xFF000000);

/// 第二背景色
static const Color secondBgColor = Color(0xFF101010);

/// 副标题颜色
static const Color subTitleColor = Color(0xFFA8A8A8);

/// 复选框颜色 -- 图标色
static const Color checkBoxColor = Color(0xFFA8A8A8);

/// 橙色
static const Color orangeColor = Color(0xFFFB9909);

/// 工具方法,生成 MaterialColor
static MaterialColor _createMaterialColor(Color color) {
List strengths = <double>[.05];
Map<int, Color> swatch = <int, Color>{};
final int r = color.red, g = color.green, b = color.blue;

for (int i = 1; i < 10; i++) {
strengths.add(0.1 * i);
}
for (var strength in strengths) {
final double ds = 0.5 - strength;

int key = (strength * 1000).round();
Color color = Color.fromRGBO(
r + ((ds < 0 ? r : (255 - r)) * ds).round(),
g + ((ds < 0 ? g : (255 - g)) * ds).round(),
b + ((ds < 0 ? b : (255 - b)) * ds).round(),
1,
);
swatch[key] = color;
}
return MaterialColor(color.value, swatch);
}
}

路由表注册

路由表注册,用于后续页面使用 命名路由 跳转页面。

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
void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: CustomColors.themeColor,
backgroundColor: Colors.black12
),
initialRoute: "/", //名为"/"的路由作为应用的home(首页)
routes: _managerRoutes()
);
}

Map<String, WidgetBuilder> _managerRoutes() {
return {
"/":(context) => const MyHomePage(title: 'Todo-list'), //注册首页路由
"create":(context) => const CreateTodoPage(), //新建Todo
};
}
}

首页改动

首页 build 方法

  • 首页标题

    使用 AppStorage().currentListType 存储的当前清单标题。默认是 Today。

  • 点击事件替换

    使用命名路由跳转,并传递一个属于当前清单下的 TodoModel 的实例对象给后续新建 Todo 的页面使用。还对页面返回时,返回的 Todo 做了一个判断,决定是否刷新页面展示。

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
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppStorage().currentListType),
),
body: Container(
padding: const EdgeInsets.fromLTRB(0, 10, 15, 10),
color: CustomColors.themeColor,
child: _buildList()
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Navigator.of(context).pushNamed("create", arguments: TodoModel(belongTo: AppStorage().currentListType))
.then((value) {
TodoModel model = value as TodoModel;
if (model.title.isNotEmpty || model.subTitle.isNotEmpty) {
setState(() {
AppStorage().addTodo(model);
});
}
});
},
tooltip: 'Increment',
backgroundColor: CustomColors.orangeColor,
child: const Icon(Icons.add),
),
);
}
...省略代码
}

buildList 改动

将数据源替换为 APPStorage 中管理的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Widget _buildList() {
return ListView.separated(
separatorBuilder: (context, index) => const Divider(
thickness: 1,
height: 1,
color: CustomColors.mainBgColor,
),
itemCount: AppStorage().currentList().length,
itemBuilder: (context, i) {
// 判断类型,返回 Todo 展示或者是 已完成 的组头
var item = AppStorage().currentList()[i];
if (item is TodoModel) {
return _buildItem(AppStorage().currentList()[i]);
} else {
return _buildHeader(item);
}
}
);
}

buildItem 改动

buildItem 的数据源本就是来自 _buildList 中的数据源,所以不需要任何改动即可使用。这里仅仅是给展示的文字增加了一个判断,展示一个默认文字。

1
2
3
4
5
6
Text(
model.title.length > 0 ? model.title : "无标题"
)
Text(
model.subTitle.length > 0 ? model.subTitle : "无描述"
)

新增 _buildHeader

增加了一个方法,用于列表分组,展示已完成的分组。这个方法返回的就是已完成分组的Header。

1
2
3
4
5
6
7
8
9
10
11
12
13
Widget _buildHeader(String title) {
return Container(
padding: EdgeInsets.only(left: 10),
child: Text(
title,
style: const TextStyle(
color: CustomColors.subTitleColor,
fontSize: 14,
fontWeight: FontWeight.normal
),
),
);
}

新建 Todo

需求梳理

  • 属性存储上个页面传参的 TodoModel 实例对象。
  • 增加两个 TextField 用于输入。一个用于输入标题,另一个用于输入内容。
  • 返回时,需要返回一个 TodoModel 实例对象。

页面搭建

  1. FocusNode、TextEditingController 与 TextField 组件相关,具体使用自行去查看文档。
  2. 在 build 中获取传参的 TodoModel 实例对象。用于页面展示的数据源。
  3. 利用 WillPopScope 拦截导航栏返回事件。回传 TodoModel 实例对象。
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
class CreateTodoPage extends StatefulWidget {
const CreateTodoPage({
Key? key
}) : super(key: key);

@override
State<CreateTodoPage> createState() => _CreateTodoPageState();
}

class _CreateTodoPageState extends State<CreateTodoPage> {

FocusNode _titleFocusNode = FocusNode();
FocusNode _descFocusNode = FocusNode();

late TextEditingController _titleController;
late TextEditingController _descController;

TodoModel? todo;

@override
void initState() {
super.initState();

_titleController = TextEditingController();
_titleController.text = todo?.title ?? "";

_descController = TextEditingController();
_descController.text = todo?.subTitle ?? "";
}

@override
void dispose() {
super.dispose();

_titleController.dispose();
_descController.dispose();
}

@override
Widget build(BuildContext context) {

todo = ModalRoute.of(context)?.settings.arguments as TodoModel;

return WillPopScope(
onWillPop: (){
FocusScope.of(context).requestFocus(_titleFocusNode);
FocusScope.of(context).requestFocus(_descFocusNode);

_saveTodo();
Navigator.pop(context, todo);
return Future.value(false);
},
child: Scaffold(
appBar: AppBar(
title: Text(todo?.belongTo ?? ""),
),
body: Container(
color: CustomColors.mainBgColor,
padding: EdgeInsets.all(10),
child: Column(
children: [
TextField(
controller: _titleController,
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w400
),
maxLines: 1,
keyboardAppearance: Brightness.dark,
textInputAction: TextInputAction.newline,
cursorColor: CustomColors.orangeColor,
decoration: InputDecoration(
hintText: "请输入标题",
hintStyle: TextStyle(
color: CustomColors.subTitleColor,
fontSize: 18,
fontWeight: FontWeight.w400
),
contentPadding: EdgeInsets.all(10.0),
enabledBorder:OutlineInputBorder(
borderRadius: BorderRadius.circular(0.0),
borderSide: BorderSide(color: Colors.transparent, width: 0, style: BorderStyle.solid)),
focusedBorder:OutlineInputBorder(
borderRadius: BorderRadius.circular(0.0),
borderSide: BorderSide(color: Colors.transparent, width: 0, style: BorderStyle.solid)),
),
onChanged: _onChanged,
onEditingComplete: _onEidtingComplete,
onSubmitted: _onSubmitted,
),
TextField(
controller: _descController,
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.normal
),
minLines: 6,
maxLines: 12,
keyboardAppearance: Brightness.dark,
textInputAction: TextInputAction.newline,
cursorColor: CustomColors.orangeColor,
decoration: InputDecoration(
hintText: "描述",
hintStyle: TextStyle(
color: CustomColors.subTitleColor,
fontSize: 14,
fontWeight: FontWeight.normal
),
contentPadding: EdgeInsets.all(10.0),
enabledBorder:OutlineInputBorder(
borderRadius: BorderRadius.circular(0.0),
borderSide: BorderSide(color: Colors.transparent, width: 0, style: BorderStyle.solid)),
focusedBorder:OutlineInputBorder(
borderRadius: BorderRadius.circular(0.0),
borderSide: BorderSide(color: Colors.transparent, width: 0, style: BorderStyle.solid)),
),
onChanged: _onChanged,
onEditingComplete: _onEidtingComplete,
onSubmitted: _onSubmitted,
),
],
),
),
),
);
}

void _saveTodo() {
String title = _titleController.text;
String desc = _descController.text;

if(todo != null) {
todo?.title = title;
todo?.subTitle = desc;
} else {
TodoModel model = TodoModel(belongTo: "", title: title, subTitle: desc);
todo = model;
}
}
}

组装完成

好了,现在所有步骤都已经完成了,保存并重新运行程序,就可以得到一个和开篇时一样的项目了。

项目Todo

  • 增加点击查看当前 Todo
  • 持久化存储
  • 利用抽屉视图实现清单功能
    • 展示清单
    • 管理清单
    • 根据清单切换展示列表

列一个表,督促自己持续学习。