iT邦幫忙

2024 iThome 鐵人賽

DAY 21
0

終於我們來到了一個進階的議題 - 動畫。在 Flutter 中,很多內建的組件以及可用的插件可以協助我們建置美觀有設計感的應用程式,但除此之外,Fluttter 還支援動畫效果如透明度、旋轉、變換樣式來操作組件,進一步提升使用者體驗。

本章節將進一步深入組件的操作,Flutter 對於動畫有很好的支援,這些動畫效果可以組合,擴展,使得介面生動有趣。我們將學習包含 Tween 和 AnimatedBuilder 來完成動畫效果。

最後我們會探討一些內建動畫效果的組件,這些組件可以輕易的使用,但不一定適合每一種狀況,不過如果只是需要一些簡單的效果時,是不錯的選擇。

這個章節會介紹:

  • 變形效果組件和 Transform 類別
  • 動畫基礎
  • 如何使用動畫效果
  • 使用 AnimatedBuilder
  • 隱式動畫組件

變形效果組件和 Transform 類別

前面的文章到這裡我們已經見過了許多組件,但有時候我們需要改變組件的樣式呈現來改善 UX。為了即時反饋使用者的輸入或者提供一些效果,我們可能需要在螢幕上移動組件,改變其大小,甚至變形。如果你曾經使用原生平台語言開發這類效果,會發現其實這不容易。

如同前面提到的,Flutter 非常關注 UI 設計,目標通過簡化原本很複雜的部分使開發者可以比較容易實現功能。

這裡,我們首先介紹 Transform 組件,因為在操作組件上,這是一個非常實用的組件,後續我們會深入這個組件看看其支援的功能。

Transform 組件

Transform 組件是 Flutter 框架一致性的最佳例子之一。它是一個單一用途的組件,只是簡單的對子元素套用圖形轉換效果。

如其名,Transform 只會執行單一任務:

Center(
  child: Transform(
    alignment: Alignment.center,
    transform: Matrix4.rotationZ(15 * math.pi / 180),
    child: const Text(
      "哈囉,世界",
      style: TextStyle(fontSize: 24),
    ),
  ),
),

如上所見,該組件並不需要很多參數便可完成效果,下面讓我們來看看有哪些參數:

  • transform:這是唯一必須的參數,用於描述變形效果並套用到 child 的組件上。該參數型別為 Matrix4 物件,一個 4D 矩陣以數學的形式描述變形,後續我們會更深入的介紹這個 Matrix4 類別。
  • origin:這是套用變形矩陣座標系統的原點。origin 屬性由 Offset 型別設定,在這個情況下表示的是笛卡爾座標系統中的一個點 (x, y),變形效果會以 origin 為參考點或「軸心」。例如當我們有一個 100 x 100 的方向,預設 origin 為 (50, 50) ,此時旋轉 45 度效果會以中心來旋轉,若改變 origin 為 (0, 0) 旋轉則效果比較接近下垂。總之變形(如旋轉、縮放)會圍繞這個點進行。
  • alignment:類似 origin 可以用來控制套用變形效果的位置。使用這個屬性可以更彈性指定「原點」。因為 origin 須給定實際座標值,而「對齊」使用相對位置,例如 Alignment.center 不管組件的尺寸,可以讓原點設定為中心。如果同時使用兩者,則效果會疊加。Flutter 會先套用 alignment 然後再以 origin 進行相對位置的調整。
  • transformHitTests:設定是否在變形後的組件版本中分析命中測試(也就是,點擊)。在 UI 開發中,命中測試是用來確認使用者點擊是否落在特定 UI 元素。而 transformHitTeststrue 時,點擊區域會隨著組件變形而變化,若為 false 那麼點擊區域保持為原始位置,不受變形影響。
  • filterQuality:設定子組件在變形狀態下重現時的視覺品質,將其保留為 null 會使子組件保持其原始視覺狀態。它控制在進行縮放或旋轉等變形操作時,如何處理像素以維持圖像質量。當變形可能導致圖像質量下降時(如放大或傾斜),使用適當的 filterQuality 可以改善視覺效果,但較高的設定會增加效能的負擔。
  • child:套用變形的組件。

了解 Matrix4

在 Flutter,變形使用 4D 矩陣來呈現。雖然聽起來好像很複雜令人害怕,但一個 4D 矩陣單純就是一個 4 x 4 的矩陣如下:

\begin{bmatrix}
	1 & 0 & 0 & 0 \\
	0 & 1 & 0 & 0 \\
	0 & 0 & 1 & 0 \\
	0 & 0 & 0 & 1 \\
\end{bmatrix}

