Wang Xuan(雅萱)
    • Create new note
    • Create a note from template
      • Sharing URL Link copied
      • /edit
      • View mode
        • Edit mode
        • View mode
        • Book mode
        • Slide mode
        Edit mode View mode Book mode Slide mode
      • Customize slides
      • Note Permission
      • Read
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Write
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Engagement control Commenting, Suggest edit, Emoji Reply
    • Invite by email
      Invitee

      This note has no invitees

    • Publish Note

      Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

      Your note will be visible on your profile and discoverable by anyone.
      Your note is now live.
      This note is visible on your profile and discoverable online.
      Everyone on the web can find and read all notes of this public team.
      See published notes
      Unpublish note
      Please check the box to agree to the Community Guidelines.
      View profile
    • Commenting
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
      • Everyone
    • Suggest edit
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
    • Emoji Reply
    • Enable
    • Versions and GitHub Sync
    • Note settings
    • Note Insights New
    • Engagement control
    • Transfer ownership
    • Delete this note
    • Save as template
    • Insert from template
    • Import from
      • Dropbox
      • Google Drive
      • Gist
      • Clipboard
    • Export to
      • Dropbox
      • Google Drive
      • Gist
    • Download
      • Markdown
      • HTML
      • Raw HTML
Menu Note settings Note Insights Versions and GitHub Sync Sharing URL Create Help
Create Create new note Create a note from template
Menu
Options
Engagement control Transfer ownership Delete this note
Import from
Dropbox Google Drive Gist Clipboard
Export to
Dropbox Google Drive Gist
Download
Markdown HTML Raw HTML
Back
Sharing URL Link copied
/edit
View mode
  • Edit mode
  • View mode
  • Book mode
  • Slide mode
Edit mode View mode Book mode Slide mode
Customize slides
Note Permission
Read
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Write
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Engagement control Commenting, Suggest edit, Emoji Reply
  • Invite by email
    Invitee

    This note has no invitees

  • Publish Note

    Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note

    Your note will be visible on your profile and discoverable by anyone.
    Your note is now live.
    This note is visible on your profile and discoverable online.
    Everyone on the web can find and read all notes of this public team.
    See published notes
    Unpublish note
    Please check the box to agree to the Community Guidelines.
    View profile
    Engagement control
    Commenting
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    • Everyone
    Suggest edit
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    Emoji Reply
    Enable
    Import from Dropbox Google Drive Gist Clipboard
       Owned this note    Owned this note      
    Published Linked with GitHub
    • Any changes
      Be notified of any changes
    • Mention me
      Be notified of mention me
    • Unsubscribe
    # 使用 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, ), ) ```

    Import from clipboard

    Paste your markdown or webpage here...

    Advanced permission required

    Your current role can only read. Ask the system administrator to acquire write and comment permission.

    This team is disabled

    Sorry, this team is disabled. You can't edit this note.

    This note is locked

    Sorry, only owner can edit this note.

    Reach the limit

    Sorry, you've reached the max length this note can be.
    Please reduce the content or divide it to more notes, thank you!

    Import from Gist

    Import from Snippet

    or

    Export to Snippet

    Are you sure?

    Do you really want to delete this note?
    All users will lose their connection.

    Create a note from template

    Create a note from template

    Oops...
    This template has been removed or transferred.
    Upgrade
    All
    • All
    • Team
    No template.

    Create a template

    Upgrade

    Delete template

    Do you really want to delete this template?
    Turn this template into a regular note and keep its content, versions, and comments.

    This page need refresh

    You have an incompatible client version.
    Refresh to update.
    New version available!
    See releases notes here
    Refresh to enjoy new features.
    Your user state has changed.
    Refresh to load new user state.

    Sign in

    Forgot password

    or

    By clicking below, you agree to our terms of service.

    Sign in via Facebook Sign in via Twitter Sign in via GitHub Sign in via Dropbox Sign in with Wallet
    Wallet ( )
    Connect another wallet

    New to HackMD? Sign up

    Help

    • English
    • 中文
    • Français
    • Deutsch
    • 日本語
    • Español
    • Català
    • Ελληνικά
    • Português
    • italiano
    • Türkçe
    • Русский
    • Nederlands
    • hrvatski jezik
    • język polski
    • Українська
    • हिन्दी
    • svenska
    • Esperanto
    • dansk

    Documents

    Help & Tutorial

    How to use Book mode

    Slide Example

    API Docs

    Edit in VSCode

    Install browser extension

    Contacts

    Feedback

    Discord

    Send us email

    Resources

    Releases

    Pricing

    Blog

    Policy

    Terms

    Privacy

    Cheatsheet

    Syntax Example Reference
    # Header Header 基本排版
    - Unordered List
    • Unordered List
    1. Ordered List
    1. Ordered List
    - [ ] Todo List
    • Todo List
    > Blockquote
    Blockquote
    **Bold font** Bold font
    *Italics font* Italics font
    ~~Strikethrough~~ Strikethrough
    19^th^ 19th
    H~2~O H2O
    ++Inserted text++ Inserted text
    ==Marked text== Marked text
    [link text](https:// "title") Link
    ![image alt](https:// "title") Image
    `Code` Code 在筆記中貼入程式碼
    ```javascript
    var i = 0;
    ```
    var i = 0;
    :smile: :smile: Emoji list
    {%youtube youtube_id %} Externals
    $L^aT_eX$ LaTeX
    :::info
    This is a alert area.
    :::

    This is a alert area.

    Versions and GitHub Sync
    Get Full History Access

    • Edit version name
    • Delete

    revision author avatar     named on  

    More Less

    Note content is identical to the latest version.
    Compare
      Choose a version
      No search result
      Version not found
    Sign in to link this note to GitHub
    Learn more
    This note is not linked with GitHub
     

    Feedback

    Submission failed, please try again

    Thanks for your support.

    On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?

    Please give us some advice and help us improve HackMD.

     

    Thanks for your feedback

    Remove version name

    Do you want to remove this version name and description?

    Transfer ownership

    Transfer to
      Warning: is a public team. If you transfer note to this team, everyone on the web can find and read this note.

        Link with GitHub

        Please authorize HackMD on GitHub
        • Please sign in to GitHub and install the HackMD app on your GitHub repo.
        • HackMD links with GitHub through a GitHub App. You can choose which repo to install our App.
        Learn more  Sign in to GitHub

        Push the note to GitHub Push to GitHub Pull a file from GitHub

          Authorize again
         

        Choose which file to push to

        Select repo
        Refresh Authorize more repos
        Select branch
        Select file
        Select branch
        Choose version(s) to push
        • Save a new version and push
        • Choose from existing versions
        Include title and tags
        Available push count

        Pull from GitHub

         
        File from GitHub
        File from HackMD

        GitHub Link Settings

        File linked

        Linked by
        File path
        Last synced branch
        Available push count

        Danger Zone

        Unlink
        You will no longer receive notification when GitHub file changes after unlink.

        Syncing

        Push failed

        Push successfully