--- tags: Flutter --- # Flutter 常用小部件/路由配置/自適應作法/動畫教學 ## 主要小部件(MaterialApp/CupertinoApp) - `Material` 用 `MaterialApp`: ```dart= MaterialApp( title: 'FLUTTER APP', // 設置標題 home: MyHomePage(), // 載入主要小部件(傳入一個 class,通常不是 StatefulWidget[狀態小部件] 就是 StatelessWidget[無狀態小部件]) theme: ThemeData( // 設置主題 fontFamily: 'Quicksand', // 設置整體字型,須先下載文字包(.ttf 檔案)並在 pubspec.yaml 檔案中啟用 primarySwatch: Colors.indigo, // 設置主題顏色 ), ); ``` - `Cupertino` 用 `CupertinoApp`: ```dart= CupertinoApp( title: 'FLUTTER APP', // 設置標題 home: MyHomePage(), // 載入主要小部件(傳入一個 class,通常不是 StatefulWidget[狀態小部件] 就是 StatelessWidget[無狀態小部件]) theme: CupertinoThemeData( // 設置主題 textTheme: CupertinoTextThemeData( textStyle: TextStyle(fontFamily: 'Quicksand'), // 設置整體字型,須先下載文字包(.ttf 檔案)並在 pubspec.yaml 檔案中啟用 ), primaryColor: CupertinoColors.systemIndigo, // 設置主題顏色 primaryContrastingColor: CupertinoColors.white, // 設置主題顏色為背景色時文字使用的顏色 barBackgroundColor: CupertinoColors.systemIndigo, // 設置各種 bar 的背景色 ), ) ``` ## 主要版面配置組件(Scaffold/CupertinoPageScaffold) - `Material` 用 `Scaffold` ```dart= Scaffold( appBar: appBar, // Navbar 小部件(AppBar) body: ..., // 主要內容(比如 container 小部件) ) ``` - `Cupertino` 用 `CupertinoPageScaffold` ```dart= CupertinoPageScaffold( navigationBar: appBar, // Navbar 小部件(CupertinoNavigationBar) child: ..., // 主要內容(比如 container 小部件) ) ``` ## Navbar 小部件 - `Material` 用 `AppBar` ```dart= AppBar( title: Text( // 設置 NavBar 標題 '我的應用程序', style: TextStyle(fontWeight: FontWeight.bold), // 設置文字樣式,Material 會自動將主題顏色設為預設的文字顏色 ), actions: [ // 顯示在 NavBar 右側的功能按鈕 IconButton( onPressed: () { // 按鈕點擊後觸發的事件 ... }, icon: Icon(Icons.add), // 按鈕的圖標,Material 的 icon 用 Icons ) ], ); ``` - `Cupertino` 用 `CupertinoNavigationBar` ```dart= CupertinoNavigationBar( middle: Text( // 設置 NavBar 標題 '我的應用程序', style: TextStyle( // 設置文字樣式 fontWeight: FontWeight.bold, )), trailing: Row( // 右側組件 mainAxisSize: MainAxisSize.min, // Row 小部件默認會佔據全寬,需設置尺寸為最小(min)以顯示 NavBar 標題 children: [ GestureDetector( // 偵測手勢的小部件 onTap: () { // 點擊後觸發的事件 }, child: Icon( // 按鈕的圖標 CupertinoIcons.add, // Cupertino 的 icon 用 CupertinoIcons size: 24, ), ) ], ), ), ``` ## 按鈕小部件 - `Material` 用 `TextButton`、`ElevatedButton`、`OutlinedButton` ```dart= TextButton( // Material 的純文字按鈕(無邊框、無背景色) onPressed: () { ... }, child: Text('Click Me!'), // 設置按鈕顯示的文字 ); ``` - `Cupertino` 用 `CupertinoButton` ```dart= CupertinoButton( // Cupertino 的按鈕小部件,可自行設定其他樣式以成為 Material 中的 ElevatedButton(有背景色的按鈕) 或 OutlinedButton(有邊框無背景色的按鈕) onPressed: () { ... }, child: Text('Click Me!'), // 設置按鈕顯示的文字 ); ``` ### 關於按鈕小部件的新舊版對應 - `FlatButton` - 純文字按鈕,無背景無邊框,新版替換為 `TextButton` - `RaisedButton` - 基本按鈕,有背景無邊框,新版替換為 `ElevatedButton` - `OutlineButton` - 框線按鈕,無背景有邊框,新版替換為 `OutlinedButton`(他倆差一個 d) ## 網格佈局的組件 GridView - `GridView` ```dart= GridView( padding: const EdgeInsets.all(15), // 設置間距 gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( childAspectRatio: 16 / 9, // 設置每個子元素的寬/高比例 maxCrossAxisExtent: 200, // 設置單個子元素最寬的大小(單位像素) crossAxisSpacing: 10, // 設置水平方向子元素之間的間距(單位像素) mainAxisSpacing: 10, // 設置垂直方向子元素之間的間距(單位像素) ), children: [...], // 通常會用 List.map 方法生成 ), ``` -`GridView.build` ```dart= GridView.builder( padding: const EdgeInsets.all(15), // 設置間距 gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( childAspectRatio: 16 / 9, // 設置每個子元素的寬/高比例 maxCrossAxisExtent: 200, // 設置單個子元素最寬的大小(單位像素) crossAxisSpacing: 10, // 設置水平方向子元素之間的間距(單位像素) mainAxisSpacing: 10, // 設置垂直方向子元素之間的間距(單位像素) ), itemCount: userPlaces.items.length, itemBuilder: (context, index) => Card( child: Text( userPlaces.items[index].title, style: Theme.of(context).textTheme.titleMedium, ), ), ), ``` ## 表單小部件 Form - 通常外層會用 SingleChildScrollView 小部件包裹,以避免鍵盤高度導致畫面出錯的問題 - Form 需綁定 key 以進行提交時的驗證(可呼叫 `GlobalKey()` 產生 key) ```dart= final GlobalKey<FormState> _formKey = GlobalKey(); // 創建 key final _authData = { // 設置資料用以在提交表單後保存數據 'name': '', 'email': '', 'password': '', }; void _submit() { if (!_formKey.currentState!.validate()) { // 確認表單驗證是否通過 return; } _formKey.currentState!.save(); // 執行表單中的所有 onSaved 函數 } SingleChildScrollView( child: Form( key: _formKey, // 設置 key child: Column( children: [ TextFormField( decoration: const InputDecoration( labelText: '電子信箱', // 設置 label ), validator: (val) { // 驗證,回傳 null 表示通過驗證,否則回傳的值就是顯示於 UI 上的錯誤訊息 final bool emailValid = RegExp( // 利用正規表達式驗證信箱格式 r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$') .hasMatch(val!); if (val.isEmpty || !emailValid) { return '請輸入正確的電子信箱'; } return null; }, keyboardType: TextInputType.emailAddress, // 設定鍵盤類型 textInputAction: TextInputAction.done, // 設定鍵盤中的 enter 鍵作用 onSaved: (value) { // 設置提交表單時要執行的函數 _authData['email'] = value!; }, ), TextFormField( decoration: const InputDecoration( labelText: '密碼', ), validator: (value) { if (value!.isEmpty || value.length < 5) { return '密碼不得少於六個字'; } else { return null; } }, obscureText: true, // 設定為輸入的值不可見(超級適用於密碼) keyboardType: TextInputType.visiblePassword, onSaved: (value) { _authData['password'] = value!; }, ), const SizedBox( height: 20, ), ElevatedButton( onPressed: _submit, child: Text( '登入', style: const TextStyle( fontWeight: FontWeight.bold, ), ), ), TextButton( onPressed: () { // 切換為註冊用表單 }, child: const Text('創建新帳號',), ) ], ), ), ) ``` ## 其他常用小部件 1. 文字超過寬度時希望自動等比例縮小,可使用 `FittedBox` 小部件包裹 `Text` 小部件 2. 希望小部件由上往下排列可以使用 `Column` 小部件包外面,希望小部件由左往右排列則可以使用 `Row` 小部件包外面 3. 想使用 padding 或 margin 但小部件不支援該屬性時,可以直接新增一個 `SizedBox` 小部件設置高度充當間距 4. 希望圖片小部件產生圓角時,可以在 `Image` 小部件的外面包一個 `ClipRRect` 小部件設置 `borderRadius` 5. `InkWell` 小部件與 `GestureDetector` 小部件一樣用於註冊事件,比如點擊事件等等,差別是 `InkWell` 點擊時會有水波紋的動畫產生 6. `Spacer` 小部件可強制將下一個小部件推至最對側,比如在 `Row` 中左側已有幾個小部件,若希望在最右側放置一個小部件,則可以在最右側小部件前面插入一個 `Spacer` 小部件 7. 在 `Row` 或 `Cloumn` 中,如果希望子部件佔據全寬或全高,可以使用 `Expanded` 小部件將子部件包裹住 ## 響應式(RWD)與自適應(辨識系統為 IOS 或 Android) - 在 Android 中使用的樣式以 `Material` 為主,使用時需引入 `Material` - 在 IOS 中則使用 `Cupertino` 樣式,使用時需引入 `Cupertino` - 辨識設備方向是否為橫向:`MediaQuery.of(context).orientation == Orientation.landscape;`(會返回布林值) - 自動根據方向計算區塊高度: - `MediaQuery.of(context).size.height` 是設備總高度 - `MediaQuery.of(context).padding.top` 是預設的 padding top - `appBar.preferredSize.height` 是 Navbar 的高度(appBar 是一個變數,裡面包裝 `Material` 的 `AppBar` 小部件,需將小部件設成變數後才可使用 `.preferredSize` 獲取 `.height`) - 利用設備總高度減去預設的 padding 與 appBar 後,再利用 `*0~1` 的方式設置內容佔據的高度倍數(所有小部件高度倍數加起來不可超過 1) - 判斷是否為 `IOS` 系統:於 `.dart` 文件的最頂部優先引入 `dart:io`,在需要判斷處使用 `Platform.isIOS`(會返回布林值) ## 使用路由的方式 1. 在要當 screen 的小部件中建立一個 `static` 類型的 `routerName` 參數: ```dart= class AScreen extends StatelessWidget { static const routerName = '/xxx'; // 設定路由 @override Widget build(BuildContext context) { return Scaffold(...) // 建立要產生的整個 screen 畫面,通常都會用 Scaffold 組件建立出 AppBar 與 body } } ``` 2. 在 `main.dart` 中設定路由表: ```dart= class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'MEALS APP', home: const HomeScreen(), // 主入口畫面 routes: { // 建立路由表 // 設定路由,以 home 來說這邊就可寫作 '/': (context)=> const HomeScreen(), AScreen.routerName: (context) => const AScreen(), }, ); } } ``` 3. 在要跳轉路由到不同畫面的小部件中設定點擊事件: ```dart= class MyWidget extends StatelessWidget { @override Widget build(BuildContext context) { return InkWell( onTap: () { // 設定點擊事件為跳轉路由 Navigator.of(ctx).pushNamed( // 使用具名路由進行跳轉 AScreen.routerName, arguments: {'id': id, 'name': name}, // 傳入參數 ); }, child: ..., ); } } ``` 4. 獲取路由中的參數 ```dart= class MyWidget extends StatelessWidget { @override Widget build(BuildContext context) { // 方法A 只傳一個參數過來 final String id = ModalRoute.of(context).settings.arguments as String; // 方法B 傳了多個參數過來 final routerArgs = ModalRoute.of(context).settings.arguments as Map<String, String>; // 獲取多個參數 final String name = routerArgs['name']; // 獲取指定參數 name final String id = routerArgs['id']; // 獲取指定參數 id final Int age = routerArgs['age']; // 獲取指定參數 age return Container(...); } } ``` ## 製作動畫效果 ### 通過 `addListener()` 搭配 `setState()` 自定義動畫 1. 將要使用到動畫的小部件改為有狀態小部件,通過 `with` 關鍵字得到 `SingleTickerProviderStateMixin` 的方法 2. 建立一個 `AnimationController` 類型的變量管理動畫,再建立一個 `Animation` 對象設置動畫 3. 使用 `AnimationController()` 方法傳入參數1 vsync 對象(vsync 對象用於阻止當前畫面以外的動畫消耗資源),參數2 duration(設置動畫執行的時間) 4. 使用 `Tween` 自定義動畫的開始值與結束值(默認的 `Animation` 對象開始到結束為 0 到 1) 5. 為動畫增加監聽器,執行 setState 以進行畫面變更 6. 在要使用動畫的地方傳入 `Animation` 對象的值 舉例如下: ```dart= class _MyCardState extends State<MyCard> with SingleTickerProviderStateMixin { // 於有狀態小部件中通過 `with` 關鍵字得到 `SingleTickerProviderStateMixin` 的方法 AnimationController _controller; // 建立管理動畫的 AnimationController 變量 Animation<Size> _heightAnimation; // 建立動畫對象 @override void initState() { // 初始化數據 // 設置 vsync 對象與動畫執行的時間 _controller = AnimationController(vsync: this, duration: Duration(milliseconds: 300)); _heightAnimation = Tween<Size>( // 使用 Tween 設置動畫對象的開始與結束狀態 begin: Size( double.infinity, 260, ), end: Size( double.infinity, 320, ), ).animate( // 接著使用 .animate() 傳入 CurvedAnimation() 設置動畫管理者與速率 CurvedAnimation( parent: _controller, // 設置動畫管理者 curve: Curves.linear, // 設置動畫開始與結束的過場速度模式,linear 為平滑 ), ); _heightAnimation.addListener(() { // 監聽動畫 setState(() {}); // 調用 setState 以更新畫面 }); super.initState(); } @override void dispose() { super.dispose(); _controller.dispose(); // 釋放動畫的監聽器 } @override Widget build(BuildContext context) { return Card( child: Container( width: deviceSize.width * 0.75, height: _heightAnimation.value.height, // 設置動畫過程中的高度為高度值 child: ..., ), ); } } ``` >關於釋放可以參考: >https://stackoverflow.com/questions/59558604/why-do-we-use-the-dispose-method-in-flutter-dart-code >(謝謝估狗大神) ### 通過 `AnimatedBuilder()` 建立動畫 以上一個例子做修改, 1. 移除 `addListener()` 監聽動畫事件 2. 移除 `dispose()` 釋放操作 3. 將需要使用動畫的小部件,用 `AnimatedBuilder()` 小部件包起來 4. `AnimatedBuilder()` 中傳入動畫對象與建構器 完整程式碼如下: ```dart= class _MyCardState extends State<MyCard> with SingleTickerProviderStateMixin { // 於有狀態小部件中通過 `with` 關鍵字得到 `SingleTickerProviderStateMixin` 的方法 AnimationController _controller; // 建立管理動畫的 AnimationController 變量 Animation<Size> _heightAnimation; // 建立動畫對象 @override void initState() { // 初始化數據 // 設置 vsync 對象與動畫執行的時間 _controller = AnimationController(vsync: this, duration: Duration(milliseconds: 300)); _heightAnimation = Tween<Size>( // 使用 Tween 設置動畫對象的開始與結束狀態 begin: Size( double.infinity, 260, ), end: Size( double.infinity, 320, ), ).animate( // 接著使用 .animate() 傳入 CurvedAnimation() 設置動畫管理者與速率 CurvedAnimation( parent: _controller, // 設置動畫管理者 curve: Curves.linear, // 設置動畫開始與結束的過場速度模式,linear 為平滑 ), ); super.initState(); } @override Widget build(BuildContext context) { return Card( child: AnimatedBuilder( animation: _heightAnimation, child: ..., builder: (context, child) => Container( width: deviceSize.width * 0.75, height: _heightAnimation.value.height, padding: const EdgeInsets.all(16), child: child, ), ), ); } } ``` ### 使用 `AnimatedContainer` 小部件建立動畫 當動畫使用者為 `Container` 小部件時,可以直接用 `AnimatedContainer` 小部件取代 `Container` 小部件 其提供了 `duration` 與 `curve` 屬性可直接生成動畫效果 舉例如下: ```dart= class _MyCardState extends State<MyCard> { @override Widget build(BuildContext context) { return Card( child: AnimatedContainer( // 將原本的 Container 替換為 AnimatedContainer 小部件 duration: Duration(milliseconds: 300), // 設置動畫執行的時間 curve: Curves.easeIn, // 設置動畫開始與結束的過場速度模式 height: _authMode == AuthMode.Login ? 260 : 320, // 設置需要動畫效果的內容 width: deviceSize.width * 0.75, padding: const EdgeInsets.all(16), child: ..., ), ); } } ``` >其他動畫小部件使用說明: >https://docs.flutter.dev/development/ui/widgets/animation ### 幫圖片小部件設定預設圖片,載入後出現淡入淡出動畫效果 當使用 network 方式顯示圖片時,在圖片下載完成之前,可以使用 assets 資料夾中的圖片當成預設圖片, 再於圖片下載完成之後,通過淡入淡出的效果,將預設圖片替換為實際抓取的圖片網址。 原本的圖片小部件: ```dart= child: Image.network( myImgUrl, // 設置圖片網址 fit: BoxFit.contain, // 設置圖片的 fit 值 alignment: Alignment.topCenter, // 設置圖片的對齊方式 ), ``` 加入淡入淡出小部件: ```dart= child: FadeInImage.assetNetwork( // 將原本的小部件改為 FadeInImage.assetNetwork placeholder: 'assets/images/product-placeholder.png', // 設置 placeholder (預設顯示的圖片) image: myImgUrl, // 設置實際要使用的圖片網址 fit: BoxFit.contain, // 設置圖片的 fit 值 alignment: Alignment.topCenter, // 設置圖片的對齊方式 ), ``` ### 用兩個不同路由中的相同物件,在切換時顯示過場動畫 以圖片小部件為例,假設在一個商品總覽頁,點擊縮圖後可以進入商品細節頁,而商品細節頁中的縮圖將會放大,此時就可以使用 Hero 小部件完成過場動畫效果。 使用方式: 1. 在商品總覽頁中找到圖片小部件,用 Hero 小部件把圖片小部件包起來,並設置一個唯一的 tag 值 2. 在商品細節頁中找到圖片小部件,用 Hero 小部件把圖片小部件包起來,並設置商品總覽頁中的同一個 tag 值即可 ```dart= // 商品總覽頁 child: Hero( tag: product.id, child: FadeInImage.assetNetwork( placeholder: 'assets/images/product-placeholder.png', image: product.imgUrl, fit: BoxFit.contain, alignment: Alignment.topCenter, ), ), // 商品細節頁 child: Hero( tag: product.id, child: Image.network( product.imgUrl, fit: BoxFit.contain, alignment: Alignment.topCenter, ), ), ``` ### 路由之間的過場動畫 原本過場動畫是左右滑動切換,假設想自定義動畫為淡入淡出,則可以自行建立一個 `.dart` 檔案: ```dart= import 'package:flutter/material.dart'; // 首先引入 material // 1. 設置給單個路由使用 class CustomRoute extends MaterialPageRoute { // 建立一個 class 繼承 MaterialPageRoute CustomRoute({WidgetBuilder builder, RouteSettings settings}) : super(builder: builder, settings: settings); @override Widget buildTransitions( BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child, ) { if (settings.name == '/') { // 判斷是否為預設頁面 return child; } return FadeTransition( // 如為其他頁面則使用 FadeTransition opacity: animation, // 傳入動畫效果 child: child, ); } } // 2. 設置給所有路由使用 class CustomPageTransitionsBuilder extends PageTransitionsBuilder { // 建立一個 class 繼承 PageTransitionsBuilder @override Widget buildTransitions<T>( // 將 buildTransitions 設置為泛型<T> PageRoute route, // 取得路由 BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child, ) { if (route.settings.name == '/') { // 判斷是否為預設頁面 return child; } return FadeTransition( // 如為其他頁面則使用 FadeTransition opacity: animation, // 傳入動畫效果 child: child, ); } } ``` 接著使用建立好的自定義動畫設定為想要的路由過場動畫的兩種方式: ```dart= // 1. 從 onTap 之類的函數中設定單個路由的過場動畫 Navigator.of(context).pushReplacement(CustomRoute( // 使用 pushReplacement 切換路由,並傳入自定義的 CustomRoute builder: (ctx) => UserProductsScreen(), )); // 2. 從 Theme 中設定所有路由的過場動畫 pageTransitionsTheme: PageTransitionsTheme(builders: { TargetPlatform.android: CustomPageTransitionsBuilder(), // 使用 TargetPlatform 判斷設備,並傳入自定義的 CustomPageTransitionsBuilder TargetPlatform.iOS: CustomPageTransitionsBuilder(), // 使用 TargetPlatform 判斷設備,並傳入自定義的 CustomPageTransitionsBuilder }) ```