上面矩陣的值表示一個「單位矩陣」Identity Matrix,這是一個特殊的矩陣,在數學的世界裡就是「不做任何事」就類似任何數乘 1 一樣,也就不會進行任何變形。當矩陣中的值變更時,組件就會以不同的效果進行變形。

矩陣乘法的作用在這裡概略為當我們把一個組件的某個座標乘以一變形矩陣,這個計算結果就是點的新位置。

但一般來說,我們不需要去設定矩陣中的值來實作變形效果。Matrix4 類別包含了一些建構子和方法協助我們控制矩陣而不需要完全了解幾何變換的細節。

  • identity() :這個建構子會直接建立一個單位矩陣如同上面呈現。
  • rotationX() rotationY() rotationZ() 建構子或者 rotateX() rotateY() rotateZ() 方法可以協助我們產生旋轉效果。
  • scale() 方法可以用來套用縮放效果,參數可以使用 x, y, z 值或者 Vector3Vector4 類別。
  • translation() 建構子或 translate() 方法可以移動 x, y, z 軸,同樣參數也可以使用 Vector3Vector4
Transform(
  transform: Matrix4.identity()..translate(50.0, 30.0),
  child: Container(
    width: 100,
    height: 100,
    color: Colors.red,
  ),
)

上面 Matrix4.identity()..translate(50.0, 30.0), 中的 .. Cascade Notation 級聯符號可以對同一個物件進行多個操作

// 例如
var person = Person();
person.name = 'andyyou';
person.age = 37;

// 使用級聯符合
var person = Person()
	..name = 'andyyou'
	..age = 37;

下面讓我們來看看如何使用 TransformMatrix4 實作不同類型的變形效果。

另外,也可以通過 SVG 研究之路 (20) - transform Matrix 來學習相關原理。

旋轉效果

當我們希望讓子組件套用旋轉變形效果,通過使用 Transform.rotate() 建構子即可達成。這和 Transform 預設的建構子並沒有太大的差異,主要的不同如下:

  • 不用 transform 屬性:當使用 rotate() 時,我們主要是要套用旋轉,因此不需設定完整的矩陣,只需要使用 angle 即可。
  • angle 屬性可以指定順時針旋轉角度,以弧度為單位。
  • origin 預設情況下,旋轉會相對於組件的中心套用效果,不過我們可以使用 origin 來設定原點。

當我們處理旋轉效果時,我們會需要使用 PI 常數協助。為此我們需要匯入數學套件

import 'dart:math';

通常,為了不產生任何疑義,關於常數從何處匯入,我們可以幫匯入指定一個名稱:

import 'dart:math' as math;

然後我們就可以使用 math.pi 來讀取圓周率

Transform.rotate(
	angle: -45 * (math.pi / 180.0),
  child: ElevatedButton(
    child: Text("旋轉按鈕"),
    onPressed: () {},
  ),
);

一個圓總共 360 度,等於 2 PI,PI 就是 180度。從上面的基礎知識我們可以得到要換算

  • 角度轉換弧度 = 度數 x (PI/180)
  • 弧度轉換角度 = 弧度 x (180 / PI)

上面程式,我們指定了角度度 -45 度(315度) 然後子組件就會套用旋轉效果。

這個效果如果我們使用預設建構子搭配 Matrix4 則如下為等價的程式碼:

Transform(
	transform: Matrix4.rotationZ(-45 * (math.pi / 180.0)),
  alignment: Alignment.center,
  child: ElevatedButton(
  	child: Text("旋轉按鈕"),
    onPressed: () {},
  ),
);

為了實現相同效果,我們提供的是圍繞 Z 軸旋轉的 transform 屬性搭配置中。

縮放效果

在我們希望輕鬆的改變組件的大小時,就可以套用縮放效果。類似於 rotate() 建構子和預設建構子沒有太大差異一樣,下面是 Transform.scale() 和預設之間的不同點:

  • 沒有 transform 屬性,這裡我們改使用 scale 取代,而不用傳入矩陣。
  • sacle 可用來設定縮放的大小,型別為 double1.0 為原始尺寸。除此之外還可以套用 x 和 y 軸
  • 預設,縮放一樣相對於組件中心點,我們可以使用 alignment 屬性改變縮放的原點
Transform.scale(
	scale: 2.0,
  child: ElevatedButton(
  	child: Text("縮放按鈕"),
    onPressed: () {},
  ),
);

上面範例我們設定了 2.0child 的組件會放大 2 倍。不過除了 Transform 如果只是改變尺寸我們也可以直接設定 ElevatedButton 的大小

