從零開始的 Flutter 動畫

ColdStone發表於2020-04-27

前言

動畫本質是在一段時間內不斷改變螢幕上顯示的內容,從而產生視覺暫留現象。

動畫一般可分為兩類:

補間動畫:補間動畫是一種預先定義物體運動的起點和終點,物體的運動方式,運動時間,時間曲線,然後從起點過渡到終點的動畫。

基於物理的動畫:基於物理的動畫是一種模擬現實世界運動的動畫,通過建立運動模型來實現。例如一個籃球?從高處落下,需要根據其下落高度,重力加速度,地面反彈力等影響因素來建立運動模型。

Flutter 中的動畫

Flutter 中有多種型別的動畫,先從一個簡單的例子開始,使用一個 AnimatedContainer 控制元件,然後設定動畫時長 duration,最後呼叫 setState 方法改變需要變化的屬性值,一個動畫就建立了。

animated-container
animated-container

程式碼如下

import 'package:flutter/material.dart';

class AnimatedContainerPage extends StatefulWidget {
  @override
  _AnimatedContainerPageState createState() => _AnimatedContainerPageState();
}

class _AnimatedContainerPageState extends State<AnimatedContainerPage{
  // 初始的屬性值
  double size = 100;
  double raidus = 25;
  Color color = Colors.yellow;

  void _animate() {
    // 改變屬性值
    setState(() {
      size = size == 100 ? 200 : 100;
      raidus = raidus == 25 ? 100 : 25;
      color = color == Colors.yellow ? Colors.greenAccent : Colors.yellow;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Animated Container')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            // 在 AnimatedContainer 上應用屬性值
            AnimatedContainer(
              width: size,
              height: size,
              curve: Curves.easeIn,
              padding: const EdgeInsets.all(20.0),
              decoration: BoxDecoration(
                color: color,
                borderRadius: BorderRadius.circular(raidus),
              ),
              duration: Duration(seconds: 1),
              child: FlutterLogo(),
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _animate,
        child: Icon(Icons.refresh),
      ),
    );
  }
}

複製程式碼

這是一個隱式動畫,除此之外還有顯式動畫,Hreo 動畫,交織動畫。

基礎概念

Flutter 動畫是建立在以下的概念之上。

Animation

Flutter 中的動畫系統基於 Animation 物件, 它是一個抽象類,儲存了當前動畫的值和狀態(開始、暫停、前進、倒退),但不記錄螢幕上顯示的內容。UI 元素通過讀取 Animation 物件的值和監聽狀態變化執行 build 函式,然後渲染到螢幕上形成動畫效果。

一個 Animation 物件在一段時間內會持續生成介於兩個值之間的值,比較常見的型別是 Animation<double>,除 double 型別之外還有 Animation<Color> 或者 Animation<Size> 等。

abstract class Animation<Textends Listenable implements ValueListenable<T{
  /// ...
}
複製程式碼

AnimationController

帶有控制方法的 Animation 物件,用來控制動畫的啟動,暫停,結束,設定動畫執行時間等。

class AnimationController extends Animation<double>
  with AnimationEagerListenerMixinAnimationLocalListenersMixinAnimationLocalStatusListenersMixin 
{
  /// ...
}

AnimationController controller = AnimationController(
  vsync: this,
  duration: Duration(seconds: 10),
);
複製程式碼

Tween

用來生成不同型別和範圍的動畫取值。

class Tween<T extends dynamicextends Animatable<T{
  Tween({ this.begin, this.end });
  /// ...
}

// double 型別
Tween<double> tween = Tween<double>(begin: -200, end: 200);

// color 型別
ColorTween colorTween = ColorTween(begin: Colors.blue, end: Colors.yellow);

// border radius 型別
BorderRadiusTween radiusTween = BorderRadiusTween(
  begin: BorderRadius.circular(0.0),
  end: BorderRadius.circular(150.0),
);
複製程式碼

Curve

Flutter 動畫的預設動畫過程是勻速的,使用 CurvedAnimation 可以將時間曲線定義為非線性曲線。

class CurvedAnimation extends Animation<doublewith AnimationWithParentMixin<double{
  /// ...
}

Animation animation = CurvedAnimation(parent: controller, curve: Curves.easeIn);
複製程式碼

Ticker

Ticker 用來新增每次螢幕重新整理的回撥函式 TickerCallback,每次螢幕重新整理都會呼叫。類似於 Web 裡面的 requestAnimationFrame 方法。

class Ticker {
  /// ...
}

Ticker ticker = Ticker(callback);
複製程式碼

隱式動畫

隱式動畫使用 Flutter 框架內建的動畫部件建立,通過設定動畫的起始值和最終值來觸發。當使用 setState 方法改變部件的動畫屬性值時,框架會自動計算出一個從舊值過渡到新值的動畫。

比如 AnimatedOpacity 部件,改變它的 opacity 值就可以觸發動畫。

opacity-toggle
opacity-toggle
import 'package:flutter/material.dart';

class OpacityChangePage extends StatefulWidget {
  @override
  _OpacityChangePageState createState() => _OpacityChangePageState();
}

class _OpacityChangePageState extends State<OpacityChangePage{
  double _opacity = 1.0;

