# 使用 flutter 建構 Meals app 原始碼連結:<https://github.com/WangShuan/flutter-04-meals> 該專案主要學習如何於多個屏幕之間切換並傳遞數據,比如在入口屏幕左上角的抽屜中,可點擊進入『所有食譜』及『篩選器』屏幕、底部的 tabs 欄位可於『所有類別』及『喜愛項目』屏幕之間切換、在所有類別屏幕中,可點擊分類進入『食譜列表』屏幕、在食譜列表屏幕中,可點擊食譜進入『食譜細節』屏幕。 ## 新小部件速覽 - `InkWell` 小部件: - 讓小部件可以化身按鈕,於點擊時產生漣漪樣式 - 可以綁定各種各樣的事件 - `Stack` 小部件: - 讓小部件之間可以上下堆疊 - `Positioned` 小部件: - 通常父層會搭配 `Stack` 小部件使用 - 設定好上下左右的絕對定位數值以放置小部件在需要的位置 - `FadeInImage` 小部件: - 用來讓圖片產生載入的淡入淡出過場效果 - 通常搭配 `transparent_image` 套件使用 - `Drawer` 小部件: - 是用來做抽屜的小部件,其子部件搭配 `DrawerHeader` 小部件使用 - 放置在 `Scaffold` 小部件中,會在 `AppBar` 左側產生漢堡選單以開啟抽屜 - `ListTile` 小部件: - 生成帶有圖示、標題、副標題、事件綁定功能的精美小部件 - 常作為 `ListView` 的子部件使用,但也可以單獨使用 - `BottomNavigationBar` 小部件: - 用來在屏幕下方做出 `tabs` 以切換屏幕 - 其子部件搭配 `BottomNavigationBarItem` 小部件使用 - `GridView` 小部件: - 用來生成網格佈局,可設置一排幾個、上下間距、左右間距 - `Wrap` 小部件: - 用來讓小部件們可以在超過範圍時,自動換行排列 - `Hero` 小部件: - 製作過場動畫用的 - 當兩個屏幕中出現相同小部件時,可用 `Hero` 小部件包裹,這樣在切換兩個屏幕時即可透過 `Hero` 小部件產生過場 - `WillPopScope` 小部件: - 處理離開頁面後要觸發的事件 - 以本例來說,用來在用戶離開篩選器頁面時,可以正確傳遞與保存篩選狀態 - `SwitchListTile` 小部件: - 用來做出帶有 Switch 開關的 `ListTile` 小部件 ## 定義 Category 與 Meal 的藍圖 以分類來說,需要有 `id` 辨識、 `title` 顯示名稱、 `color` 設定背景色,所以藍圖應該如下: ```dart import 'package:flutter/material.dart'; class Category { const Category(this.id, this.title, this.color); final String id; final String title; final Color color; // 設置 color 類別為 Color,因 Color 是 material 提供所以需引入 material } ``` 以食譜來說,需要有 `id` 辨識、 `title` 顯示名稱、 `imgUrl` 呈現菜品、 `categories` 設置類別、 `ingredients` 顯示材料、 `steps` 顯示製作步驟、 `duration` 顯示製作時長、 `isGlutenFree` 判斷是否為無麩質飲食、 `isLactoseFree` 判斷是否為無乳糖食品、 `isVegan` 判斷是否為純素、 `isVegetarian` 判斷是否為蛋奶素,所以藍圖應該如下: ```dart class Meal { const Meal({ required this.id, required this.title, required this.categories, required this.imgUrl, required this.ingredients, required this.steps, required this.duration, required this.isGlutenFree, required this.isLactoseFree, required this.isVegan, required this.isVegetarian, }); final String id; final String title; final List<String> categories; // 屬於哪些分類 final String imgUrl; // 圖片連結 final List<String> ingredients; // 材料有哪些 final List<String> steps; // 製作步驟依序是啥 final String duration; // 製作時長 final bool isGlutenFree; // 無麩質飲食 final bool isLactoseFree; // 無乳糖食品 final bool isVegan; // 素食(純素) final bool isVegetarian; // 蔬食(蛋奶素) } ``` ## 製作主屏幕 主屏幕需顯示所有類別,且需帶有抽屜開關讓用戶可進入篩選器屏幕、還需有底部的 tabs 欄位用來在所有類別及喜愛項目屏幕之間切換,建立 `tabs_screen.dart` 如下: ```dart // 宣告 _activeIndex 用來保存當前選中的屏幕下標 int _activeIndex = 0; Scaffold( appBar: AppBar( title: Text(_activeIndex == 0 ? '所有類別' : '喜好項目'), // 判斷下標以切換 title 內容 ), drawer: MainDrawer(categories, selectedScreen), // 設置抽屜 bottomNavigationBar: BottomNavigationBar( // 設置底部 tabs onTap: (index) { // 設置點擊時觸發的事件 setState(() { // 保存當前選中的下標以切換顯示的屏幕 _activeIndex = index; }); }, items: const [ // 設置 tabs 有哪些 tab 按鈕 BottomNavigationBarItem( // 使用 BottomNavigationBarItem 小部件 icon: Icon(Icons.category_rounded), // 設置圖標 label: '所有類別', // 設置顯示標題 ), BottomNavigationBarItem( icon: Icon(Icons.favorite), label: '喜好項目', ), ], currentIndex: _activeIndex, // 切換當前選中下標,以觸發樣式變動 selectedItemColor: Theme.of(context).colorScheme.primary, // 設置選中的高亮顏色 ), body: _activeIndex == 0 // 判斷選中下標以切換顯示的屏幕 ? CategoriesScreen(filterMeals, toggleFavo) : MealsScreen('', _favoMeals, toggleFavo), ); ``` ### 抽屜小部件 首先在 `tabs_screen.dart` 中宣告 `selectedScreen` (切換屏幕用的方法),並將其傳給 `MainDrawer` 抽屜用的小部件: ```dart // 宣告 selectedScreen 切換屏幕用的方法 void selectedScreen(String routeName) async { Navigator.of(context).pop(); // 關閉抽屜 if (routeName == 'meals') { // 判斷點擊的是否為所有食譜 Navigator.of(context).push(MaterialPageRoute( builder: (context) => MealsScreen('所有食譜', meals, toggleFavo), )); } } // 把 selectedScreen 傳給 MainDrawer drawer: MainDrawer(selectedScreen), ``` 接著在 `main_drawer.dart` 中設置抽屜小部件: ```dart Drawer( child: Column( children: [ DrawerHeader( // 設置 DrawerHeader 小部件顯示抽屜的標題 child: Row( children: [ Icon(Icons.restaurant), const SizedBox(width: 16), Text("Meals") ], ), ), ListTile( // 設置切換屏幕用的按鈕 title: Text('所有食譜'), onTap: () { // 設置點擊事件 selectedScreen('meals'); // 點擊後觸發切換屏幕事件 }, ), ListTile( title: Text('篩選器'), onTap: () { selectedScreen('filters'); }, ) ], ), ); ``` ## 在屏幕之間傳遞篩選器狀態 於 `tabs_screen.dart` 中獲取篩選器的篩選狀態: ```dart void selectedScreen(String routeName) async { Navigator.of(context).pop(); if (routeName == 'filters') { // 獲取 Navigator.of(context).push 回傳的內容 final f = (await Navigator.of(context).push<Map<Filter, bool>>( MaterialPageRoute( builder: (context) => FiltersScreen(_selectedFilter), ), ))!; // 將 Navigator.of(context).push 回傳的篩選狀態保存起來 setState(() { _selectedFilter = f; }); } else { Navigator.of(context).push(MaterialPageRoute( builder: (context) => MealsScreen('所有食譜', meals, toggleFavo), )); } } ``` 於 `filters_screen.dart` 中通過 `WillPopScope` 傳遞篩選狀態到外層(`tabs_screen.dart`): ```dart WillPopScope( onWillPop: () async { // 通過 onWillPop 方法於關閉屏幕時觸發事件 Navigator.of(context).pop({ // 關閉屏幕的同時傳遞數據 Filter.glutenFree: _isGlutenFree, Filter.lactoseFree: _isLactoseFree, Filter.vegan: _isVegan, Filter.vegetarian: _isVegetarian, }); return false; // 回傳 false 表示不要執行 pop() }, child: Column( children: [ SwitchListTile(), SwitchListTile(), ], ), ); ``` > 由於在 `onWillPop` 中已經通過 `Navigator.of(context).pop({...})` 傳遞篩選狀態了,所以最後要回傳 false 給 `onWillPop` 否則回傳 true 會導致多執行一次 pop() 事件,關閉整個應用。 接著在 `tabs_screen.dart` 中,宣告 `filterMeals` 保存篩選器過濾後的食譜,並傳遞給 `CategoriesScreen` ,再藉由 `CategoriesScreen` 傳遞給 `MealsScreen` 以顯示篩選後的食譜: ```dart final List<Meal> filterMeals = meals.where((meal) { // 判斷當食譜的 isGlutenFree 為否,且 filter 開啟時不要加入該食譜 if (!meal.isGlutenFree && _selectedFilter[Filter.glutenFree]!) { return false; } if (!meal.isLactoseFree && _selectedFilter[Filter.lactoseFree]!) { return false; } if (!meal.isVegan && _selectedFilter[Filter.vegan]!) { return false; } if (!meal.isVegetarian && _selectedFilter[Filter.vegetarian]!) { return false; } // 讓其他非上述判斷的食譜都加到 filterMeals 中 return true; }).toList(); // 最後轉為 List 類型 // 把 filterMeals 傳給 CategoriesScreen CategoriesScreen(filterMeals, toggleFavo) ``` ## 將食譜加入喜愛項目 在 `meal_screen.dart` 中點擊按鈕,觸發 `toggleFavo` 事件,該 `toggleFavo` 事件來自於外層的 `meals_screen.dart` ,而 `meals_screen.dart` 的事件則來自於外層的 `tabs_screen.dart`: ```dart // 1. tabs_screen.dart // 宣告 _favoMeals 用來存放喜愛項目 final List<Meal> _favoMeals = []; // 設置 toggleFavo 事件用來切換是否加入喜愛項目 void toggleFavo(Meal meal) { if (_favoMeals.contains(meal)) { // 如果食譜已經在 _favoMeals 則移除 setState(() { _favoMeals.remove(meal); }); _showMessage('已將食譜${meal.title}從喜好項目中移除。'); } else { // 如果食譜不存在 _favoMeals 則加入 setState(() { _favoMeals.add(meal); }); _showMessage('已將食譜${meal.title}添加到喜好項目中。'); } } // 將 toggleFavo 方法傳給 MealsScreen MealsScreen('', _favoMeals, toggleFavo) // 2. meals_screen.dart // 設置 _selectedMeal 事件,把 toggleFavo 傳給 MealScreen void _selectedMeal(BuildContext context, Meal meal) { Navigator.of(context).push(MaterialPageRoute( builder: (ctx) => MealScreen(meal, toggleFavo), )); } // 3. meal_screen.dart // 將 toggleFavo 作為按鈕點擊後觸發的事件 appBar: AppBar( title: Text(meal.title), actions: [ IconButton( onPressed: () { toggleFavo(meal); }, icon: const Icon(Icons.favorite_border)) ], ) ``` ## 其他新小部件用法 ### InkWell 小部件做漣漪效果按鈕 要讓某個小部件可點擊並執行點擊事件可以使用 `InkWell` 小部件包裹,並設置其 `onTap` 事件: ```dart InkWell( splashColor: Theme.of(context).primaryColor, // 設置點擊時漣漪的顏色 borderRadius: BorderRadius.circular(8), // 設置圓角 onTap: () {}, // 設置點擊後要觸發的事件 child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: bgColor, borderRadius: BorderRadius.circular(8), // 設置與父層相同的圓角 ), child: Text( title, style: Theme.of(context).textTheme.headlineMedium, ), ), ) ``` ### 跳轉屏幕事件 點擊按鈕後跳轉到其他屏幕的做法: ```dart // 1. 在分類的屏幕 CategoriesScreen 中設置跳轉到食譜屏幕 MealsScreen 的方法 _selectedCategory void _selectedCategory(BuildContext context, Category category) { Navigator.of(context).push( // 通過 Navigator.of(context).push 跳轉屏幕,傳入 route MaterialPageRoute( // 使用 MaterialPageRoute 方法作為 route builder: (context) => MealsScreen(category, meals), // 傳入 builder,並傳入食譜屏幕 MealsScreen ) ); } // 2. 在分類的屏幕 CategoriesScreen 中,將 _selectedCategory 方法傳遞給每個分類子部件 CategoryGridItem CategoryGridItem( c.title, c.color, () { // 由於需傳遞參數給 _selectedCategory // 所以要用空函數包裹住 _selectedCategory // 否則會被 dart 誤以為要立即執行 _selectedCategory 函數 _selectedCategory(context, c); }, ) // 3. 在分類子部件 CategoryGridItem 中接收並使用 _selectedCategory 函數 final Function() selectedCategory; // 接收函數 InkWell( onTap: selectedCategory, // 傳入函數 child: // ... ); ``` ### FadeInImage 小部件處理圖片載入時的淡入淡出 可使用 FadeInImage 小部件將圖片包裹住,這樣在圖片加載時,即可顯示淡入淡出效果(這邊需安裝 transparent_image 套件,用於 FadeInImage 小部件的 placeholder): ```dart FadeInImage.memoryNetwork( placeholder: kTransparentImage, // 設置圖片載入前顯示的內容為 transparent_image 提供的 kTransparentImage image: meal.imgUrl, width: double.infinity, // 設置圖片寬度 height: 300, // 設置圖片度 fit: BoxFit.cover, // 設置圖片不符合寬高時要自動裁切填滿 ) ``` ### for loop 獲取 index 的方法 for loop 中可以通過以下做法獲取 index : ```dart for (final (index, step) in meal.steps.indexed) ListTile( leading: CircleAvatar( child: Text('${index + 1}', textAlign: TextAlign.center), ), title: Text( step, style: Theme.of(context).textTheme.bodyMedium, ), ), ``` ### Hero 小部件做兩個屏幕間的過場 可以用 Hero 小部件包裹住兩個屏幕之間重複的元素,以做出過場效果,以本例來說,可將 MealItem 中的圖片用 Hero 小部件包裹住,再將 MealScreen 中的圖片也用 Hero 小部件包裹住,這樣點擊 MealItem 中的圖片進入 MealScreen 時就可以看到圖片過場效果: ```dart Hero( tag: meal.id, // 在兩邊都傳入相同的辨識標記 child: FadeInImage.memoryNetwork( // 兩邊都使用相同小部件 placeholder: kTransparentImage, image: meal.imgUrl, width: double.infinity, height: 300, fit: BoxFit.cover, ), ), ``` ### Stack & Positioned 小部件 ```dart Stack( alignment: Alignment.bottomRight, // 設置其 children 的對齊方式 children: [ Hero( // 設置一個圖片當底 tag: meal.id, child: FadeInImage.memoryNetwork( placeholder: kTransparentImage, image: meal.imgUrl, width: double.infinity, height: 300, fit: BoxFit.cover, ), ), Positioned( // 設置一個標籤,用 Positioned 規定要放在右 0 下 8 處 bottom: 8, right: 0, child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.background, borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), bottomLeft: Radius.circular(16), ), ), padding: const EdgeInsets.symmetric( vertical: 8, horizontal: 16), child: MealItemLable( const Icon(Icons.schedule), meal.duration), ), ), ], ) ``` ### Wrap 讓文字超過寬度自動換行 ```dart Wrap( alignment: WrapAlignment.center, // 設置內容對齊方式 children: [ // 放入所有食譜的材料 for (final e in meal.ingredients) Text( meal.ingredients.last == e ? e : '$e、', style: Theme.of(context).textTheme.bodyMedium!.copyWith( color: Theme.of(context).colorScheme.onBackground, ), ) ], ) ``` ### SwitchListTile 開關小部件 ```dart SwitchListTile( title: Text( // 設置標題 '不含乳糖', style: Theme.of(context).textTheme.titleLarge!.copyWith( color: Theme.of(context).colorScheme.onBackground, ), ), subtitle: Text( // 設置副標題 '篩選所有不含乳糖的食譜', style: Theme.of(context).textTheme.bodyMedium!.copyWith( color: Theme.of(context).colorScheme.onBackground, ), ), activeColor: Theme.of(context).colorScheme.primary, // 設置開啟時的高亮顏色 value: _isLactoseFree, // 設置預設的 value 為 _isLactoseFree onChanged: (value) { // 開關切換時觸發 setState(() { // 更新 _isLactoseFree 的值 _isLactoseFree = value; }); }, ), ``` ### GridView 小部件 ```dart GridView( padding: const EdgeInsets.all(20), // 設置最外面的間距 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, // 設置一行幾個 childAspectRatio: 1.75, // 設置單個的寬高比 crossAxisSpacing: 20, // 設置水平間距 mainAxisSpacing: 20, // 設置垂直間距 ), children: [ for (final c in categories) CategoryGridItem( c.title, c.color, () { _selectedCategory(context, c); }, ) ], ); ``` - gridDelegate - 如果是 SliverGridDelegateWithMaxCrossAxisExtent ,則需設置 `maxCrossAxisExtent` 限制每個子部件可用的最大寬度 - 如果是 SliverGridDelegateWithFixedCrossAxisCount ,則需設置 `crossAxisCount` 限制每行要放給多少個子部件均分 ### ListTile 小部件 ```dart for (final (index, e) in meal.steps.indexed) ListTile( leading: CircleAvatar( // leading 設置圖標,用 CircleAvatar 設置圓形填色背景 child: Text('${index + 1}'), // 顯示步驟數 ), title: Text(e), // 顯示步驟內容 ) ``` - leading 設置左側圖標,常見放圓形填色背景、頭貼圖片、Icon 等等 - title 設置主要標題 - subtitle 設置次要標題,會顯示在標題下方,比標題小字 - trailing 設置右側圖標,常見放箭頭 Icon 按鈕等等 ## 進階 - 狀態管理 flutter_riverpod 套件 ### 安裝與初始化 執行指令 `flutter pub add flutter_riverpod` 以進行安裝。 安裝好後,修改 `main.dart` 內容,以初始化 `flutter_riverpod`: ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; // 引入 flutter_riverpod void main() { runApp( const ProviderScope( // 將 MyApp 用 ProviderScope 包起來 child: MyApp(), ), ); } ``` 接著即可開始使用。 ### 使用方式 與 material 提供的有狀態小部件與無狀態小部件一樣,在 flutter_riverpod 中,也分為 `ConsumerWidget` 與 `ConsumerStatefulWidget` 小部件兩種,分別對應原本的 `StatelessWidget` 與 `StatefulWidget` 小部件。 如果是使用 `ConsumerWidget` 小部件,則須在 `Widget build(BuildContext context)` 中多新增第二個參數 `WidgetRef ref` 才能在 `Widget build` 裏面通過 `ref.watch` 或 `ref.read` 獲取 provider 。 如果是使用 `ConsumerStatefulWidget` 小部件,則需將 `State` 改為 `ConsumerState` ,但無須在 `Widget build(BuildContext context)` 中新增 `ref` 參數。 另外 `ref.watch()` 主要用於獲取數據資料,該方法會在每次數據產生變動時自動重新讀取更新後的資料 而 `ref.read()` 則拿來呼叫 provider 裡面宣告的方法。 ### Provider 方法 #### 直接回傳數據 以 meals 來說,原先是直接使用 `/data/dummy_categories.dart` 存放的陣列資料, 現在可於 `/lib` 中新增資料夾 `providers` 再新增檔案 `meals_providers.dart` 來存放 meals 資料: ```dart import 'package:meals/data/dummy_categories.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; // 基礎用法,直接通過 Provider 方法回傳 meals final mealsProvider = Provider((ref) => meals); ``` 接著要在 `tabs_screen.dart` 中獲取 `mealsProvider` 作法如下: 1. 將 `StatefulWidget` 有狀態小部件改為 `ConsumerStatefulWidget` 小部件 2. 將 `State` 改為 `ConsumerState` 3. 在 `Widget build(BuildContext context) {}` 中通過 `final meals = ref.watch(mealsProvider);` 獲取 meals 即可。 #### 與其他 provider 進行交互並回傳數據 每個 provider 之間可通過 ref 互相交互,比如 filterMeals 就需要通過 meals 進行過濾後獲取結果: ```dart final filterMealsProvider = Provider((ref) { final meals = ref.watch(mealsProvider); // 獲取 meals return meals.where((meal) { // 過濾 meals if (!meal.isGlutenFree && selectedFilter[Filter.glutenFree]!) { return false; } if (!meal.isLactoseFree && selectedFilter[Filter.lactoseFree]!) { return false; } if (!meal.isVegan && selectedFilter[Filter.vegan]!) { return false; } if (!meal.isVegetarian && selectedFilter[Filter.vegetarian]!) { return false; } return true; }).toList(); }); ``` 接著即可在 `tabs_screen.dart` 中通過 `final filterMeals = ref.watch(filterMealsProvider);` 獲取 filterMeals 的資料。 ### StateNotifierProvider 方法 以 favoriteMeals 來說,除了需要提供喜好項目列表外,還需要提供變更喜好狀態的方法,以及獲取當前喜好狀態等,要用更複雜的方式處理: ```dart class FavoriteMealsNotifier extends StateNotifier<List<Meal>> { // 通過 extends StateNotifier 建立 FavoriteMealsNotifier class FavoriteMealsNotifier() : super([]); // 用 : super() 設置預設值為 [] bool toggleFavo(Meal meal) { // 宣告變更喜好狀態的方法,回傳布林值以供判斷顯示的提示為刪除或添加 if (state.contains(meal)) { // state 即為 favoriteMeals,通過 contains() 方法判斷是否已有 meal state = state.where((element) => element.id != meal.id).toList(); // 使用 state = ... 的方式重新賦值 return false; // 回傳非增加 } else { state = [...state, meal]; // 通過解構方式添加 meal return true; // 回傳是增加 } } } final favoriteMealsProvider = StateNotifierProvider<FavoriteMealsNotifier, List<Meal>>( // 通過 StateNotifierProvider 回傳 FavoriteMealsNotifier (ref) { return FavoriteMealsNotifier(); }, ); ``` 接著在 `tabs_screen.dart` 中就可以透過 `final favoMeals = ref.watch(favoriteMealsProvider);` 獲取喜愛項目的數據傳遞到 `MealsScreen()` 中了。 另外在 `MealScreen()` 中的右上角原本有切換喜好項目用的按鈕,可以將其單獨拉出來做成有狀態小部件,再通過 `favoriteMealsProvider` 獲取方法來切換喜好項目、愛心 icon: ```dart class MealFavoriteButton extends ConsumerStatefulWidget {// 將有狀態小部件改為 ConsumerStatefulWidget const MealFavoriteButton(this.meal, {super.key}); final Meal meal; // 從 MealScreen 中傳入當前的 meal @override ConsumerState<MealFavoriteButton> createState() => _MealFavoriteButtonState(); } class _MealFavoriteButtonState extends ConsumerState<MealFavoriteButton> { @override Widget build(BuildContext context) { // 獲取 favoriteMeals final favoriteMeals = ref.watch(favoriteMealsProvider); return IconButton( onPressed: () { // 通過 ref.read(favoriteMealsProvider.notifier) 呼叫使用 toggleFavo 方法 final isAddFavo = ref.read(favoriteMealsProvider.notifier).toggleFavo(widget.meal); ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(isAddFavo ? '已將食譜${widget.meal.title}添加到喜好項目。' : '已將食譜${widget.meal.title}從喜好項目中移除。'), ), ); }, // 判斷 favoriteMeals 中是否已有 meal 切換愛心 icon icon: Icon(favoriteMeals.contains(widget.meal) ? Icons.favorite : Icons.favorite_border), ); } } ``` ## 進階 - 使用 supabase 獲取分類資料 ### 安裝與初始化 先到 supabase 開一個新專案,並為分類建立好 table ,欄位 id(type text) 、 title(type text) 、 color(type bigint)。 > 通過 `Colors.red.value` 可獲取純數字格式的顏色,使用時則通過 `Color(4282339765)` 即可。 執行指令 `flutter pub add supabase_flutter` 以進行安裝。 安裝好後,修改 `main.dart` 內容,以初始化 `supabase_flutter`: ```dart import 'package:supabase_flutter/supabase_flutter.dart'; // 引入 supabase_flutter Future<void> main() async { await Supabase.initialize( url: 'your url here', anonKey: 'your key here', ); runApp(const ProviderScope(child: MyApp())); } ``` 接著即可開始使用。 ### 通過 Future 獲取資料 在 `categories_screen.dart` 中,將所有小部件用 `FutureBuilder` 包裹住,並設置其 `future` 及 `builder` 函數: ```dart import 'package:supabase_flutter/supabase_flutter.dart' as supabase; // 引入 supabase ,設置別稱為 supabase 否則會跟 flutter_riverpod 衝突 final _supabase = supabase.Supabase.instance.client; // 宣告 _supabase FutureBuilder( // 最外層用 FutureBuilder 小部件 future: _supabase.from('categories').select(), // 用 supabase 提供的方法,獲取 table 中 categories 的資料 builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { // 判斷是否還在載入中 return const Center(child: CircularProgressIndicator()); // 顯示圓形 loading 小部件 } else { if (snapshot.error != null) { // 判斷是否有錯 return const Center( child: Text('Something wrong...'), ); } else { // 顯示原本的小部件 return GridView( padding: const EdgeInsets.all(20), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 1.75, crossAxisSpacing: 20, mainAxisSpacing: 20, ), children: [ for (final c in snapshot.data) // 用 snapshot.data 來獲取拿到的 categories 資料 CategoryGridItem( c['title'], // c.title 改為 c['title'] Color(c['color']), // c.color 改為 Color(c['color']) () => _selectedCategory(context, Category(c['id'], c['title'], Color(c['color']))), // 原本傳入 c 改為傳入完整的 Category 物件 ) ], ); } } }, ); ``` ## 進階 - 添加動畫過場效果 動畫區分為顯性與隱性兩種,一種是自定義動畫,另一種則是使用 flutter 提供的各種小部件來實現,將大量部分交給 flutter 做管理及運算。 ### 顯性 要使用顯性動畫,需要先將小部件轉為有狀態小部件,接著宣告 animationController 以獲取每一幀的值,且與 textController 一樣,須通過 dispose() 摧毀控制器,以避免佔用內存。 主要程式碼如下: ```dart // 使用 with SingleTickerProviderStateMixin 以獲取 AnimationController 中 vsync 要用的 TickerProvider class _CategoriesScreenState extends State<CategoriesScreen> with SingleTickerProviderStateMixin { late AnimationController _animationController; // 用 late 宣告在稍後使用時才被賦值的 _animationController @override void initState() { // 通過 initState 設置 _animationController 的值 _animationController = AnimationController( vsync: this, // 利用 with SingleTickerProviderStateMixin 獲取到的 this duration: const Duration(milliseconds: 800), // 設置動畫時長 800 毫秒 ); super.initState(); _animationController.forward(); // 讓動畫往前執行 } @override void dispose() { _animationController.dispose(); // 摧毀動畫 super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( // 用 AnimatedBuilder 包住小部件 animation: _animationController, // 設置動畫控制器 child: // 將原本的小部件你內容全放在這,避免執行動畫時每幀重置 UI builder: (context, child) { return Opacity( // 通過 Opacity 小部件 讓所有內容淡入 opacity: _animationController.value, child: child, ); }, // 也可用 FadeTransition 小部件,搭配 CurvedAnimation 設置淡入淡出過場的曲線類型 // builder: (context, child) { // return FadeTransition( // opacity: CurvedAnimation(parent: _animationController, curve: Curves.easeInOutBack), // child: child, // ); // }, ); } } ``` ### 隱性 以加入喜好項目按鈕來說,可以在切換圖標時製作動畫效果: ```dart icon: AnimatedSwitcher( // 將 icon 小部件用 AnimatedSwitcher 包住 duration: const Duration(milliseconds: 300), // 設置動畫時長 child: Icon( // 將原本的 icon 設為 child,並給上 key 值(否則 flutter 會認為小部件沒變化而無法執行動畫) isFavo ? Icons.favorite : Icons.favorite_border, key: ValueKey(isFavo), ), transitionBuilder: (child, animation) { // 設置動畫內容 return ScaleTransition( // 使用 ScaleTransition 小部件讓動畫的效果為放大、縮小 scale: Tween(begin: 1.25, end: 1.0).animate(CurvedAnimation(parent: animation, curve: Curves.easeIn)), child: child, ); }, ), ``` 另外在上面曾提及的新小部件中, `Hero` 小部件也屬於隱性動畫之一,當兩個不同屏幕中出現相同小部件時,可用 `Hero` 小部件包裹,這樣在切換兩個屏幕時即可透過 `Hero` 小部件產生動畫過場,比如在 `meal_item.dart` 與 `meal_screen.dart` 中擁有相同的圖片小部件,就可以這樣做: ```dart Hero( // 用 Hero 小部件包裹住相同小部件 tag: meal.id, // 設置辨識用的唯一 tag child: FadeInImage.memoryNetwork( // 相同小部件 placeholder: kTransparentImage, image: meal.imgUrl, width: double.infinity, height: 300, fit: BoxFit.cover, ), ) ```