本文由玉刚说写作平台提供写作赞助
赞助金额:200元
原作者:水晶虾饺
版权声明:本文版权归微信公众号玉刚说所有,未经许可,不得以任何形式转载
在这一篇文章中,我们首先介绍手势事件的处理和页面跳转的基础知识,然后通过实现一个 echo 客户端的前端页面来加强学习;最后我们再学习内置的动画 Widget 以及如何自定义动画效果。
手势处理
按钮点击
为了获取按钮的点击事件,只需要设置 onPressed 参数就可以了:
| 12
 3
 4
 5
 6
 7
 8
 9
 
 | class TestWidget extends StatelessWidget {@override
 Widget build(BuildContext context) {
 return RaisedButton(
 child: Text('click'),
 onPressed: () => debugPrint('clicked'),
 );
 }
 }
 
 | 
任意控件的手势事件
跟 button 不同,大多数的控件没有手势事件监听函数可以设置,为了监听这些控件上的手势事件,我们需要使用另一个控件——GestureDetector(没错,它也是一个控件):
| 12
 3
 4
 5
 6
 7
 8
 9
 
 | 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 控件。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 
 | 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 管理的栈中。
| 12
 3
 4
 
 | Navigator.push(context,
 MaterialPageRoute(builder: (_) => SecondScreen())
 );
 
 | 
需要返回的话,pop 掉就可以了:
下面是完整的例子:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 
 | 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 也支持从页面返回数据:
| 1
 | Navigator.pop(context, 'message from second screen');
 | 
由于打开页面是异步的,页面的结果通过一个 Future 来返回:
| 12
 3
 4
 5
 6
 7
 8
 9
 
 | onPressed: () async {
 
 var msg = await Navigator.push(
 context,
 MaterialPageRoute(builder: (_) => SecondScreen())
 );
 debugPrint('msg = $msg');
 }
 
 | 
我们还可以在 MaterialApp 里设置好每个 route 对应的页面,然后使用 Navigator.pushNamed(context, routeName) 来打开它们:
| 12
 3
 4
 5
 6
 7
 8
 
 | MaterialApp(
 initialRoute: '/',
 routes: {
 '/': (context) => HomeScreen(),
 '/about': (context) => AboutScreen(),
 },
 );
 
 | 
接下来,我们通过实现一个 echo 客户端的前端页面来综合运用前面所学的知识(逻辑部分我们留到下一篇文章再补充)。
echo 客户端
消息输入页
这一节我们来实现一个用户输入的页面。UI 很简单,就是一个文本框和一个按钮。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 
 | class MessageForm extends StatefulWidget {@override
 State createState() {
 return _MessageFormState();
 }
 }
 
 class _MessageFormState extends State<MessageForm> {
 final editController = TextEditingController();
 
 
 
 @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。这里我们先添加一个按钮并实现页面间的跳转。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 
 | class MessageListScreen extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
 return Scaffold(
 appBar: AppBar(
 title: Text('Echo client'),
 ),
 floatingActionButton: FloatingActionButton(
 onPressed: () {
 
 Navigator.push(
 context,
 MaterialPageRoute(builder: (_) => AddMessageScreen())
 );
 },
 tooltip: 'Add message',
 child: Icon(Icons.add),
 )
 );
 }
 }
 
 | 
在消息的输入页面,我们点击 Send 按钮后就返回:
| 12
 3
 4
 
 | onTap: () {debugPrint('send: ${editController.text}');
 Navigator.pop(context);
 }
 
 | 
最后,我们加入一些骨架代码,实现一个完整的应用:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 
 | void main() {runApp(MyApp());
 }
 
 class MyApp extends StatelessWidget {
 
 @override
 Widget build(BuildContext context) {
 return MaterialApp(
 title: 'Flutter UX demo',
 home: MessageListScreen(),
 );
 }
 }
 
 | 
但是,上面代码所提供的功能还不够,我们需要从 AddMessageScreen 中返回一个消息。
首先我们对数据建模:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | class Message {final String msg;
 final int timestamp;
 
 Message(this.msg, this.timestamp);
 
 @override
 String toString() {
 return 'Message{msg: $msg, timestamp: $timestamp}';
 }
 }
 
 | 