ElevatedButton(
  style: ButtonStyle(
    fixedSize: MaterialStateProperty.all(Size(200, 60)),
    // 或者使用 minimumSize 設定最小尺寸
    // minimumSize: MaterialStateProperty.all(Size(200, 60)),
  ),
  child: const Text("調整按鈕大小"),
  onPressed: () {},
),

同樣的也可以使用一般的 Transform 建構子達成一樣的效果:

Transform(
	transform: Matrix4.identity()..scale(2.0, 2.0),
  alignment: Alignment.center,
  child: ElevatedButton(
  	child: const Text("縮放按鈕"),
    onPressed: () {},
  ),
)

關於級聯運算子:

你大概注意到上面 sacle 方法也使用了級聯運算子。在前面我們已經有簡單的說明,通常我們使用一個 . 用來存取方法或其屬性,而 .. 可以操作回傳的物件而不用另外建立變數。

另外上面 rotationZ() 並沒有使用級聯運算子是因為該方法直接回傳了一個新的矩陣,而縮放 sacle 方法本身回傳型別為 void ,作用為修改現有的矩陣。

位移效果

位移效果通常會出現在動畫中,後續 Animation 我們會介紹。而這邊我們先介紹 translate() 和預設 Transform 建構子的差異:

  • 沒有 transformalignment 屬性,效果通過 offset 參數設定而不用使用矩陣。
  • offset 單純設定 child 組件的位移
Transform.translate(
	offset: Offset(30, 30),
  child: ElevatedButton(
  	child: const Text("移動按鈕"),
    onPressed: () {},
  ),
);

同樣的對照預設建構子的用法

Transfrom(
	transform: Matrix4.translationValues(30, 30, 0),
  child: ElevatedButton(
  	child: const Text("移動按鈕"),
    onPressed: () {},
  ),
);

組合變形效果

除了上面介紹的各種效果,我們還可以組合上面介紹的效果例如旋轉加上縮放。組合變形一般可以通過下面兩種方式達成:

  • 使用預設 Transform 組件搭配 Matrix4 提供的各種方法組合而成。
  • 使用多個 Transform 組件嵌套的方式
// 嵌套的方式
Transform.translate(
  offset: Offset(70, 200),
  child: Transform.rotate(
    angle: -45 * (math.pi / 180.0),
    child: Transform.scale(
      scale: 2.0,
      child: ElevatedButton(
        child: Text("組合多種效果"),
        onPressed: () {},
      ),
    ),
  ),
);

如你所見,我們使用了多個 Transform 組合效果,雖然比較容易理解和修改,但缺點就是我們在樹狀結構中加入大量組件。除了影響效能外,當我們同時對一個組件加入多個變換時,必須注意效果的順序。您可以自己嘗試 - 交換 Transform 組件的位置會導致不同的結果。

另一種作法便是使用預設 Transform 組件搭配 Matrix4

Transform(
  alignment: Alignment.center,
  transform: Matrix4.translationValues(70, 100, 0)
    ..rotateZ(-45 * (math.pi / 180.0))
    ..scale(2.0, 2.0),
  child: ElevatedButton(
    child: Text("多種效果"),
    onPressed: () {},
  ),
);

就像前面的例子一樣,我們指定對齊子組件的中心,然後使用 Matrix4 設定變形效果。這與多個 Transform 組件的版本效果很接近,但不需要大量嵌套組件。

對於複雜的組合,考慮使用單一的 Matrix4 變形效果可以有比較好的效能。

動畫

Flutter 廣泛的支持動畫功能,提供了多種方式來幫組件增加動畫效果。此外還有一些組件內建動畫。雖然 Flutter 簡化了涉及動畫的複雜性,但在進一步深入之前我們還是需要理解一些重要概念。

Animation<T> 類別

在 Flutter, Animation 類別包含一個狀態和 T 型別的值,其中 TAnimation 類別實例化時定義。而動畫的狀態即對應它是正在執行還是已經完成;狀態值會隨著動畫的進行而改變,這個值即對應動畫執行過程組件的變化。

這個類別也揭露 callback 因此其他類別可以得知動畫目前的狀態。一個 Animation<T> 物件只負責揭露狀態和屬性值。它並不知道任何視覺反饋,螢幕呈現,或如何渲染即 build() 方法。通過 Animation 類別隨著間隔時間產生的值會被其他組件用來處理它們的動畫。

其中最常見的動畫類型就是 Animation<double> 型別,因為 double 常被用來表示動畫的進度,並用比例的概念操作任何類型的值。

