# 不專業教學系列 - Flutter To-Do List 第一集 萬丈高樓平地起!
>tag: `Flutter`、`Dart`
> 前情提要: 由於本人健忘,所以需要一個待辦事項清單來提醒我記得某些事情,於是乎不久前就做了個Win Form版本的To-Do List([詳情請看GitHub](https://github.com/allian000/OnTopTodo)),在我快快樂樂地分享給好大哥時,他突然問我: "為啥不用Flutter做?" ,於是兩個禮拜後想偷懶寫一點垃圾code的我終於完成了Flutter版本的To-Do List了。
> 要使用完整程式碼的話可以直接去最下方的[測試 App 與完整程式碼](#測試-App-與完整程式碼)
## 正文開始
今天要做的 To-Do app難度大約兩顆星,很適合剛學Flutter的小夥伴們。
基本上長的就像下方圖片中的模樣:

以及新增事項的畫面:

實際畫面:

畫面陽春了點,但還是可以用的OUOb
## 第一步 新增專案
>這裡我使用VS Code,如果你使用的是其他IDE操作可能有點不同。
在VS Code中同時按下`Ctrl`+`shift`+`p` 或是 `F1`來開啟命令面板,輸入`Flutter`後點選`Flutter: New Project`:

接下來選取`Empty Application`來建立一個空白專案:

這邊順便簡單介紹不同選項的功能:
- Application: 一個完整架構,包含測試與註解的專案。
- Empty Application: 相對簡潔的專案架構,移除了註解與測試。
- Skeleton Application: 骨架專案,可以拿來練習社群上的教學文章用。
- 既然你會看我的文章,那下面三個選項可能還不適合你,所以先跳過XD
選取`Empty Application`後就會讓你選一個目錄來產生你的專案,記得輸入專案名稱喔!
## 第二步 必備知識
在開始寫程式之前,有幾件事你必須要知道,在Flutter中有幾個很重要的觀念:
- Widget 是一切: 在Flutter中,所有的元件都是 Widget。從基本的文本和按鈕到複雜的布局和動畫,都是由不同的 Widget 組成。
- 狀態管理: Flutter中的 Widget 是有狀態的或者無狀態的。有狀態的 Widget 在狀態改變時會重新構建自己,因此需要適當的狀態管理來保存和更新數據,以避免意外的 UI 錯誤或性能問題。
- Widget Tree 和 Element Tree: Flutter 使用 Widget Tree 和 Element Tree 來管理和渲染 UI。Widget Tree 描述了 UI 的結構和外觀,而 Element Tree 實際上負責渲染和更新 Widget。
現在大概有個概念就夠了,更具體的可以去[Flutter官方文件](https://docs.flutter.dev/)查詢喔~
## 第三步 了解專案架構
專案產生完成後,就可以看到完整的架構了,沒意外會跟下面圖片差不多:

左側是專案目錄,右側就是你的程式碼啦~
基本上,App的程式碼包括畫面與邏輯都會放在`lib`資料夾下,未來根據你設計的架構可能會有改動,但目前就先簡單了解就好。
==請注意,隨著專案的負責度上升,或是你本身的經驗積累,你可能會將專案中不同的功能進行分類,本文章為了降低複雜度所以才放在同個檔案中!==
可以看到右側程式碼中有一段如下:
```dart
void main() {
runApp(const MainApp());
}
```
這就是你整支App的進入點,所以不要隨便更改檔案位置或是有奇怪的操作,除非你去設定你的App從哪開XD
接下來是:
```dart
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: Text('Hello World!'),
),
),
);
}
}
```
這就是一支可以執行的程式了,不妨先按下`F5`來執行看看!
可能會出現下列選項,選一個你喜歡的模擬器即可:

我是選Windows XD
執行結果:

## 第四步 開始寫程式,讓畫面動起來!
由於清單是動態產生的,所以需要先更改一下程式碼,讓畫面是在`StatefulWidget`中被建構
```dart
void main() {
runApp(MainApp());
}
class MainApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: MainScreen(),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<StatefulWidget> createState() {
return MainState();
}
}
class MainState extends State<MainScreen> {
@override
void initState() {
super.initState();
init();
}
void init() {
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child:Text("Hello World!")),
);
}
}
```
可以看到我們最終的畫面是在繼承MainScreen也就是StatefulWidget的狀態,這讓我們可以動態渲染UI,太好了OUOb。
接下來先定義一下待辦事項的結構,我的想法很簡單,就是事項名稱、事項內容與是否完成,變成程式碼:
```dart
class TodoData {
// 宣告屬性
String title;
String content;
bool isChecked;
// 建構器
TodoData({
required this.title,
required this.content,
required this.isChecked,
});
}
```
接下來如果要新增一筆待辦事項,只需要建立一個新的`TodoData`物件,並且將內容給填進去就好:
```dart
// 實作TodoData物件
TodoData todo = TodoData(
title: _todoTitleController.text,
content: _todoContentController.text,
isChecked: false,
);
```
有了待辦事項的物件,那我們就要呈現出來!
所以我們可以寫一個新的`Widget`叫做`_showTodos`來幫我們自動產生畫面,這時候就可以使用ListView小部件來根據清單產生內容,讓我們先宣告一個清單`todoList`,方便ListView來用:
```dart
final List<TodoData> todoList = [];
```
這清單將會存放未來的所有的待辦事項!
開始寫ListView,程式細節都在註解中了XD:
```dart
Widget _showTodos() {
return ListView.builder(
// 產生清單得長度根據todoList來決定
itemCount: todoList.length,
itemBuilder: (BuildContext context, int index) {
// 因為要包含ListTitl與下橫線,所以用Column包起來!
return Column(
children: <Widget>[
ListTile(
// 待辦事項名稱,可以看到是根據index從todoList裡面取出物件的內容來建構ListTitle! 同時設定文字大小與粗體
title: Text(todoList[index].title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
// 待辦事項內容,文字設定與上方標題類似
subtitle: Text(todoList[index].content, style: const TextStyle(fontSize: 16)),
// leading,就是在ListTitle前方的位置,這裡我們放勾勾小部件XD
leading: Checkbox(
// 被勾選與未勾選的呈現顏色
activeColor: Colors.blueGrey.shade600,
checkColor: Colors.white,
// 勾選框變成圓形,因為設置了角度
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
// 呈現勾選與否就是看value的狀態,是布林值
value: todoList[index].isChecked,
// 當被勾選/取消勾選
onChanged: (bool? value) {
// 設置狀態,使UI重新建構
setState(() {
// 改變物件狀態
todoList[index].isChecked = value!;
});
},
),
// 長在ListTitle後面的位置,放了一個標誌按鈕,被按下後會移除該元素
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.blueGrey),
onPressed: () {
setState(() {
todoList.removeAt(index);
});
},
),
),
// 這是一個橫線的物件
const Divider(),
],
);
},
);
}
```
寫好了ListView,需要讓它被調取:
```dart
@override
Widget build(BuildContext context) {
return Scaffold(
body: _showTodos(),
);
}
```
接下來可以在`initState()`中調用我們自己加的`init()`來幫`todoList`增加一些元素,方便我們觀察程式有沒有正確運行。
```dart
@override
void initState() {
super.initState();
init();
}
void init() {
TodoData todo =
TodoData(title: "title", content: "content", isChecked: false);
todoList.add(todo);
}
```
## 第五步 我需要有個按鈕來增加事項
為了能夠增加事項,我們會需要一個按鈕來跳出畫面,並且在畫面中可以輸入事項名稱與內容,有點麻煩,讓我們一步一步來。
首先,新增一個浮動按鈕:
```dart
@override
Widget build(BuildContext context) {
return Scaffold(
body: _showTodos(),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.playlist_add_rounded, color: Colors.white),
),
);
}
```
接下來,需要在按鈕被按下時跳出Dialog,裡面會有兩個文字框來讓我們輸入:
```dart
floatingActionButton: FloatingActionButton(
onPressed: () {
// 跳出對話框
showDialog(
context: context,
builder: (context) {
return AlertDialog(
// 標題
title: const Text("新增待辦事項",
style: TextStyle(fontWeight: FontWeight.bold)),
// 內容
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
// 控制器
controller: _todoTitleController,
decoration: const InputDecoration(hintText: "待辦事項名稱"),
),
TextField(
// 控制器
controller: _todoContentController,
decoration: const InputDecoration(hintText: "待辦事項內容"),
),
],
),
// 按鈕
actions: <Widget>[
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey.shade600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
onPressed: () {
// 關閉對話框
Navigator.pop(context);
// 清空輸入框
_todoTitleController.clear();
_todoContentController.clear();
},
child: const Text(
"取消",
style: TextStyle(color: Colors.white),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey.shade600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
onPressed: () {
// 新增待辦事項
TodoData todo = TodoData(
title: _todoTitleController.text,
content: _todoContentController.text,
isChecked: false,
);
setState(() {
// 新增待辦事項至清單
todoList.add(todo);
// 清空輸入框
_todoTitleController.clear();
_todoContentController.clear();
});
Navigator.pop(context);
},
// 按鈕文字
child: const Text(
"新增",
style: TextStyle(color: Colors.white),
),
),
],
);
},
);
},
child: const Icon(Icons.playlist_add_rounded, color: Colors.white),
),
```
可以發現有兩個很莫名其妙的東西出現了,就是
- `_todoTitleController`
- `_todoContentController`
這是文字輸入框的控制器,可以從這控制器中取得輸入框的文字
為了要取得輸入框的文字,需要先去宣告控制器:
```dart
// title 的控制器
final TextEditingController _todoTitleController = TextEditingController();
// content 的控制器
final TextEditingController _todoContentController = TextEditingController();
```
然後在TextField中設定控制器:
```dart
TextField(
// 控制器
controller: _todoTitleController,
decoration: const InputDecoration(hintText: "待辦事項名稱"),
),
```
接下來就可以操作控制器:
```dart
// 取得文字
_todoTitleController.text;
// 清除輸入框
_todoTitleController.clear();
```
到了這裡,你已經可以新增與刪除待辦事項,距離完成專案只剩下幾步驟了!
## 第六步 移除已勾選的完成事項
基本功能已經完成了,接下來就是要將已完成的事項移除,為了達到這個目的,我們會需要一個按鈕來操作。
首先,新增一個按鈕的小部件:
```dart
Widget _complectedButton() {
return Padding(
// Padding widget 是用來設定元件的間距
padding: const EdgeInsets.only(top: 10, bottom: 22),
// ElevatedButton 是一個有陰影的按鈕
child: ElevatedButton(
// 設定按鈕的樣式
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey.shade600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
// 設定按鈕的最小和最大尺寸
minimumSize: const Size(130, 50),
maximumSize: const Size(130, 50),
),
onPressed: () {
// 從todoList中清除完成事項
setState(() {
// removeWhere 是一個用來移除符合條件的元素的方法
todoList.removeWhere((item)=>item.isChecked==true);
});
},
child: const Text(
"清除完成事項",
style: TextStyle(color: Colors.white, fontSize: 12),
)),
);
}
```
然後在`body`中加上剛才定義好的按鈕小部件:
```dart
body: Column(
children: <Widget>[
Expanded(
child: _showTodos(),
),
_complectedButton(),
],
),
```
順便更改一下排版,加上了`Column`
## 測試 App 與完整程式碼
完成了以上所有步驟,你應該會得到以下的程式碼:
```dart
import 'package:flutter/material.dart';
void main() {
runApp(MainApp());
}
class MainApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: MainScreen(),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<StatefulWidget> createState() {
return MainState();
}
}
class MainState extends State<MainScreen> {
final TodoData todo =
TodoData(title: "title", content: "content", isChecked: false);
final List<TodoData> todoList = [];
final TextEditingController _todoTitleController = TextEditingController();
final TextEditingController _todoContentController = TextEditingController();
@override
void initState() {
super.initState();
init();
}
void init() {
todoList.add(todo);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: <Widget>[
Expanded(
child: _showTodos(),
),
_complectedButton(),
],
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.blueGrey.shade600,
onPressed: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text("新增待辦事項",
style: TextStyle(fontWeight: FontWeight.bold)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: _todoTitleController,
decoration: const InputDecoration(hintText: "待辦事項名稱"),
),
TextField(
controller: _todoContentController,
decoration: const InputDecoration(hintText: "待辦事項內容"),
),
],
),
actions: <Widget>[
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey.shade600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
onPressed: () {
Navigator.pop(context);
_todoTitleController.clear();
_todoContentController.clear();
},
child: const Text(
"取消",
style: TextStyle(color: Colors.white),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey.shade600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
),
onPressed: () {
TodoData todo = TodoData(
title: _todoTitleController.text,
content: _todoContentController.text,
isChecked: false,
);
setState(() {
todoList.add(todo);
_todoTitleController.clear();
_todoContentController.clear();
});
Navigator.pop(context);
},
child: const Text(
"新增",
style: TextStyle(color: Colors.white),
),
),
],
);
},
);
},
child: const Icon(Icons.playlist_add_rounded, color: Colors.white),
),
);
}
Widget _showTodos() {
return ListView.builder(
itemCount: todoList.length,
itemBuilder: (BuildContext context, int index) {
return Column(
children: <Widget>[
ListTile(
title: Text(todoList[index].title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
subtitle: Text(todoList[index].content, style: const TextStyle(fontSize: 16)),
leading: Checkbox(
activeColor: Colors.blueGrey.shade600,
checkColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
value: todoList[index].isChecked,
onChanged: (bool? value) {
setState(() {
todoList[index].isChecked = value!;
});
},
),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.blueGrey),
onPressed: () {
setState(() {
todoList.removeAt(index);
});
},
),
),
const Divider(),
],
);
},
);
}
Widget _complectedButton() {
return Padding(
padding: const EdgeInsets.only(top: 10, bottom: 22),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey.shade600,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
minimumSize: const Size(130, 50),
maximumSize: const Size(130, 50),
),
onPressed: () {
setState(() {
todoList.removeWhere((item)=>item.isChecked==true);
});
},
child: const Text(
"清除完成事項",
style: TextStyle(color: Colors.white, fontSize: 12),
)),
);
}
}
class TodoData {
String title;
String content;
bool isChecked;
TodoData({
required this.title,
required this.content,
required this.isChecked,
});
}
```
接著按下`F5`來執行偵錯模式,你就可以實現上方的Demo了!
## 結語
這是我第一次寫Flutter相關文章,所以稍微囉嗦點了,文章的敘述幾乎是照著我自己寫程式時的思考順序,可能沒法讓所有人都快速簡單的吸收,先在這說抱歉><,其實To-Do List是學習一個新框架時很好的練習題目,可以充分學習到很多必需的技能,正是這樣才有了這篇文章,如果有任何問題都可以在下方留言,我會盡可能地回覆,謝謝觀看!