Flutter學習指南:互動、手勢和動畫
在這一篇文章中,我們首先介紹手勢事件的處理和頁面跳轉的基礎知識,然後通過實現一個 echo 客戶端的前端頁面來加強學習;最後我們再學習內建的動畫 Widget 以及如何自定義動畫效果。
手勢處理
按鈕點選
為了獲取按鈕的點選事件,只需要設定 onPressed 引數就可以了:
class TestWidget extends StatelessWidget { @override Widget build(BuildContext context) { return RaisedButton( child: Text('click'), onPressed: () => debugPrint('clicked'), ); } } 複製程式碼
任意控制元件的手勢事件
跟 button 不同,大多數的控制元件沒有手勢事件監聽函式可以設定,為了監聽這些控制元件上的手勢事件,我們需要使用另一個控制元件——GestureDetector(沒錯,它也是一個控制元件):
class TestWidget extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( child: Text('text'), onTap: () => debugPrint('clicked'), ); } } 複製程式碼
除了上面程式碼使用到的 onTap,GestureDetector 還支援許多其他事件:
- onTapDown:按下
- onTap:點選動作
- onTapUp:抬起
- onTapCancel:前面觸發了 onTapDown,但並沒有完成一個 onTap 動作
- onDoubleTap:雙擊
- onLongPress:長按
- onScaleStart, onScaleUpdate, onScaleEnd:縮放
- onVerticalDragDown, onVerticalDragStart, onVerticalDragUpdate, onVerticalDragEnd, onVerticalDragCancel, onVerticalDragUpdate:在豎直方向上移動
- onHorizontalDragDown, onHorizontalDragStart, onHorizontalDragUpdate, onHorizontalDragEnd, onHorizontalDragCancel, onHorizontalDragUpdate:在水平方向上移動
- onPanDown, onPanStart, onPanUpdate, onPanEnd, onPanCancel:拖曳(水平、豎直方向上移動)
如果同時設定了 onVerticalXXX 和 onHorizontalXXX,在一個手勢裡,只有一個會觸發(如果使用者首先在水平方向移動,則整個過程只觸發 onHorizontalUpdate;豎直方向的類似)
這裡要說明的是,onVerticalXXX/onHorizontalXXX 和 onPanXXX 不能同時設定。如果同時需要水平、豎直方向的移動,使用 onPanXXX。
如果讀者希望在使用者點選的時候能夠有個水波紋效果,可以使用 InkWell,它的用法跟 GestureDetector 類似,只是少了拖動相關的手勢(畢竟,這個水波紋效果只有在點選的時候才有意義)。
原始手勢事件監聽
GestureDetector 在絕大部分時候都能夠滿足我們的需求,如果真的滿足不了,我們還可以使用最原始的 Listener 控制元件。
class TestWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Listener( child: Text('text'), onPointerDown: (event) => print('onPointerDown'), onPointerUp: (event) => print('onPointerUp'), onPointerMove: (event) => print('onPointerMove'), onPointerCancel: (event) => print('onPointerCancel'), ); } } 複製程式碼
在頁面間跳轉
Flutter 裡所有的東西都是 widget,所以,一個頁面,也是 widget。為了調整到新的頁面,我們可以 push 一個 route 到 Navigator 管理的棧中。
Navigator.push( context, MaterialPageRoute(builder: (_) => SecondScreen()) ); 複製程式碼
需要返回的話,pop 掉就可以了:
Navigator.pop(context); 複製程式碼
下面是完整的例子:
import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter navigation', home: FirstScreen(), ); } } class FirstScreen extends StatefulWidget { @override State createState() { return _FirstScreenState(); } } class _FirstScreenState extends State<FirstScreen> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Navigation deme'),), body: Center( child: RaisedButton( child: Text('First screen'), onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (_) => SecondScreen()) ); } ), ), ); } } class SecondScreen extends StatefulWidget { @override State createState() { return _SecondScreenState(); } } class _SecondScreenState extends State<SecondScreen> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Navigation deme'),), body: Center( child: RaisedButton( child: Text('Second screen'), onPressed: () { Navigator.pop(context); } ), ), ); } } 複製程式碼
除了開啟一個頁面,Flutter 也支援從頁面返回資料:
Navigator.pop(context, 'message from second screen'); 複製程式碼
由於開啟頁面是非同步的,頁面的結果通過一個 Future 來返回:
onPressed: () async { // Navigator.push 會返回一個 Future<T>,如果你對這裡使用的 await不太熟悉,可以參考 // https://www.dartlang.org/guides/language/language-tour#asynchrony-support var msg = await Navigator.push( context, MaterialPageRoute(builder: (_) => SecondScreen()) ); debugPrint('msg = $msg'); } 複製程式碼
我們還可以在 MaterialApp 裡設定好每個 route 對應的頁面,然後使用 Navigator.pushNamed(context, routeName) 來開啟它們:
MaterialApp( // 從名字叫做 '/' 的 route 開始(也就是 home) initialRoute: '/', routes: { '/': (context) => HomeScreen(), '/about': (context) => AboutScreen(), }, ); 複製程式碼
接下來,我們通過實現一個 echo 客戶端的前端頁面來綜合運用前面所學的知識(邏輯部分我們留到下一篇文章再補充)。
echo 客戶端
訊息輸入頁
這一節我們來實現一個使用者輸入的頁面。UI 很簡單,就是一個文字框和一個按鈕。
class MessageForm extends StatefulWidget { @override State createState() { return _MessageFormState(); } } class _MessageFormState extends State<MessageForm> { final editController = TextEditingController(); // 物件被從 widget 樹裡永久移除的時候呼叫 dispose 方法(可以理解為物件要銷燬了) // 這裡我們需要主動再呼叫 editController.dispose() 以釋放資源 @override void dispose() { super.dispose(); editController.dispose(); } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.all(16.0), child: Row( children: <Widget>[ // 我們讓輸入框佔滿一行裡除按鈕外的所有空間 Expanded( child: Container( margin: EdgeInsets.only(right: 8.0), child: TextField( decoration: InputDecoration( hintText: 'Input message', contentPadding: EdgeInsets.all(0.0), ), style: TextStyle( fontSize: 22.0, color: Colors.black54 ), controller: editController, // 自動獲取焦點。這樣在頁面開啟時就會自動彈出輸入法 autofocus: true, ), ), ), InkWell( onTap: () => debugPrint('send: ${editController.text}'), onDoubleTap: () => debugPrint('double tapped'), onLongPress: () => debugPrint('long pressed'), child: Container( padding: EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0), decoration: BoxDecoration( color: Colors.black12, borderRadius: BorderRadius.circular(5.0) ), child: Text('Send'), ), ) ], ), ); } } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter UX demo', home: AddMessageScreen(), ); } } class AddMessageScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Add message'), ), body: MessageForm(), ); } } 複製程式碼
這裡的按鈕本應該使用 RaisedButton 或 FlatButton。為了演示如何監聽手勢事件,我們這裡故意自己用 Container 做了一個按鈕,然後通過 InkWell 監聽手勢事件。InkWell 除了上面展示的幾個事件外,還帶有一個水波紋效果。如果不需要這個水波紋效果,讀者也可以使用 GestureDetector。
訊息列表頁面
我們的 echo 客戶端共有兩個頁面,一個用於展示所有的訊息,另一個頁面使用者輸入訊息,後者在上一小節我們已經寫好了。下面,我們來實現用於展示訊息的頁面。
頁面間跳轉
我們的頁面包含一個列表和一個按鈕,列表用於展示資訊,按鈕則用來開啟上一節我們所實現的 AddMessageScreen。這裡我們先新增一個按鈕並實現頁面間的跳轉。
// 這是我們的訊息展示頁面 class MessageListScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Echo client'), ), floatingActionButton: FloatingActionButton( onPressed: () { // push 一個新的 route 到 Navigator 管理的棧中,以此來開啟一個頁面 Navigator.push( context, MaterialPageRoute(builder: (_) => AddMessageScreen()) ); }, tooltip: 'Add message', child: Icon(Icons.add), ) ); } } 複製程式碼
在訊息的輸入頁面,我們點選 Send 按鈕後就返回:
onTap: () { debugPrint('send: ${editController.text}'); Navigator.pop(context); } 複製程式碼
最後,我們加入一些骨架程式碼,實現一個完整的應用:
void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter UX demo', home: MessageListScreen(), ); } } 複製程式碼
但是,上面程式碼所提供的功能還不夠,我們需要從 AddMessageScreen 中返回一個訊息。
首先我們對資料建模:
class Message { final String msg; final int timestamp; Message(this.msg, this.timestamp); @override String toString() { return 'Message{msg: $msg, timestamp: $timestamp}'; } } 複製程式碼
下面是返回資料和接收資料的程式碼:
onTap: () { debugPrint('send: ${editController.text}'); final msg = Message( editController.text, DateTime.now().millisecondsSinceEpoch ); Navigator.pop(context, msg); }, floatingActionButton: FloatingActionButton( onPressed: () async { final result = await Navigator.push( context, MaterialPageRoute(builder: (_) => AddMessageScreen()) ); debugPrint('result = $result'); }, // ... ) 複製程式碼
把資料展示到 ListView
class MessageList extends StatefulWidget { // 先忽略這裡的引數 key,後面我們就會看到他的作用了 MessageList({Key key}): super(key: key); @override State createState() { return _MessageListState(); } } class _MessageListState extends State<MessageList> { final List<Message> messages = []; @override Widget build(BuildContext context) { return ListView.builder( itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[index]; final subtitle = DateTime.fromMillisecondsSinceEpoch(msg.timestamp) .toLocal().toIso8601String(); return ListTile( title: Text(msg.msg), subtitle: Text(subtitle), ); } ); } void addMessage(Message msg) { setState(() { messages.add(msg); }); } } 複製程式碼
這段程式碼裡唯一的新知識就是給 MessageList 的 key 引數,我們下面先看看如何使用他,然後再說明它的作用:
class MessageListScreen extends StatelessWidget { final messageListKey = GlobalKey<_MessageListState>(debugLabel: 'messageListKey'); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Echo client'), ), body: MessageList(key: messageListKey), floatingActionButton: FloatingActionButton( onPressed: () async { final result = await Navigator.push( context, MaterialPageRoute(builder: (_) => AddMessageScreen()) ); debugPrint('result = $result'); if (result is Message) { messageListKey.currentState.addMessage(result); } }, tooltip: 'Add message', child: Icon(Icons.add), ) ); } } 複製程式碼
引入一個 GlobalKey 的原因在於,MessageListScreen 需要把從 AddMessageScreen 返回的資料放到 _MessageListState 中,而我們無法從 MessageList 拿到這個 state。
GlobalKey 的是應用全域性唯一的 key,把這個 key 設定給 MessageList 後,我們就能夠通過這個 key 拿到對應的 statefulWidget 的 state。
現在,整體的效果是這個樣子的:

如果你遇到了麻煩,在 Github 上找到所有的程式碼:
git clone https://github.com/Jekton/flutter_demo.git cd flutter_demo git checkout ux-basic 複製程式碼
動畫
Flutter 動畫的核心是 Animation,Animation 接受一個時鐘訊號(vsync),轉換為 T 值輸出。它控制著動畫的進度和狀態,但不參與影象的繪製。最基本的 Animation 是 AnimationController,它輸出 [0, 1] 之間的值。
使用內建的 Widget 完成動畫
為了使用動畫,我們可以用 Flutter 提供的 AnimatedContainer、FadeTransition、ScaleTransition 和 RotationTransition 等 Widget 來完成。
下面我們就來演示如何使用 ScaleTransition:
import 'package:flutter/material.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'animation', home: Scaffold( appBar: AppBar(title: Text('animation'),), body: AnimWidget(), ), ); } } // 動畫是有狀態的 class AnimWidget extends StatefulWidget { @override State createState() { return _AnimWidgetState(); } } class _AnimWidgetState extends State<AnimWidget> with SingleTickerProviderStateMixin { var controller; @override void initState() { super.initState(); controller = AnimationController( // 動畫的時長 duration: Duration(milliseconds: 5000), // 提供 vsync 最簡單的方式,就是直接繼承 SingleTickerProviderStateMixin vsync: this, ); // 呼叫 forward 方法開始動畫 controller.forward(); } @override Widget build(BuildContext context) { return ScaleTransition( child: FlutterLogo(size: 200.0), scale: controller, ); } } 複製程式碼
AnimationController 的輸出是線性的。非線性的效果可以使用 CurveAnimation 來實現:
class _AnimWidgetState extends State<AnimWidget> with SingleTickerProviderStateMixin { AnimationController controller; CurvedAnimation curve; @override void initState() { super.initState(); controller = AnimationController( // 動畫的時長 duration: Duration(milliseconds: 5000), // 提供 vsync 最簡單的方式,就是直接繼承 SingleTickerProviderStateMixin vsync: this, ); curve = CurvedAnimation( parent: controller, // 更多的效果,參考 https://docs.flutter.io/flutter/animation/Curves-class.html curve: Curves.easeInOut, ); // 呼叫 forward 方法開始動畫 controller.forward(); } @override Widget build(BuildContext context) { return ScaleTransition( child: FlutterLogo(size: 200.0), // 注意,這裡我們把原先的 controller 改為了 curve scale: curve, ); } } 複製程式碼
當然,我們還可以組合不同的動畫:
class _AnimWidgetState extends State<AnimWidget> with SingleTickerProviderStateMixin { // ... @override Widget build(BuildContext context) { var scaled = ScaleTransition( child: FlutterLogo(size: 200.0), scale: curve, ); return FadeTransition( child: scaled, opacity: curve, ); } } 複製程式碼
更多的動畫控制元件,讀者可以參考 flutter.io/widgets/ani… 。
自定義動畫效果
上一節我們使用 Flutter 內建的 Widget 來實現動畫。他們雖然能夠完成日常開發的大部分需求,但總有一些時候不太適用。這時我們就得自己實現動畫效果了。
前面我們說,AnimationController 的輸出在 [0, 1] 之間,這往往對我們需要實現的動畫效果不太方便。為了將數值從 [0, 1] 對映到目標空間,可以使用 Tween:
animationValue = Tween(begin: 0.0, end: 200.0).animate(controller) // 每一幀都會觸發 listener 回撥 ..addListener(() { // animationValue.value 隨著動畫的進行不斷地變化。我們利用這個值來實現 // 動畫效果 print('value = ${animationValue.value}'); }); 複製程式碼
下面我們來畫一個小圓點,讓它往復不斷地在正弦曲線上運動。

先來實現小圓點沿著曲線運動的效果:
import 'dart:async'; import 'dart:math' as math; import 'package:flutter/animation.dart'; import 'package:flutter/material.dart'; class AnimationDemoView extends StatefulWidget { @override State createState() { return _AnimationState(); } } class _AnimationState extends State<AnimationDemoView> with SingleTickerProviderStateMixin { static const padding = 16.0; AnimationController controller; Animation<double> left; @override void initState() { super.initState(); // 只有在 initState 執行完,我們才能通過 MediaQuery.of(context) 獲取 // mediaQueryData。這裡通過建立一個 Future 從而在 Dart 事件佇列裡插入 // 一個事件,以達到延後執行的目的(類似於在 Android 裡 post 一個 Runnable) // 關於 Dart 的事件佇列,讀者可以參考 https://webdev.dartlang.org/articles/performance/event-loop Future(_initState); } void _initState() { controller = AnimationController( duration: const Duration(milliseconds: 2000), // 注意類定義的 with SingleTickerProviderStateMixin,提供 vsync 最簡單的方法 // 就是繼承一個 SingleTickerProviderStateMixin。這裡的 vsync 跟 Android 裡 // 的 vsync 類似,用來提供時針滴答,觸發動畫的更新。 vsync: this); // 我們通過 MediaQuery 獲取螢幕寬度 final mediaQueryData = MediaQuery.of(context); final displayWidth = mediaQueryData.size.width; debugPrint('width = $displayWidth'); left = Tween(begin: padding, end: displayWidth - padding).animate(controller) ..addListener(() { // 呼叫 setState 觸發他重新 build 一個 Widget。在 build 方法裡,我們根據 // Animatable<T> 的當前值來建立 Widget,達到動畫的效果(類似 Android 的屬性動畫)。 setState(() { // have nothing to do }); }) // 監聽動畫狀態變化 ..addStatusListener((status) { // 這裡我們讓動畫往復不斷執行 // 一次動畫完成 if (status == AnimationStatus.completed) { // 我們讓動畫反正執行一遍 controller.reverse(); // 反著執行的動畫結束 } else if (status == AnimationStatus.dismissed) { // 正著重新開始 controller.forward(); } }); controller.forward(); } @override Widget build(BuildContext context) { // 假定一個單位是 24 final unit = 24.0; final marginLeft = left == null ? padding : left.value; // 把 marginLeft 單位化 final unitizedLeft = (marginLeft - padding) / unit; final unitizedTop = math.sin(unitizedLeft); // unitizedTop + 1 是了把 [-1, 1] 之間的值對映到 [0, 2] // (unitizedTop+1) * unit 後把單位化的值轉回來 final marginTop = (unitizedTop + 1) * unit + padding; return Container( // 我們根據動畫的進度設定圓點的位置 margin: EdgeInsets.only(left: marginLeft, top: marginTop), // 畫一個小紅點 child: Container( decoration: BoxDecoration( color: Colors.red, borderRadius: BorderRadius.circular(7.5)), width: 15.0, height: 15.0, ), ); } @override void dispose() { super.dispose(); controller.dispose(); } } void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter animation demo', home: Scaffold( appBar: AppBar(title: Text('Animation demo')), body: AnimationDemoView(), ), ); } } 複製程式碼
上面的動畫中,我們只是對位置做出了改變,下面我們將在位置變化的同時,也讓小圓點從紅到藍進行顏色的變化。
class _AnimationState extends State<AnimationDemoView> with SingleTickerProviderStateMixin { // ... Animation<Color> color; void _initState() { // ... color = ColorTween(begin: Colors.red, end: Colors.blue).animate(controller); controller.forward(); } @override Widget build(BuildContext context) { // ... final color = this.color == null ? Colors.red : this.color.value; return Container( // 我們根據動畫的進度設定圓點的位置 margin: EdgeInsets.only(left: marginLeft, top: marginTop), // 畫一個小圓點 child: Container( decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(7.5)), width: 15.0, height: 15.0, ), ); } } 複製程式碼
在 GitHub 上,可以找到所有的程式碼:
git clone https://github.com/Jekton/flutter_demo.git cd flutter_demo git checkout sin-curve 複製程式碼
在這個例子中,我們還可以加多一些效果,比方說讓小圓點在運動的過程中大小也不斷變化、使用 CurveAnimation 改變它運動的速度,這些就留給讀者作為練習吧。
程式設計·思維·職場