Animation 類別在決定的最小值和最大值之間生成一系列的值。這個過程也被稱為插值 Interpolation 而不只是給出線性值的進程,進程可以是線性、階梯或曲線。Flutter 也提供了多種操作動畫的函式和工具:

  • AnimationController 雖然類別名稱為 Controller 但它不是用來直接控制動畫物件,它繼承 Animation 用來控制狀態值,也就是控制動畫的時間,方向和持續時間,可以啟動、停止、反轉動畫。
  • CurvedAnimation 這是一個將曲線套用到其他 Animation 的動畫。支援一系列內建的曲線,可以用這些曲線控制生成的動畫值。也就是修改動畫的速度曲線。
  • Tween 給定開始、結束值,協助建立其間線性的插值

Animation 類別提供了在運行期間存取狀態和值的方法。通過狀態監聽,可以得知動畫的開始,結束或者反向執行。通過使用 addStatusListener() 方法,可對應動畫開始或結束事件來操作我們的組件。同樣的,我們可以用 addListener() 方法監聽「值」,這樣每次「動畫值」變化時我們都會收到通知,就可以使用 setState 方法重建我們的組件。

另外,使用 addListener 搭配 setState 可能在複雜動畫中影響性能,對於希望更高效的實現,考慮使用 AnimatedBuilderAnimatedWidget 。至此我們概略的介紹了關於動畫的一些概念。

AnimationController

AnimationController 是 Flutter 中最常使用的動畫相關類別之一。延伸自 Animation<double> 類別並加入一些基本控制動畫的方法。

如同前面提到的,Animation 類別是 Flutter 動畫的基礎,但是它不包含直接控制動畫的方法。AnimationController 可以為動畫概念加入各種控制。

  • 播放、停止: AnimationController 加入播放、回放動畫以及停止的功能
  • 持續時間 Driation:動畫的播放時間通常有所限制
  • 設定動畫當前的值:這會造成動畫停止以及通知狀態和值的監聽
  • 設定動畫值的最小值和最大值:這是為了讓我們在播放動畫之前和之後知道預期的範圍

接著,讓我們來看看 AnimationController 建構子範例以及其主要屬性:

var controller = AnimationController(
	value: 0.0, // 初始值
  duration: Duration(seconds: 2), // 動畫持續時間
  reverseDuration: Duration(seconds: 2), // 動畫持續時間
  debugLabel: 'Animation', // 用於調試的標籤
  lowerBound: 0.0, // 最小值
  upperBound: 1.0, // 最大值
  animationBehavior: AnimationBehavior.normal,
  vsync: this, // TickerProvider
);
var animation = Tween<double>(begin: 0.0, end: 1.0).animate(controller);
  • value 動畫值的初始值,如果沒有設定預設為 lowerBound
  • duration 動畫執行的時間
  • reverseDuration 當動畫以反向執行時的執行時間,預設情況下,動畫不會自動反向執行,如果沒有明確調用反向動畫,reverseDuration 是不會自動生效的。
  • debugLabel 協助除錯的字串,用來識別時那一個 Controller 的輸出。
  • lowerBound 不得為 null,為動畫值的最小值
  • upperBound 不得為 null ,它是動畫值的最大值,通常是執行的結束值。
  • animationBehavior 這個參數設定了當動畫被停用,通常是出於無障礙的考慮。如果設定為 AnimationBehavior.normal 當觸發停用時,動畫時間會被縮短。如果是 AnimationBehavior.preserve 那麼則保持原來的行為。
  • vsync 其值為一個 TickerProvider 物件,Controller 利用它在每次幀觸發時獲得信號。它確保動畫只在可見時運行,防止動畫消耗資源。

關於 TickerProvider 後續我們會深入介紹。

TickerProvider 和 Ticker

TickerProvider 介面,擴展類別使其支援建立 Ticker 物件的方法。Ticker 是 Flutter 動畫系統的核心。每當觸發新的一幀,就會發出一個 Ticker 物件,使其他物件可以對新的一幀 Frame 執行對應的行為。TickerProvider 它的主要作用是確保只有在 Widget 可見時才建立和運行 Ticker,避免不必要的資源消耗。

幀的重新渲染是依據裝置「螢幕更新頻率間隔」發動的,因此 Ticker 和螢幕更新率同步,通常是每秒 60 幀。利用這點的動畫能獲得最佳的體驗,而更新率低於動畫可能就會掉幀,某些畫面會跳過不渲染。

這個 Ticker 物件通常是通過 AnimationController 物件間接使用,因此包含動畫的狀態組件須支援 TickerProvider 。因此 State 類別可以擴展 TickerProviderStateMixinSingleTickerProviderStateMixin 。這些 Mixin 實作 TickerProvider 並且可以和 AnimationController 物件共用。如果你有多個動畫,那麼使用 TickerProviderStateMixin ,只有一個動畫的話使用 SingleTickerProviderStateMixin 可以提升效率。

