Flutter動畫:用Flutter來實現一個拍手動畫

依然範特稀西發表於2019-09-30

文章地址:mrw.so/4VPxov 譯者:依然範特稀西

在本文中,我們將通過在Flutter中建立一個拍手動畫的模型,來學習一些有關動畫的核心概念。

就像標題中所說的那樣,本文將更多地關注動畫,而不會關注Flutter的基礎知識。

正文開始

我們將從建立一個新的Flutter專案生成的程式碼開始,建立一個新的Flutter專案,你就會得到下面的程式碼:

import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);
  final String title;

  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: new Center(
        child: new Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            new Text(
              'You have pushed the button this many times:',
            ),
            new Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1,
            ),
          ],
        ),
      ),
      floatingActionButton: new FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: new Icon(Icons.add),
      ),
    );
  }
}
複製程式碼

Flutter為我們提供了一些免費的入門程式碼,它為我們建立了一個浮動操作按鈕,並且自動幫我們管理計數的狀態。

Flutter動畫:用Flutter來實現一個拍手動畫

下圖是我們最終要實現的效果:

Flutter動畫:用Flutter來實現一個拍手動畫

在新增動畫之前,讓我們快速解決一些簡單的問題:

  • 更改按鈕圖示和背景。

  • 按住按鈕時,按鈕應繼續增加計數。

讓我們快速解決上面2個問題,然後開始實現動畫:

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  final duration = new Duration(milliseconds: 300);
  Timer timer;


  initState() {
    super.initState();
  }

  dispose() {
   super.dispose();
  }

  void increment(Timer t) {
    setState(() {
      _counter++;
    });
  }

  void onTapDown(TapDownDetails tap) {
    // User pressed the button. This can be a tap or a hold.
    increment(null); // Take care of tap
    timer = new Timer.periodic(duration, increment); // Takes care of hold
  }

  void onTapUp(TapUpDetails tap) {
    // User removed his finger from button.
    timer.cancel();
  }

  Widget getScoreButton() {

    return new Positioned(
        child: new Opacity(opacity: 1.0, child: new Container(
            height: 50.0 ,
            width: 50.0 ,
            decoration: new ShapeDecoration(
              shape: new CircleBorder(
                  side: BorderSide.none
              ),
              color: Colors.pink,
            ),
            child: new Center(child:
            new Text("+" + _counter.toString(),
              style: new TextStyle(color: Colors.white,
                  fontWeight: FontWeight.bold,
                  fontSize: 15.0),))
        )),
        bottom: 100.0
    );
  }

  Widget getClapButton() {
    // Using custom gesture detector because we want to keep increasing the claps
    // when user holds the button.
    return new GestureDetector(
        onTapUp: onTapUp,
        onTapDown: onTapDown,
        child: new Container(
          height: 60.0 ,
          width: 60.0 ,
          padding: new EdgeInsets.all(10.0),
          decoration: new BoxDecoration(
              border: new Border.all(color: Colors.pink, width: 1.0),
              borderRadius: new BorderRadius.circular(50.0),
              color: Colors.white,
              boxShadow: [
                new BoxShadow(color: Colors.pink, blurRadius: 8.0)
              ]
          ),
          child: new ImageIcon(
              new AssetImage("images/clap.png"), color: Colors.pink,
              size: 40.0),
        )
    );
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(widget.title),
      ),
      body: new Center(
        child: new Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            new Text(
              'You have pushed the button this many times:',
            ),
            new Text(
              '$_counter',
              style: Theme
                  .of(context)
                  .textTheme
                  .display1,
            ),
          ],
        ),
      ),
      floatingActionButton: new Padding(
          padding: new EdgeInsets.only(right: 20.0),
          child: new Stack(
            alignment: FractionalOffset.center,
            overflow: Overflow.visible,
            children: <Widget>[
              getScoreButton(),
              getClapButton(),
            ],
          )
      ),
    );
  }
}
複製程式碼

看了上面最終的效果圖,我們需要做2件事:

  • 更改widgets的大小。
  • 按下按鈕時顯示分數widget,釋放按鈕時將其隱藏。
  • 新增這些小巧的widget併為其設定動畫。

讓我們一個接一個地慢慢增加學習曲線。首先,我們需要了解有關Flutter動畫的一些基本知識。

瞭解Flutter中基本動畫的元件

動畫不過是隨著時間變化的一些值,例如,當我們點選按鈕時,我們希望用動畫來讓顯示分數widget 從底部升起,而當手指離開按鈕時,繼續上升然後隱藏。

如果僅看分數Widget,我們需要在一段時間內更改Widget的位置和不透明度值。

new Positioned(
        child: new Opacity(opacity: 1.0, 
          child: new Container(
            ...
          )),
        bottom: 100.0
    );
