# 不專業教學系列 - 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的小夥伴們。 基本上長的就像下方圖片中的模樣: ![app_main_page_ui](https://hackmd.io/_uploads/HyNIZVWDR.png) 以及新增事項的畫面: ![app_add_todo_page_ui](https://hackmd.io/_uploads/rkY_-VbPC.png) 實際畫面: ![todo_app_demo](https://hackmd.io/_uploads/BJQQE4-PA.gif) 畫面陽春了點,但還是可以用的OUOb ## 第一步 新增專案 >這裡我使用VS Code,如果你使用的是其他IDE操作可能有點不同。 在VS Code中同時按下`Ctrl`+`shift`+`p` 或是 `F1`來開啟命令面板,輸入`Flutter`後點選`Flutter: New Project`: ![image](https://hackmd.io/_uploads/SyQdI4ZwA.png) 接下來選取`Empty Application`來建立一個空白專案: ![image](https://hackmd.io/_uploads/Bkbi8NZDR.png) 這邊順便簡單介紹不同選項的功能: - 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/)查詢喔~ ## 第三步 了解專案架構 專案產生完成後,就可以看到完整的架構了,沒意外會跟下面圖片差不多: ![image](https://hackmd.io/_uploads/B1u6u4-DC.png) 左側是專案目錄,右側就是你的程式碼啦~ 基本上,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`來執行看看! 可能會出現下列選項,選一個你喜歡的模擬器即可: ![image](https://hackmd.io/_uploads/Hy-dR4-wC.png) 我是選Windows XD 執行結果: ![image](https://hackmd.io/_uploads/B1BgyrWvA.png) ## 第四步 開始寫程式,讓畫面動起來! 由於清單是動態產生的,所以需要先更改一下程式碼,讓畫面是在`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是學習一個新框架時很好的練習題目,可以充分學習到很多必需的技能,正是這樣才有了這篇文章,如果有任何問題都可以在下方留言,我會盡可能地回覆,謝謝觀看!