當我們在 AnimationController 的建構子中使用 vsync: this 時,這個 this 指的是當前的 State 物件。這意味著我們的 State 類別必須「混入」(mixin) 某個 TickerProvider 實現,通常是 SingleTickerProviderStateMixinTickerProviderStateMixin。下面讓我們通過範例慢慢的學習這些物件。

CurvedAnimation

CurvedAnimation 類別用於設定 Animation 類別採用非線性曲線,而不是線性死板地執行動畫。換句話說,它用來調整動畫,改變插值的方法。

我們可以套用曲線來調整動畫值,比如讓一個平移效果產生重力彈跳的感覺,或者更直白地說,讓動畫一開始的效果比較快,接近結束時變慢。

通過 curvereverseCurve 屬性,我們可以分別設定正向播放和反向執行時使用不同的曲線效果。

除了 linear 線性效果外,Curves 類別還提供了多種內建曲線供我們選擇。如果上面的說明還是讓你感到困惑可以參考官方文件的影片

Tween

如同之前所見,預設最簡單的起始和結束值分別為 0.0 和 1.0。通過 Tween 類別調整 AnimationController 的範圍。Tween<double> 類別除了預設的浮點數也可以是任何型別,如果有需要,我們還可以自訂 Tween 類別。重點是 Tween 在動畫開始結束之間回傳的動畫值,例如我們可以通過動畫值來改變組件的大小,位置,透明度,顏色等等。

此外,我們還有 Tween 衍生的類別,例如 CurveTween 可以修改在動畫的曲線,ColorTween 產生兩個顏色之間的插值。

目前我們已經大致了解了動畫的基本概念和功能。

使用動畫

處理動畫時,儘管動畫可能千變萬化,但它們通常建立在相似的基礎之上,Tween 物件對於調整動畫的效果非常實用。大部分的情況下我們會使用 AnimationControllerCurvedAnimationTween 物件來組合動畫。

在我們使用進階自訂 Tween 之前,讓我們來複習「套用 Transform 變形效果的組件」,不過,這次我們改以動畫的方式實作。最終我們會得到相同的效果,但效果更加流暢。

旋轉動畫

除了像之前,可以直接將按鈕套用旋轉效果,我們也可以使用 AnimationController 類別使其產生漸變效果。下面範例我們將逐步建立類似於使用 Transform 旋轉效果的組件。

  1. 首先,因為動畫涉及到狀態的變化,我們需要一個 StatefulWidget 組件。
import 'package:flutter/material.dart';
import 'dart:math' as math;

class RotationButton extends StatefulWidget {
  const RotationButton({super.key});

  @override
  State<RotationButton> createState() => _RotationButtonState();
}

class _RotationButtonState extends State<RotationButton>
    with SingleTickerProviderStateMixin { // <- 重點擴展了 SingleTickerProviderStateMixin
  // 後續會在這裡增加程式碼
}
  1. 建立動畫控制器

_RotationButtonState 類別中加入 _angle 以及 _controller 類別成員並進行初始化。後續我們會使用 setState() 調整 _angle 角度。

double _angle = 0.0;
late AnimationController _controller;

@override
void initState() {
  super.initState();
  _controller = _createRotationAnimation();
  _controller.forward();
}

AnimationController _createRotationAnimation() {
  var controller = AnimationController(
      vsync: this, // 因為類別擴展了 SingleTickerProviderStateMixin 
      duration: const Duration(seconds: 3),
      debugLabel: '旋轉動畫按鈕'
  );
  
  controller.addListener(() {
    setState(() {
      _angle = (controller.value * 360.0) * (math.pi / 180);
    });
  });
  return controller;
}
  1. 建立旋轉按鈕
Widget _renderButton() {
  return Transform.rotate(
    angle: _angle,
    child: ElevatedButton(
      child: const Text('旋轉'),
      onPressed: () {
        _controller.reset();
        _controller.forward();
      },
    ),
  );
}

@override
Widget build(BuildContext context) {
  return _renderButton();
}
  1. 清理資源

State 生命週期結束時,必須 dispose 我們的 Controller 物件,避免記憶體洩漏。

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

如果希望使用不同的曲線,可以使用 CurveTween 範例如下:

AnimationController _createBounceInRotationAnimation() {
  var controller = AnimationController(
  	vsync: this,
    debugLabel: "範例",
    duration: Duration(seconds: 3),
  );
  
  var animation = controller.drive(
  	CurveTween(
    	curve: Curves.bounceIn,
    )
  );
  
  animation.addListener(() {
    setState(() {
      _angle = (animation.value * 360.0) * (math.pi / 180);
    });
  });
  
  return controller;
}