複製程式碼

假設我們希望分數widget需要150毫秒才能從底部顯示出來。在以下時間軸上考慮一下:

Flutter動畫:用Flutter來實現一個拍手動畫

這是一個簡單的2D圖形。 position將隨著時間而改變。 請注意,對角線是直線。如果你喜歡,它也可以是曲線。

你可以使position隨時間緩慢增加,然後變得越來越快。或者,你也可以讓它以超高速進入,然後在最後放慢速度。

下面是我們介紹的第一個元件:Animation Controller

scoreInAnimationController = new AnimationController(duration: new Duration(milliseconds: 150), vsync: this);
複製程式碼

在這裡,我們為動畫建立了一個簡單的控制器(Controller)。我們已經指定希望動畫執行150ms。但是,vsync是什麼東西?

移動裝置每隔幾毫秒重新整理一次螢幕。這就是我們將一組影象視為連續流或電影的方式。

螢幕重新整理的速率因裝置而異。 假設移動裝置每秒重新整理螢幕60次(每秒60幀)。 那就是每16.67毫秒之後,我們就會向大腦提供新的影象。 有時,影象就會錯位(在螢幕重新整理時發出不同的影象),並且看到螢幕撕裂。 VSync就是解決這個問題的。

我們給控制器設定一個監聽器,然後開始動畫:

scoreInAnimationController.addListener(() {
      print(scoreInAnimationController.value);
    });
scoreInAnimationController.forward(from: 0.0);
/* OUTPUT
I/flutter ( 1913): 0.0
I/flutter ( 1913): 0.0
I/flutter ( 1913): 0.22297333333333333
I/flutter ( 1913): 0.3344533333333333
I/flutter ( 1913): 0.4459333333333334
I/flutter ( 1913): 0.5574133333333334
I/flutter ( 1913): 0.6688933333333335
I/flutter ( 1913): 0.7803666666666668
I/flutter ( 1913): 0.8918466666666668
I/flutter ( 1913): 1.0
*/
複製程式碼

控制器在150ms內生成了0.01.0的數字。請注意,生成的值幾乎是線性的。 0.20.30.4…我們如何改變這種行為?這將在第二部分完成:曲線動畫

曲線動畫
bounceInAnimation = new CurvedAnimation(parent: scoreInAnimationController, curve: Curves.bounceIn);
    bounceInAnimation.addListener(() {
      print(bounceInAnimation.value);
    });

/*OUTPUT
I/flutter ( 5221): 0.0
I/flutter ( 5221): 0.0
I/flutter ( 5221): 0.24945376519722218
I/flutter ( 5221): 0.16975716286388898
I/flutter ( 5221): 0.17177866222222238
I/flutter ( 5221): 0.6359024059750003
I/flutter ( 5221): 0.9119433941222221
I/flutter ( 5221): 1.0
*/
複製程式碼

通過將parent屬性設定為我們的控制器,並提供動畫遵循曲線,就可以建立一個CurvedAnimation,Flutter曲線文件頁面上提供了多種曲線供我們選擇:api.flutter.dev/flutter/ani…

控制器在150ms的時間內為曲線動畫Widget提供從0.01.0的值。曲線動畫Widget根據我們設定的曲線對這些值進行插值。

儘管我們得到了0.01.0之間的一系列值,但是我們希望顯示分數的Widget顯示的值為0-100,我們可以簡單地乘以100來得到結果,或者我們可以使用第三個元件:Tween類。

tweenAnimation = new Tween(begin: 0.0, end: 100.0).animate(scoreInAnimationController);
    tweenAnimation.addListener(() {
      print(tweenAnimation.value);
    });

/* Output 
I/flutter ( 2639): 0.0
I/flutter ( 2639): 0.0
I/flutter ( 2639): 33.452000000000005
I/flutter ( 2639): 44.602000000000004
I/flutter ( 2639): 55.75133333333334
I/flutter ( 2639): 66.90133333333334
I/flutter ( 2639): 78.05133333333333
I/flutter ( 2639): 89.20066666666668
I/flutter ( 2639): 100.0
*/
複製程式碼

Tween類生成beginend之間的值,前面我們已經使用過線性的scoreInAnimationController,相反,我們可以使用反彈曲線來獲得不同的值。Tween的優點遠不止這些,你還可以補間其他東西,比如你可以補間color(顏色)offset(偏移量)position(位置)、和其他Widget屬性,從而進一步擴充套件了基礎補間類。

Score Widget 位置動畫

至此,我們已經掌握了足夠的知識,現在可以使我們的得分Widget在按下按鈕時從底部彈出,而在離開時隱藏。

