Flutter 動畫效果大全:從隱式動畫到自訂繪製

前言

嗨,我是 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(),
),

隱式動畫的限制

隱式動畫雖然簡單,但有兩個主要限制:

  1. 無法控制動畫的中間過程 — 你只能設定起點和終點,中間完全由 Flutter 控制
  2. 無法做循環動畫 — 隱式動畫只在值改變時觸發一次,不會自動重複

當你需要更精細的控制,就該進入顯式動畫了。

第二層:顯式動畫(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 的背景中用了三層不同透明度的波浪,效果相當好看。

粒子爆炸效果

結合 CustomPainterAnimationController,可以做出各種粒子效果。以下是一個通用的粒子爆炸效果:

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 要精確

CustomPaintershouldRepaint 方法決定了什麼時候需要重繪。不要永遠回傳 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 來體驗!