  // 改變目標值
  void _toggle() {
    _opacity = _opacity > 0 ? 0.0 : 1.0;
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('隱式動畫')),
      body: Center(
        child: AnimatedOpacity(
          // 傳入目標值
          opacity: _opacity,
          duration: Duration(seconds: 1),
          child: Container(
            width: 200,
            height: 200,
            color: Colors.blue,
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _toggle,
        child: Icon(Icons.play_arrow),
      ),
    );
  }
}

複製程式碼

除了 AnimatedOpacity 外,還有其他的內建隱式動畫部件如:AnimatedContainer, AnimatedPadding, AnimatedPositioned, AnimatedSwitcherAnimatedAlign 等。

顯式動畫

顯式動畫指的是需要手動設定動畫的時間,運動曲線,取值範圍的動畫。將值傳遞給動畫部件如: RotationTransition,最後使用一個AnimationController 控制動畫的開始和結束。

explicit-animation
explicit-animation
import 'dart:math';
import 'package:flutter/material.dart';

class RotationAinmationPage extends StatefulWidget {
  @override
  _RotationAinmationPageState createState() => _RotationAinmationPageState();
}

class _RotationAinmationPageState extends State<RotationAinmationPage>
    with SingleTickerProviderStateMixin 
{
  AnimationController _controller;
  Animation<double> _turns;
  bool _playing = false;

  // 控制動畫執行狀態
  void _toggle() {
    if (_playing) {
      _playing = false;
      _controller.stop();
    } else {
      _controller.forward()..whenComplete(() => _controller.reverse());
      _playing = true;
    }
    setState(() {});
  }

  @override
  void initState() {
    super.initState();
    // 初始化動畫控制器,設定動畫時間
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 10),
    );

    // 設定動畫取值範圍和時間曲線
    _turns = Tween(begin: 0.0, end: pi * 2).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeIn),
    );
  }

  @override
  void dispose() {
    super.dispose();
    _controller.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('顯示動畫')),
      body: Center(
        child: RotationTransition(
          // 傳入動畫值
          turns: _turns,
          child: Container(
            width: 200,
            height: 200,
            child: Image.asset(
              'assets/images/fan.png',
              fit: BoxFit.cover,
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _toggle,
        child: Icon(_playing ? Icons.pause : Icons.play_arrow),
      ),
    );
  }
}

複製程式碼

除了 RotationTransition 外,還有其他的顯示動畫部件如:FadeTransition, ScaleTransition, SizeTransition, SlideTransition 等。

Hero 動畫

Hero 動畫指的是在頁面切換時一個元素從舊頁面運動到新頁面的動畫。Hero 動畫需要使用兩個 Hero 控制元件實現:一個用來在舊頁面中,另一個在新頁面。兩個 Hero 控制元件需要使用相同的 tag 屬性,並且不能與其他tag重複。