這裡我們使用 drive() 方法建立了 animation 並且傳入了希望的 CurveTween 物件。注意到這裡我們使用 animation 來註冊監聽而不是 controller ,這是因為我們希望動畫值套用曲線效果。

下面是完整範例:

class RotationButton extends StatefulWidget {
  const RotationButton({super.key});

  @override
  State<RotationButton> createState() => _RotationButtonState();
}

class _RotationButtonState extends State<RotationButton>
    with SingleTickerProviderStateMixin {
  double _angle = 0.0;
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = _createRotationAnimation();
    _controller.forward();
  }

  AnimationController _createRotationAnimation() {
    var controller = AnimationController(
        vsync: this,
        duration: const Duration(seconds: 3),
        debugLabel: '旋轉動畫按鈕'
    );
    controller.addListener(() {
      setState(() {
        _angle = (controller.value * 360.0) * (math.pi / 180);
      });
    });
    return controller;
  }

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

  Widget _renderButton() {
    return Transform.rotate(
      angle: _angle,
      child: ElevatedButton(
        child: const Text('旋轉'),
        onPressed: () {
          _controller.reset();
          _controller.forward();
        },
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return _renderButton();
  }
}

另外提供一個比較進階的範例:

class RotationButton extends StatefulWidget {
  const RotationButton({super.key});

  @override
  State<RotationButton> createState() => _RotationButtonState();
}

class _RotationButtonState extends State<RotationButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller =
        AnimationController(vsync: this, duration: const Duration(seconds: 3));
    _animation = Tween(begin: 0.0, end: 1.0).animate(_controller);
  }

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

  Widget _renderRotationButton() {
    return AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Transform.rotate(
          angle: (_animation.value * 360) / (math.pi / 180),
          child: child,
        );
      },
      child: ElevatedButton(
          child: const Text("旋轉按鈕"),
          onPressed: () {
            if (_controller.status == AnimationStatus.completed) {
              _controller.reset();
            }
            _controller.forward();
          }),
    );
  }

  @override
  Widget build(BuildContext context) {
    return _renderRotationButton();
  }
}

AnimatedBuilder 的作用和 setState 類似,它會在動畫值產生變化時自動重新渲染。兩個範例中我們先使用我們已經熟悉的狀態 setState 來實作展示其概念,實務上 AnimatedBuilder 搭配 Animation 的範例則有比較好的效能,這裡提供範例可以自行比較學習。

縮放動畫

為了建立縮放動畫並且實現比直接改變尺寸更流暢的 UI 效果,我們可以再次利用 AnimationController 達成效果。

這一次我們的將使用 scale 效果套用到 ElevatedButton 組件上。為了更加熟練的掌握 Flutter 的概念,這次我們由下而上,先從按鈕渲染開始:

Widget _renderButton() {
  return Transform.scale(
    scale: _scale,
    child: ElevatedButton(
        child: const Text('縮放按鈕'),
        onPressed: () {
          // 這裡我們儘量介紹各種 API
          // 等價於 _controller.status == AnimationStatus.completed
          if (_controller.isCompleted) {
            _controller.reverse();
          } else if (_controller.isDismissed) {
            _controller.forward();
          }
        }),
  );
}

可以預期我們會有一個 _scale 屬性還有在 onPressed 執行一些操作,這裡包含了反向播放動畫 reverse() 若動畫播完了就反向,在初始狀態就正常播放。

和旋轉動畫類似我們需要建立 _controller 物件,但是使用的參數些微不同:

AnimationController _createAnimationController() {
  var controller = AnimationController(
    vsync: this,
    lowerBound: 1.0,
    upperBound: 2.0,
    duration: const Duration(seconds: 2),
  );

  controller.addListener(() {
    setState(() {
      _scale = controller.value;
    });
  });

  return controller;
}

如你所見我們這次使用了 lowerBoundupperBoundlowerBound 設為 1.0:確保按鈕不會小於其原始大小。upperBound 設為 2.0:允許按鈕最大放大到原始大小的兩倍。

完整程式碼如下:

class ScaleButton extends StatefulWidget {
  const ScaleButton({super.key});

  @override
  State<ScaleButton> createState() => _ScaleButtonState();
}