initState() {
    super.initState();
    scoreInAnimationController = new AnimationController(duration: new Duration(milliseconds: 150), vsync: this);
    scoreInAnimationController.addListener((){
      setState(() {}); // Calls render function
    });
  }

void onTapDown(TapDownDetails tap) {
    scoreInAnimationController.forward(from: 0.0);
    ...    
}
Widget getScoreButton() {
    var scorePosition = scoreInAnimationController.value * 100;
    var scoreOpacity = scoreInAnimationController.value;
    return new Positioned(
        child: new Opacity(opacity: scoreOpacity, 
                           child: new Container(...)
                          ),
        bottom: scorePosition
    );
  }
複製程式碼

Flutter動畫:用Flutter來實現一個拍手動畫

如上圖所示,點選按鈕,Score Widget 從底部彈出了,但是這兒還有一個小問題:當多次點選按鈕的時候,score widget 一次又一次的彈出,這是由於上述程式碼中的一個小錯誤。每次點選按鈕時,我們都告訴控制器從0開始,即forward(from: 0.0)

score widget 退出動畫

現在,我們為score Widget 新增退出動畫,首先,我們新增一個列舉來更輕鬆地管理score Widget的狀態。

enum ScoreWidgetStatus {
  HIDDEN,
  BECOMING_VISIBLE,
  BECOMING_INVISIBLE
}
複製程式碼

然後,建立一個退出動畫的控制器,動畫控制器將使score widget的位置從100非線性變化到150。我們還為動畫新增了狀態監聽器。動畫結束後,我們將得分元件的狀態設定為隱藏。

scoreOutAnimationController = new AnimationController(vsync: this, duration: duration);
    scoreOutPositionAnimation = new Tween(begin: 100.0, end: 150.0).animate(
      new CurvedAnimation(parent: scoreOutAnimationController, curve: Curves.easeOut)
    );
    scoreOutPositionAnimation.addListener((){
      setState(() {});
    });
    scoreOutAnimationController.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        _scoreWidgetStatus = ScoreWidgetStatus.HIDDEN;
      }
    });
複製程式碼

當使用者手指離開元件的時候,我們將相應地設定狀態,並啟動300毫秒的計時器。 300毫秒後,我們將為得分元件新增位置和不透明度動畫。

void onTapUp(TapUpDetails tap) {
    // User removed his finger from button.
    scoreOutETA = new Timer(duration, () {
      scoreOutAnimationController.forward(from: 0.0);
      _scoreWidgetStatus = ScoreWidgetStatus.BECOMING_INVISIBLE;
    });
    holdTimer.cancel();
  }
複製程式碼

我們還修改了onTapDown事件以處理某些邊角情況。

void onTapDown(TapDownDetails tap) {
    // User pressed the button. This can be a tap or a hold.
    if (scoreOutETA != null) scoreOutETA.cancel(); // We do not want the score to vanish!
    if (_scoreWidgetStatus == ScoreWidgetStatus.HIDDEN) {
      scoreInAnimationController.forward(from: 0.0);
      _scoreWidgetStatus = ScoreWidgetStatus.BECOMING_VISIBLE;
    }
    increment(null); // Take care of tap
    holdTimer = new Timer.periodic(duration, increment); // Takes care of hold
  }
複製程式碼

最後,我們需要選擇用於score widget的位置和不透明度的控制器值。一個簡單的開關就完成了。

Widget getScoreButton() {
    var scorePosition = 0.0;
    var scoreOpacity = 0.0;
    switch(_scoreWidgetStatus) {
      case ScoreWidgetStatus.HIDDEN:
        break;
      case ScoreWidgetStatus.BECOMING_VISIBLE :
        scorePosition = scoreInAnimationController.value * 100;
        scoreOpacity = scoreInAnimationController.value;
        break;
      case ScoreWidgetStatus.BECOMING_INVISIBLE:
        scorePosition = scoreOutPositionAnimation.value;
        scoreOpacity = 1.0 - scoreOutAnimationController.value;
    }
  return ...
}
複製程式碼

Flutter動畫:用Flutter來實現一個拍手動畫

score widget的執行效果很棒,先彈出然後逐漸消失。

Score Widget 尺寸動畫

到這一步,我們幾乎知道如何在分數增加時也改變大小。讓我們快速新增大小動畫,然後繼續搞火花閃爍效果

我已經更新了ScoreWidgetStatus列舉來保留一個額外的VISIBLE值。現在,我們為size屬性新增一個新的控制器。

    scoreSizeAnimationController = new AnimationController(vsync: this, duration: new Duration(milliseconds: 150));
    scoreSizeAnimationController.addStatusListener((status) {
      if(status == AnimationStatus.completed) {
        scoreSizeAnimationController.reverse();
      }
    });
    scoreSizeAnimationController.addListener((){
      setState(() {});
    });