hero-animation
hero-animation
// 頁面 1
import 'package:flutter/material.dart';

import 'hero_animation_page2.dart';

String cake1 = 'assets/images/cake01.jpg';
String cake2 = 'assets/images/cake02.jpg';

class HeroAnimationPage1 extends StatelessWidget {
  GestureDetector buildRowItem(context, String image) {
    return GestureDetector(
      onTap: () {
        // 跳轉到頁面 2
        Navigator.of(context).push(
          MaterialPageRoute(builder: (ctx) {
            return HeroAnimationPage2(image: image);
          }),
        );
      },
      child: Container(
        width: 100,
        height: 100,
        child: Hero(
          // 設定 Hero 的 tag 屬性
          tag: image,
          child: ClipOval(child: Image.asset(image)),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('頁面 1')),
      body: Column(
        children: <Widget>[
          SizedBox(height: 40.0),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: <Widget>[
              buildRowItem(context, cake1),
              buildRowItem(context, cake2),
            ],
          ),
        ],
      ),
    );
  }
}

// 頁面 2
import 'package:flutter/material.dart';

class HeroAnimationPage2 extends StatelessWidget {
  final String image;

  const HeroAnimationPage2({@required this.image});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: <Widget>[
          SliverAppBar(
            expandedHeight: 400.0,
            title: Text('頁面 2'),
            backgroundColor: Colors.grey[200],
            flexibleSpace: FlexibleSpaceBar(
              collapseMode: CollapseMode.parallax,
              background: Hero(
                // 使用從頁面 1 傳入的 tag 值
                tag: image,
                child: Container(
                  decoration: BoxDecoration(
                    image: DecorationImage(
                      image: AssetImage(image),
                      fit: BoxFit.cover,
                    ),
                  ),
                ),
              ),
            ),
          ),
          SliverList(
            delegate: SliverChildListDelegate(
              <Widget>[
                Container(height: 600.0, color: Colors.grey[200]),
              ],
            ),
          ),
        ],
      ),
    );
  }
}


複製程式碼

交織動畫

交織動畫是由一系列的小動畫組成的動畫。每個小動畫可以是連續或間斷的,也可以相互重疊。其關鍵點在於使用 Interval 部件給每個小動畫設定一個時間間隔,以及為每個動畫的設定一個取值範圍 Tween,最後使用一個 AnimationController 控制總體的動畫狀態。

Interval 繼承至 Curve 類,通過設定屬性 beginend 來確定這個小動畫的執行範圍。

class Interval extends Curve {
  /// 動畫起始點
  final double begin;
  /// 動畫結束點
  final double end;
  /// 動畫緩動曲線
  final Curve curve;

  /// ...
}

複製程式碼
staggered-animation
staggered-animation

這是一個由 5 個小動畫組成的交織動畫,寬度,高度,顏色,圓角,邊框,每個動畫都有自己的動畫區間。

staggered-animation-timeline
staggered-animation-timeline
import 'package:flutter/material.dart';

class StaggeredAnimationPage extends StatefulWidget {
  @override
  _StaggeredAnimationPageState createState() => _StaggeredAnimationPageState();
}