class _ScaleButtonState extends State<ScaleButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  double _scale = 1.0;

  @override
  void initState() {
    super.initState();
    _controller = _createAnimationController();
  }

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

  @override
  Widget build(BuildContext context) {
    return _renderButton();
  }

  Widget _renderButton() {
    return Transform.scale(
      scale: _scale,
      child: ElevatedButton(
          child: const Text('縮放按鈕'),
          onPressed: () {
            if (_controller.isCompleted) {
              _controller.reverse();
            } else if (_controller.isDismissed) {
              _controller.forward();
            }
          }),
    );
  }

  AnimationController _createAnimationController() {
    var controller = AnimationController(
      vsync: this,
      lowerBound: 1.0,
      upperBound: 2.0,
      duration: const Duration(seconds: 2),
    );

    controller.addListener(() {
      setState(() {
        _scale = controller.value;
      });
    });

    return controller;
  }
}

其他問題處理:如果你在開發過程遇到無法開啟 iOS 模擬器的情況「Unable to boot the Simulator」可以到 🍎 > 系統設定 > 一般 > 儲存空間 > 開發者 > 刪除快取。

位移動畫

跟上面我們完成的旋轉和縮放動畫一樣,我們也可以實現比較流暢的位移效果。大部分都跟上面類似,差異只是我們改成使用 Transform.translate 。這次我們須使用不同型別的動畫值,不再是 double。讓我們來看看如何實作 Offset 動畫

AnimationController _createAnimationController() {
  var controller = AnimationController(
  	vsync: this,
    debugLabel: "位移動畫",
    duration: Duration(seconds: 2),
  );
  
  var animation = controller.drive(
  	Tween<Offset>(
    	begin: Offset.zero,
      end: Offset(70, 200),
    )
  );
  
  animation.addListener(() {
    setState(() {
      _offset = animation.value,
    });
  });
  return controller;
}

如你所見,我們使用了不同的方式修改 Offset 也就是 Tween<Offset> 物件,並使用 drive() 傳入 AnimationController 簡單的說就是給定 Tween<Offset> 開始結束的座標,剩下的複雜計算由該物件替我們自動計算。因為 Offset 可以覆寫一些計算方法,因此可以調整中間偏移量的計算。

總結來說 AnimationController 的角色負責控制動畫的時間,何時開始,結束,暫停或反轉。Tween 是一個轉換工具可以將 Controller 的 0.0 - 1.0 轉換為實際需要的值,而 AnimationAnimationControllerTween 結合的結果,代表隨著時間變化的實際值。

接著,讓我們來介紹上面提到的 AnimatedBuilder

使用 AnimatedBuilder

回顧我們上面的程式碼,可以發想一個問題;我們的動畫邏輯和 UI 邏輯混合在一起例如 _renderButton() 中同時處理了組件和使用 _scale 狀態,加上在按鈕中調用 forward 等。

針對簡單的應用這麼做沒什麼問題,但隨著應用複雜度提升,這慢慢的會變成問題且導致難以維護。

AnimatedBuilder 除了上面提到的效能外,就是協助我們來完成分離職責的任務。我們的組件無論是 ElevatedButton 還是其他東西,都不需要知道它是在動畫中被渲染。而將 build 方法進一步分解成各個單一職責的組件可以說是 Flutter 的基本概念之一。

AnimatedBuilder 類別

AnimatedBuilder 組件的存在就是為了幫助我們將動畫邏輯與 UI 構建分離,使得創建複雜的動畫效果變得簡單。

Widget build(BuildContext context) {
  return Center(
    child: AnimatedBuilder(
      animation: _animation,
      builder: (context, child) {
        return Container(
          width: _animation.value,
          height: 50,
          child: child,
        );
      },
      child: ElevatedButton(
        child: Text('動畫效果按鈕'),
        onPressed: () {},
      ),
    ),
  );
}

如上面看到的,這裡有幾個比較重要的屬性:

  • animation 通常是一個 AnimationController 或其衍生物件,型別為 Listenable 物件,常見產生的方式如下

    var _animation = AnimationController(...); // 基本控制器
    var _animation = Tween<double>(...).animate(_controller); // 使用 Tween
    var _animation = CurvedAnimation(parent: _controller, curve: Curves.easeIn); // 使用曲線
    var _animation = Tween<double>(...).animation(CurvedAnimation(...)); // 組合使用
    var _animation = _controller.drive(Tween<double>(...)); // 使用 drive 方法
    
  • builder 根據動畫值調整組件的地方,根據當前狀態動態修改 UI。

  • child 傳入的組件,用於定義不需要在每次動畫更新時重建的 UI 部分,提高效率。

使用 AnimatedBuilder 重構

為了分解我們的程式碼、調整動畫使其容易維護,我們必須分離每個負責的內容:

  • 動畫:AnimationController 類別的部分保持不變。
  • build:換成使用 AnimatedBuilder 組件,這裡我們會抽離和按鈕動畫相關大部分的程式碼。
  • 組件,在我們的例子中單純只是 ElevatedButton