下面是返回数据和接收数据的代码:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 
 | 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
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 
 | class MessageList extends StatefulWidget {
 
 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 参数,我们下面先看看如何使用他,然后再说明它的作用:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 
 | 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 上找到所有的代码:
| 12
 3
 
 | git clone https://github.com/Jekton/flutter_demo.gitcd flutter_demo
 git checkout ux-basic
 
 | 
动画
Flutter 动画的核心是 Animation<T>,Animation 接受一个时钟信号(vsync),转换为 T 值输出。它控制着动画的进度和状态,但不参与图像的绘制。最基本的 Animation 是 AnimationController,它输出 [0, 1] 之间的值。
为了使用动画,我们可以用 Flutter 提供的 AnimatedContainer、FadeTransition、ScaleTransition 和 RotationTransition 等 Widget 来完成。
下面我们就来演示如何使用 ScaleTransition:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 
 | 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: this,
 );
 
 controller.forward();
 }
 @override
 Widget build(BuildContext context) {
 return ScaleTransition(
 child: FlutterLogo(size: 200.0),
 scale: controller,
 );
 }
 }
 
 | 
AnimationController 的输出是线性的。非线性的效果可以使用 CurveAnimation 来实现:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 
 | class _AnimWidgetState extends State<AnimWidget>with SingleTickerProviderStateMixin {
 
 AnimationController controller;
 CurvedAnimation curve;
 
 @override
 void initState() {
 super.initState();
 controller = AnimationController(
 
 duration: Duration(milliseconds: 5000),
 
 vsync: this,
 );
 curve = CurvedAnimation(
 parent: controller,
 
 curve: Curves.easeInOut,
 );
 
 controller.forward();
 }
 @override
 Widget build(BuildContext context) {
 return ScaleTransition(
 child: FlutterLogo(size: 200.0),
 
 scale: curve,
 );
 }
 }
 
 | 
当然,我们还可以组合不同的动画:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 
 | 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,
 );
 }
 }
 
 | 
更多的动画控件,读者可以参考 https://flutter.io/widgets/animation/。
自定义动画效果
上一节我们使用 Flutter 内置的 Widget 来实现动画。他们虽然能够完成日常开发的大部分需求,但总有一些时候不太适用。这时我们就得自己实现动画效果了。
前面我们说,AnimationController 的输出在 [0, 1] 之间,这往往对我们需要实现的动画效果不太方便。为了将数值从 [0, 1] 映射到目标空间,可以使用 Tween:
| 12
 3
 4
 5
 6
 7
 
 | animationValue = Tween(begin: 0.0, end: 200.0).animate(controller)
 ..addListener(() {
 
 
 print('value = ${animationValue.value}');
 });
 
 | 
下面我们来画一个小圆点,让它往复不断地在正弦曲线上运动。

先来实现小圆点沿着曲线运动的效果:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 
 | 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();
 
 
 
 
 Future(_initState);
 }
 
 void _initState() {
 controller = AnimationController(
 duration: const Duration(milliseconds: 2000),
 
 
 
 vsync: this);
 
 
 final mediaQueryData = MediaQuery.of(context);
 final displayWidth = mediaQueryData.size.width;
 debugPrint('width = $displayWidth');
 left = Tween(begin: padding, end: displayWidth - padding).animate(controller)
 ..addListener(() {
 
 
 setState(() {
 
 });
 })
 
 ..addStatusListener((status) {
 
 
 
 if (status == AnimationStatus.completed) {
 
 controller.reverse();
 
 } else if (status == AnimationStatus.dismissed) {
 
 controller.forward();
 }
 });
 controller.forward();
 }
 
 @override
 Widget build(BuildContext context) {
 
 final unit = 24.0;
 final marginLeft = left == null ? padding : left.value;
 
 
 final unitizedLeft = (marginLeft - padding) / unit;
 final unitizedTop = math.sin(unitizedLeft);
 
 
 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(),
 ),
 );
 }
 }
 
 | 
上面的动画中,我们只是对位置做出了改变,下面我们将在位置变化的同时,也让小圆点从红到蓝进行颜色的变化。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 
 | 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 上,可以找到所有的代码:
| 12
 3
 
 | git clone https://github.com/Jekton/flutter_demo.gitcd flutter_demo
 git checkout sin-curve
 
 | 
在这个例子中,我们还可以加多一些效果,比方说让小圆点在运动的过程中大小也不断变化、使用 CurveAnimation 改变它运动的速度,这些就留给读者作为练习吧。