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

栏目: ASP.NET · 发布时间: 7年前

内容简介:在这一篇文章中,我们首先介绍手势事件的处理和页面跳转的基础知识,然后通过实现一个 echo 客户端的前端页面来加强学习;最后我们再学习内置的动画 Widget 以及如何自定义动画效果。为了获取按钮的点击事件,只需要设置 onPressed 参数就可以了:跟 button 不同,大多数的控件没有手势事件监听函数可以设置,为了监听这些控件上的手势事件,我们需要使用另一个控件——GestureDetector(没错,它也是一个控件):

在这一篇文章中,我们首先介绍手势事件的处理和页面跳转的基础知识,然后通过实现一个 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。

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

Flutter学习指南:交互、手势和动画
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,
    );
  }
}
复制代码

更多的动画控件,读者可以参考 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}');
    });
复制代码

下面我们来画一个小圆点,让它往复不断地在正弦曲线上运动。

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

先来实现小圆点沿着曲线运动的效果:

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

编程·思维·职场

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


以上所述就是小编给大家介绍的《Flutter学习指南:交互、手势和动画》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

The Linux Command Line

The Linux Command Line

William E. Shotts Jr. / No Starch Press, Incorporated / 2012-1-17 / USD 39.95

You've experienced the shiny, point-and-click surface of your Linux computer-now dive below and explore its depths with the power of the command line. The Linux Command Line takes you from your very ......一起来看看 《The Linux Command Line》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具