Flutter学习指南:交互、手势和动画

    xiaoxiao2025-04-23  14

    Flutter学习指南: 4. UI布局和控件 3. 熟悉Dart语言 2. 编写第一个应用 1. 开发环境搭建

    本文是第5篇,建议大家收藏起来慢慢阅读,也欢迎分享给更多人。在这一篇文章中,我们首先介绍手势事件的处理和页面跳转的基础知识,然后通过实现一个 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。

    现在,整体的效果是这个样子的:

    message-list

    如果你遇到了麻烦,在 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,     );   } }

    更多的动画控件,读者可以参考 https://flutter.io/widgets/animation/。

    自定义动画效果

    上一节我们使用 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 改变它运动的速度,这些就留给读者作为练习吧。

    原文 https://mp.weixin.qq.com/s?__biz=MzIwMTAzMTMxMg==&mid=2649493373&idx=1&sn=554adfc44d9acd226e5a8d0b3c8c118e&chksm=8eec8482b99b0d942e276a70ad820a87b869c4555643590de518980e406d9863fc1bddec52e8&scene=21#wechat_redirect

    最新回复(0)