Flutter學習指南:交互、手勢和動畫

Flutter學習指南 系列文章
UI佈局和控件
熟悉Dart語言
編寫第一個應用
開發環境搭建html

在這一篇文章中,咱們首先介紹手勢事件的處理和頁面跳轉的基礎知識,而後經過實現一個 echo 客戶端的前端頁面來增強學習;最後咱們再學習內置的動畫 Widget 以及如何自定義動畫效果。前端

手勢處理

按鈕點擊

爲了獲取按鈕的點擊事件,只須要設置 onPressed 參數就能夠了:git

class TestWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return RaisedButton(
      child: Text('click'),
      onPressed: () => debugPrint('clicked'),
    );
  }
}
複製代碼

任意控件的手勢事件

跟 button 不一樣,大多數的控件沒有手勢事件監聽函數能夠設置,爲了監聽這些控件上的手勢事件,咱們須要使用另外一個控件——GestureDetector(沒錯,它也是一個控件):github

class TestWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      child: Text('text'),
      onTap: () => debugPrint('clicked'),
    );
  }
}
複製代碼

除了上面代碼使用到的 onTap,GestureDetector 還支持許多其餘事件:web

  • 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;豎直方向的相似)shell

這裏要說明的是,onVerticalXXX/onHorizontalXXX 和 onPanXXX 不能同時設置。若是同時須要水平、豎直方向的移動,使用 onPanXXX。編程

若是讀者但願在用戶點擊的時候可以有個水波紋效果,可使用 InkWell,它的用法跟 GestureDetector 相似,只是少了拖動相關的手勢(畢竟,這個水波紋效果只有在點擊的時候纔有意義)。bash

原始手勢事件監聽

GestureDetector 在絕大部分時候都可以知足咱們的需求,若是真的知足不了,咱們還可使用最原始的 Listener 控件。app

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 管理的棧中。less

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
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}');
    });
複製代碼

下面咱們來畫一個小圓點,讓它往復不斷地在正弦曲線上運動。

先來實現小圓點沿着曲線運動的效果:

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 改變它運動的速度,這些就留給讀者做爲練習吧。

編程·思惟·職場
歡迎掃碼關注

相關文章
相關標籤/搜索