首先,我們來重構建立動畫的部分:

(AnimationController, Animation) _createAnimation() {
  final controller = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 3),
      debugLabel: '旋轉按鈕動畫'
  );

  final animation = controller.drive(CurveTween(curve: Curves.bounceIn));

  return (controller, animation);
}

我們一樣建立了 AnimationControllerAnimation ,差別是我們不再通過監聽器使用 setState

但這次我們須回傳 animation 這是因為我們需要在 AnimatedBuilder 中使用這個動畫值。回傳的部分我們使用了 Dart 3 的新 Record 型別。

新的 AnimatedBuilder 作法依舊須 dispose()

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

狀態組件依舊是必須的,我們來處理初始化階段

class _RotationButtonState extends State<RotationButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation _animation;

  @override
  void initState() {
    super.initState();
    final (controller, animation) = _createAnimation();
    _controller = controller;
    _animation = animation;
  }
	
  // ...
}

最後完整範例如下:

import 'package:flutter/material.dart';
import 'dart:math' as math;

class RotationButton extends StatefulWidget {
  const RotationButton({super.key});

  @override
  State<RotationButton> createState() => _RotationButtonState();
}

class _RotationButtonState extends State<RotationButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation _animation;

  @override
  void initState() {
    super.initState();
    final (controller, animation) = _createAnimation();
    _controller = controller;
    _animation = animation;
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _animation,
      child: ElevatedButton(
        child: const Text('旋轉按鈕'),
        onPressed: () {
          print("開始旋轉");
          print("${_animation.status}");
          if (_animation.isCompleted || _animation.isDismissed) {
            _controller.reset();
            _controller.forward();
          }
        },
      ),
      builder: (context, child) {
        return Transform.rotate(
          angle: _animation.value * 2.0 * math.pi,
          child: child,
        );
      },
    );
  }

  (AnimationController, Animation) _createAnimation() {
    final controller = AnimationController(
        vsync: this,
        duration: const Duration(seconds: 3),
        debugLabel: '旋轉按鈕動畫');

    final animation = controller.drive(CurveTween(curve: Curves.bounceIn));

    return (controller, animation);
  }

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

如同之前提到的,我們不用在建立 Controller 時使用監聽搭配 setState - AnimatedBuilder 會負責處理相關任務,並將重新渲染限制在 AnimatedBuilder 類別的子組件範圍內。到此我們已經了解了動畫的基礎知識。

隱式動畫組件

除了我們上面自己簡直的動畫效果,Flutter 還有內建一整套的動畫組件。這些組件包含了一些常見的效果,可以更容易的使用。舉例來說我們的組件設定了某個顏色,然後我們通過 setState 變更顏色值,顏色的變化會自動套用動畫。

AnimatedContainer

第一個要看的就是最強大的 AnimatedContainer 組件。這個組件類似於 Container ,但加入了一些屬性支援動畫處理。

Container(
  width: _winner ? 50 : 400,
  child: Image.asset(...),
),

一開始 _winner 值為 false,然後使用 setState 變更為 true,圖片就會從 50px 擴展到 400px。

進一步我們還可以加入其他屬性:

AnimatedContainer(
  width: _winner ? 50 : 400,
  child: Image.asset(...),
  duration: Duration(seconds: 2),
  curve: Curves.bounceOut,
),

Animated 其他組件

在 Flutter 中隱式動畫組件,常被叫做 AnimatedFoo 這個 Foo 是非動畫版本的名字。這類組件很多,具體例子如下:

  • AnimatedAlign:這個動畫如同其名是對齊方式的效果
  • AnimatedOpacity:組件透明度的動畫效果,適合淡入淡出效果
  • AnimatedPadding:內部間距動畫效果
  • AnimatedPositioned:這個效果只能在 Stack 組件中使用,移動位置相關變化的動畫效果
  • AnimatedSize:組件大小變化效果

如果不是太複雜的效果,這些組件可以幫助你節省不少開發時間。

本文我們學習了:

  1. 使用 Transform 變形來改變組件。
  2. 學習 Matrix4
  3. 動畫的基本概念和應用。
  4. 核心物件:AnimationControllerCurvedAnimationTween
  5. 使用 AnimatedBuilder 組件重構優化。
  6. 介紹內建的 Animated 類別。

希望你在這個章節學習到動畫相關的知識,後續在需求上可以進一步深入學習。


上一篇
Day 20 探索主流第三方插件
下一篇
Day 22 多國語系支援
系列文
Flutter 開發實戰 - 30 天逃離新手村26
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言