複製程式碼

控制器在150ms的時間內生成從01的值,完成之後((status == AnimationStatus.completed),又會生成從10的值。這會產生很好的增長和收縮效果。

void increment(Timer t) {
    scoreSizeAnimationController.forward(from: 0.0);
    setState(() {
      _counter++;
    });
複製程式碼

我們需要注意處理列舉的visible屬性情況。為此,我們需要在 T​​ouch down事件中新增一些基本條件。

void onTapDown(TapDownDetails tap) {
    // User pressed the button. This can be a tap or a hold.
    if (scoreOutETA != null) {
      scoreOutETA.cancel(); // We do not want the score to vanish!
    }
    if(_scoreWidgetStatus == ScoreWidgetStatus.BECOMING_INVISIBLE) {
      // We tapped down while the widget was flying up. Need to cancel that animation.
      scoreOutAnimationController.stop(canceled: true);
      _scoreWidgetStatus = ScoreWidgetStatus.VISIBLE;
    }
    else if (_scoreWidgetStatus == ScoreWidgetStatus.HIDDEN ) {
        _scoreWidgetStatus = ScoreWidgetStatus.BECOMING_VISIBLE;
        scoreInAnimationController.forward(from: 0.0);
    }
    increment(null); // Take care of tap
    holdTimer = new Timer.periodic(duration, increment); // Takes care of hold
  }
複製程式碼

最後,我們使用Widget中控制器的值


extraSize = scoreSizeAnimationController.value * 10;
...
height: 50.0 + extraSize,
width: 50.0  + extraSize,
...
複製程式碼

完整的程式碼,可以在github(gist.github.com/Kartik1607/… 中找到。我們同時使用大小和位置動畫。大小動畫需要一些調整,我們最後會介紹。

最後,火花閃爍動畫

在進行火花閃爍動畫之前,我們需要對尺寸動畫進行一些調整。目前,該按鈕已增長太多。解決方法很簡單,我們將額外的乘數從10更改為一個較小的數字。

現在來看看火花閃爍動畫,我們可以看到到火花其實就是位置在變化的5張圖片

我在MS Paint中製作了一個三角形和一個圓形的圖片,並將其儲存為flutter資源。然後,我們就可以將該圖片用作Image asset

在實現動畫之前,讓我們考慮一下定位以及需要完成的一些任務:

  • 1、我們需要定位5個圖片,每張圖片以不同的角度形成一個完整的圓。

  • 2、我們需要根據角度旋轉圖片

  • 3、隨著時間增加圓的半徑

  • 4、需要根據角度和半徑找到座標。

簡單的三角函式給了我們根據角度的正弦和餘弦來獲得xy座標的公式。

var sparklesWidget =
        new Positioned(child: new Transform.rotate(
            angle: currentAngle - pi/2,
            child: new Opacity(opacity: sparklesOpacity,
                child : new Image.asset("images/sparkles.png", width: 14.0, height: 14.0, ))
          ),
          left:(sparkleRadius*cos(currentAngle)) + 20,
          top: (sparkleRadius* sin(currentAngle)) + 20 ,
      );
複製程式碼

現在,我們需要建立5widget。每個widget具有不同的角度。一個簡單的for迴圈就ok了。

for(int i = 0;i < 5; ++i) {
      var currentAngle = (firstAngle + ((2*pi)/5)*(i));
      var sparklesWidget = ...
      stackChildren.add(sparklesWidget);
    }
複製程式碼

2 * pi(360度)分成5個部分,並相應地建立一個widget。然後,我們將widget新增到stackChildren陣列中。

好了,到這一步,大多數的準備工作都做完了,我們只需要設定sparkleRadius的動畫並生成一個新的firstAngle即可。

sparklesAnimationController = new AnimationController(vsync: this, duration: duration);
    sparklesAnimation = new CurvedAnimation(parent: sparklesAnimationController, curve: Curves.easeIn);
    sparklesAnimation.addListener((){
      setState(() { });
    });

 void increment(Timer t) {
    sparklesAnimationController.forward(from: 0.0);
     ...
    setState(() {
    ...
      _sparklesAngle = random.nextDouble() * (2*pi);
    });
     
Widget getScoreButton() {
    ...
    var firstAngle = _sparklesAngle;
    var sparkleRadius = (sparklesAnimationController.value * 50) ;
    var sparklesOpacity = (1 - sparklesAnimation.value);
    ...
}     
複製程式碼

Flutter動畫:用Flutter來實現一個拍手動畫

這就是我們對flutter中的基本動畫介紹。我將繼續探索flutter,學習建立高階UI。

完整程式碼訪問:github.com/Kartik1607/…

相關文章