前言
嗨,我是 Steven。在開發 Happy Balloon Pop、SandTrace 和 JigsawEase 這三款 App 的過程中,動畫效果是我花最多心力鑽研的領域之一。流暢的動畫能讓 App 從「能用」變成「好用」,從「普通」變成「出色」。
今天我想把自己在 Flutter 動畫方面的經驗整理成一篇完整的指南,從最簡單的隱式動畫到最靈活的自訂繪製,一步步帶你掌握 Flutter 動畫的各種技巧。
第一層:隱式動畫(Implicit Animations)
隱式動畫是 Flutter 動畫系統中最容易上手的。你只需要改變目標值,Flutter 會自動處理中間的過渡動畫。這類動畫以 Animated 為前綴命名。
AnimatedContainer
AnimatedContainer 大概是最常用的隱式動畫 Widget。它可以平滑過渡大小、顏色、邊距、圓角等各種屬性:
class PulseButton extends StatefulWidget {
@override
State<PulseButton> createState() => _PulseButtonState();
}
class _PulseButtonState extends State<PulseButton> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack,
width: _isExpanded ? 200 : 150,
height: _isExpanded ? 60 : 48,
decoration: BoxDecoration(
color: _isExpanded ? Colors.indigo : Colors.blue,
borderRadius: BorderRadius.circular(_isExpanded ? 30 : 12),
boxShadow: [
BoxShadow(
color: Colors.blue.withOpacity(_isExpanded ? 0.4 : 0.2),
blurRadius: _isExpanded ? 16 : 8,
offset: const Offset(0, 4),
),
],
),
child: Center(
child: Text('點擊我', style: TextStyle(color: Colors.white)),
),
),
);
}
}
在我的 App 中,按鈕的狀態變化、卡片的展開收合,幾乎都是用 AnimatedContainer 來處理的。
AnimatedOpacity 和 AnimatedScale
這兩個在 UI 轉場中非常實用。比如一個元素要漸入漸出,或者要從小到大彈出來:
AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: const Text('我會漸入漸出'),
),
AnimatedScale(
scale: _isSelected ? 1.1 : 1.0,
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
child: _buildCard(),
),
隱式動畫的限制
隱式動畫雖然簡單,但有兩個主要限制:
- 無法控制動畫的中間過程 — 你只能設定起點和終點,中間完全由 Flutter 控制
- 無法做循環動畫 — 隱式動畫只在值改變時觸發一次,不會自動重複
當你需要更精細的控制,就該進入顯式動畫了。
第二層:顯式動畫(Explicit Animations)
顯式動畫的核心是 AnimationController。它讓你能完全掌控動畫的進度、方向、重複次數等等。
基本設置
class _MyWidgetState extends State<MyWidget> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
SingleTickerProviderStateMixin 提供了 vsync 參數所需的 TickerProvider,這能確保動畫只在 Widget 可見時才消耗資源。如果需要多個 AnimationController,改用 TickerProviderStateMixin。
實戰:呼吸燈效果
在 SandTrace 的工具選單中,我用了一個「呼吸燈」效果來提示當前選中的工具:
class BreathingGlow extends StatefulWidget {
final Widget child;
final Color glowColor;
const BreathingGlow({required this.child, required this.glowColor});
@override
State<BreathingGlow> createState() => _BreathingGlowState();
}
class _BreathingGlowState extends State<BreathingGlow>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: widget.glowColor.withOpacity(0.3 + _controller.value * 0.4),
blurRadius: 8 + _controller.value * 12,
spreadRadius: _controller.value * 4,
),
],
),
child: child,
);
},
child: widget.child,
);
}
}
repeat(reverse: true) 讓動畫在正向和反向之間來回循環,產生自然的呼吸感。
Staggered Animation(交錯動畫)
當多個元素需要依序出場時,交錯動畫能創造出很優雅的效果。我在 JigsawEase 的選單頁面就用了這個技巧:
class StaggeredList extends StatefulWidget {
@override
State<StaggeredList> createState() => _StaggeredListState();
}
class _StaggeredListState extends State<StaggeredList>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..forward();
}
Animation<double> _getDelayedAnimation(int index) {
final start = index * 0.15;
final end = start + 0.4;
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start.clamp(0, 1), end.clamp(0, 1), curve: Curves.easeOut),
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: List.generate(5, (index) {
final animation = _getDelayedAnimation(index);
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, 30 * (1 - animation.value)),
child: Opacity(
opacity: animation.value,
child: child,
),
);
},
child: _buildListItem(index),
);
}),
);
}
}
關鍵在 Interval 的使用——每個元素的動畫在時間軸上錯開 15%,造成一個接一個冒出來的效果。
第三層:Hero 轉場動畫
Hero 是 Flutter 內建的頁面轉場動畫,能讓一個 Widget 在兩個頁面之間「飛」過去。設定非常簡單:
// 列表頁
Hero(
tag: 'puzzle-image-${puzzle.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(puzzle.thumbnail),
),
)
// 詳情頁
Hero(
tag: 'puzzle-image-${puzzle.id}',
child: Image.asset(puzzle.fullImage),
)
只要兩端的 tag 一致,Flutter 就會自動產生飛行動畫。我在 JigsawEase 的拼圖選擇頁面到遊戲頁面之間就用了這個效果,使用者點擊縮圖後,圖片會自然地「展開」到全螢幕,過渡非常順暢。
自訂 Hero 動畫
預設的 Hero 動畫是線性縮放和位移。如果你想要更特殊的效果,可以用 flightShuttleBuilder 自訂飛行過程中的 Widget:
Hero(
tag: 'puzzle-image-${puzzle.id}',
flightShuttleBuilder: (flightContext, animation, direction, fromContext, toContext) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.rotate(
angle: animation.value * 0.05,
child: Material(
elevation: 8 * (1 - animation.value),
borderRadius: BorderRadius.circular(12 * (1 - animation.value)),
clipBehavior: Clip.antiAlias,
child: toContext.widget,
),
);
},
);
},
child: _buildThumbnail(),
)
第四層:CustomPainter 自訂繪製動畫
當內建的 Widget 無法滿足需求時,CustomPainter 是終極武器。在我的三款 App 中,最核心的視覺效果全都是用 CustomPainter 畫出來的。
波浪動畫
這是一個我很喜歡的背景效果,用正弦函數畫出波浪,搭配動畫讓波浪持續流動:
class WavePainter extends CustomPainter {
final double phase;
final Color color;
WavePainter({required this.phase, required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final path = Path();
path.moveTo(0, size.height);
for (double x = 0; x <= size.width; x++) {
final y = size.height * 0.5 +
sin((x / size.width) * 2 * pi + phase) * 20 +
sin((x / size.width) * 4 * pi + phase * 1.5) * 10;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(WavePainter oldDelegate) => oldDelegate.phase != phase;
}
多層疊加不同頻率和振幅的正弦波,就能產生非常自然的海浪效果。我在 Happy Balloon Pop 的背景中用了三層不同透明度的波浪,效果相當好看。
粒子爆炸效果
結合 CustomPainter 和 AnimationController,可以做出各種粒子效果。以下是一個通用的粒子爆炸效果:
class ExplosionPainter extends CustomPainter {
final List<Particle> particles;
final double progress;
ExplosionPainter({required this.particles, required this.progress});
@override
void paint(Canvas canvas, Size size) {
for (final p in particles) {
final currentPos = Offset(
p.origin.dx + p.velocity.dx * progress * 100,
p.origin.dy + p.velocity.dy * progress * 100 + 50 * progress * progress,
);
final opacity = (1.0 - progress).clamp(0.0, 1.0);
final currentSize = p.size * (1.0 - progress * 0.5);
canvas.drawCircle(
currentPos,
currentSize,
Paint()..color = p.color.withOpacity(opacity),
);
}
}
@override
bool shouldRepaint(ExplosionPainter oldDelegate) => true;
}
效能最佳實踐
動畫效能是個大話題,分享幾個我在實務中學到的重點:
1. RepaintBoundary 隔離重繪
當只有某個區域在動畫,用 RepaintBoundary 把它包起來,避免整棵 Widget tree 都被重繪:
RepaintBoundary(
child: CustomPaint(
painter: MyAnimatedPainter(animation: _controller),
),
)
2. shouldRepaint 要精確
CustomPainter 的 shouldRepaint 方法決定了什麼時候需要重繪。不要永遠回傳 true,盡量精確比較:
@override
bool shouldRepaint(MyPainter oldDelegate) {
return oldDelegate.progress != progress || oldDelegate.color != color;
}
3. 善用 child 參數
在 AnimatedBuilder 中,不會隨動畫改變的部分放在 child 參數裡,這樣 Flutter 就不需要每幀都重建那些 Widget:
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * pi,
child: child, // 這個不會每幀重建
);
},
child: const Icon(Icons.refresh, size: 48), // 只建構一次
)
4. 避免在動畫中做 layout
動畫過程中盡量只做 transform(位移、旋轉、縮放)和 opacity 變化,這些操作不需要重新 layout,效能最好。如果動畫中有寬高變化,Flutter 需要每幀重新計算 layout,代價很高。
結語
Flutter 的動畫系統設計得非常有層次感——從簡單到複雜,從隱式到顯式,每一層都有明確的使用場景。
我的建議是:
- 能用隱式動畫就用隱式動畫 — 程式碼最少,出錯機率最低
- 需要循環或精確控制時用顯式動畫 — AnimationController 給你完整的控制力
- 頁面轉場首選 Hero — 設定簡單,效果專業
- 終極效果用 CustomPainter — 想畫什麼都可以,但也最需要功力
希望這篇整理對你有幫助。如果想看這些動畫效果的實際表現,歡迎下載我的 App 來體驗!