class _StaggeredAnimationPageState extends State<StaggeredAnimationPage>
    with SingleTickerProviderStateMixin 
{
  AnimationController _controller;
  Animation<double> _width;
  Animation<double> _height;
  Animation<Color> _color;
  Animation<double> _border;
  Animation<BorderRadius> _borderRadius;

  void _play() {
    if (_controller.isCompleted) {
      _controller.reverse();
    } else {
      _controller.forward();
    }
  }

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 5),
    );

    _width = Tween<double>(
      begin: 100,
      end: 300,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(
          0.0,
          0.2,
          curve: Curves.ease,
        ),
      ),
    );

    _height = Tween<double>(
      begin: 100,
      end: 300,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(
          0.2,
          0.4,
          curve: Curves.ease,
        ),
      ),
    );

    _color = ColorTween(
      begin: Colors.blue,
      end: Colors.yellow,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(
          0.4,
          0.6,
          curve: Curves.ease,
        ),
      ),
    );

    _borderRadius = BorderRadiusTween(
      begin: BorderRadius.circular(0.0),
      end: BorderRadius.circular(150.0),
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(
          0.6,
          0.8,
          curve: Curves.ease,
        ),
      ),
    );

    _border = Tween<double>(
      begin: 0,
      end: 25,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(0.81.0),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('交織動畫')),
      body: Center(
        child: AnimatedBuilder(
          animation: _controller,
          builder: (BuildContext context, Widget child) {
            return Container(
              width: _width.value,
              height: _height.value,
              decoration: BoxDecoration(
                color: _color.value,
                borderRadius: _borderRadius.value,
                border: Border.all(
                  width: _border.value,
                  color: Colors.orange,
                ),
              ),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _play,
        child: Icon(Icons.refresh),
      ),
    );
  }
}

複製程式碼

物理動畫

物理動畫是一種模擬現實世界物體運動的動畫。需要建立物體的運動模型,以一個物體下落為例,這個運動受到物體的下落高度,重力加速度,地面的反作用力等因素的影響。

throw-animation
throw-animation
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

class ThrowAnimationPage extends StatefulWidget {
  @override
  _ThrowAnimationPageState createState() => _ThrowAnimationPageState();
}

class _ThrowAnimationPageState extends State<ThrowAnimationPage{
  // 球心高度
  double y = 70.0;
  // Y 軸速度
  double vy = -10.0;
  // 重力
  double gravity = 0.1;
  // 地面反彈力
  double bounce = -0.5;
  // 球的半徑
  double radius = 50.0;
  // 地面高度
  final double height = 700;

  // 下落方法
  void _fall(_) {
    y += vy;
    vy += gravity;

    // 如果球體接觸到地面,根據地面反彈力改變球體的 Y 軸速度
    if (y + radius > height) {
      y = height - radius;
      vy *= bounce;
    } else if (y - radius < 0) {
      y = 0 + radius;
      vy *= bounce;
    }

    setState(() {});
  }

  @override
  void initState() {
    super.initState();
    // 使用一個 Ticker 在每次更新介面時執行球體下落方法
    Ticker(_fall)..start();
  }

  @override
  Widget build(BuildContext context) {
    double screenWidth = MediaQuery.of(context).size.width;

    return Scaffold(
      appBar: AppBar(title: Text('物理動畫')),
      body: Column(
        children: <Widget>[
          Container(
            height: height,
            child: Stack(
              children: <Widget>[
                Positioned(
                  top: y - radius,
                  left: screenWidth / 2 - radius,
                  child: Container(
                    width: radius * 2,
                    height: radius * 2,
                    decoration: BoxDecoration(
                      color: Colors.blue,
                      shape: BoxShape.circle,
                    ),
                  ),
                ),
              ],
            ),
          ),
          Expanded(child: Container(color: Colors.blue)),
        ],
      ),
    );
  }
}

複製程式碼

總結

本文介紹了 Flutter 中多種型別的動畫,分別是

  • 隱式動畫
  • 顯式動畫
  • Hero 動畫
  • 交織動畫
  • 基於物理的動畫

Flutter 動畫基於型別化的 Animation 物件,Widgets 通過讀取動畫物件的當前值和監聽狀態變化重新執行 build 函式,不斷變化 UI 形成動畫效果。

一個動畫的主要因素有

  • Animation 動畫物件
  • AnimationController 動畫控制器
  • Tween 動畫取值範圍
  • Curve 動畫運動曲線

參考

Flutter animation basics with implicit animations

Directional animations with built-in explicit animations

動畫效果介紹

Flutter動畫簡介

在 Flutter 應用裡實現動畫效果

本文使用 mdnice 排版

相關文章