Chương 12 · Animation

Animation

Animation tốt → app feel premium. Flutter có 2 hệ: implicit (đơn giản, 80% use case) và explicit (control đầy đủ). Beginner thường jump thẳng explicit → over-engineer. Chương này dạy đúng spectrum.

Độ dài: ~1090 dòng Bài tập: 5 Quiz: 8 Prerequisites: Ch 2-4 + Dart Ch 7
🎯 Mục tiêu chương
  • Phân biệt implicit vs explicit animation.
  • Implicit family: AnimatedContainer, AnimatedOpacity, AnimatedAlign, AnimatedSwitcher.
  • Explicit: AnimationController, Tween<T>, Curve.
  • TweenAnimationBuilder — flexible implicit.
  • vsync với SingleTickerProviderStateMixin.
  • Hero — shared element transition.
  • Lottie / Rive cho animation phức tạp.
  • Custom animation với CustomPainter.
  • Staggered animation.
  • Performance: RepaintBoundary.

1. Implicit vs Explicit — spectrum

ImplicitExplicit
Cách dùngDeclare target state, Flutter tween từ state hiện tạiControl AnimationController + value mỗi frame
BoilerplateMinCao
FlexibilityLimitedFull
Use case80% — UI state transition đơn giảnComplex sequence, custom curve, multi-property
VdAnimatedContainer color changeLoading spinner custom, hero spring

2. Implicit — Animated* widget family

WidgetAnim
AnimatedContainercolor, size, padding, decoration, alignment
AnimatedOpacityfade in/out
AnimatedAlignvị trí align
AnimatedDefaultTextStyletext style transition
AnimatedCrossFadefade giữa 2 child
AnimatedSwitcherswap child có transition
AnimatedSizesize change
AnimatedPaddingpadding change
AnimatedPositionedposition trong Stack
class _ToggleBoxState extends State<ToggleBox> {
  bool _on = false;

  @override
  Widget build(BuildContext context) => GestureDetector(
    onTap: () => setState(() => _on = !_on),
    child: AnimatedContainer(
      duration: const Duration(milliseconds: 300),
      curve: Curves.easeInOut,
      width: _on ? 200 : 100,
      height: _on ? 100 : 50,
      decoration: BoxDecoration(
        color: _on ? Colors.amber : Colors.blue,
        borderRadius: BorderRadius.circular(_on ? 24 : 8),
      ),
    ),
  );
}
// 1 setState đổi flag → AnimatedContainer tween từ size+color cũ → mới.

3. TweenAnimationBuilder — flexible implicit

TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: targetValue),
  duration: const Duration(seconds: 1),
  curve: Curves.easeOutCubic,
  builder: (context, value, child) {
    return Transform.rotate(angle: value * 6.28, child: child);
  },
  child: const Icon(Icons.refresh, size: 64),
)
// Khi targetValue đổi → animate từ value hiện tại → target mới.

4. Explicit — AnimationController + Tween

class _BouncingState extends State<Bouncing> with SingleTickerProviderStateMixin {
  late final AnimationController _ctrl;
  late final Animation<double> _scale;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 800),
    );
    _scale = Tween<double>(begin: 1, end: 1.4).animate(
      CurvedAnimation(parent: _ctrl, curve: Curves.elasticOut),
    );

    _ctrl.repeat(reverse: true);   // loop forward + reverse
  }

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

  @override
  Widget build(BuildContext context) => ScaleTransition(
    scale: _scale,
    child: const Icon(Icons.favorite, color: Colors.red, size: 100),
  );
}
⚠️ Luôn dispose AnimationController

Không dispose = leak + animation tiếp tục chạy sau khi widget unmount. super.dispose() không tự dispose controller.

vsync và TickerProviderMixin

  • vsync: this — sync animation với screen refresh (60Hz/120Hz). Tránh waste CPU khi screen off.
  • SingleTickerProviderStateMixin — 1 controller. Đa số case.
  • TickerProviderStateMixin — nhiều controller.

5. Curve — easing function

Curves.linear            // đều, robot-like
Curves.easeIn            // chậm đầu
Curves.easeOut           // chậm cuối
Curves.easeInOut         // chậm 2 đầu, "natural" nhất
Curves.easeOutCubic      // recommend cho UI transition
Curves.bounceIn          // nảy
Curves.bounceOut
Curves.elasticIn         // đàn hồi
Curves.elasticOut
Curves.fastOutSlowIn    // Material default
💡 Curve quan trọng hơn duration

Cùng duration 300ms, easeOutCubic feel premium, linear feel mechanical. Default cho hầu hết UI: easeOutCubic hoặc fastOutSlowIn.

6. AnimatedBuilder — perf pattern

// ❌ Sai — heavy widget rebuild mỗi frame
return RotationTransition(
  turns: _ctrl,
  child: ExpensiveWidget(),   // rebuild mỗi tick
);

// ✅ Đúng — child ngoài builder, không rebuild
return AnimatedBuilder(
  animation: _ctrl,
  builder: (context, child) => Transform.rotate(
    angle: _ctrl.value * 6.28,
    child: child,
  ),
  child: const ExpensiveWidget(),   // build 1 lần, pass vào builder
);

7. Staggered animation

// 1 controller, nhiều animation interval
late final AnimationController _ctrl = AnimationController(
  vsync: this, duration: const Duration(seconds: 2),
);

late final Animation<double> _fade = CurvedAnimation(
  parent: _ctrl, curve: const Interval(0, 0.4, curve: Curves.easeOut),
);
late final Animation<Offset> _slide = Tween(
  begin: const Offset(0, 0.3), end: Offset.zero,
).animate(CurvedAnimation(
  parent: _ctrl, curve: const Interval(0.3, 0.7, curve: Curves.easeOut),
));
late final Animation<double> _scale = Tween(begin: 0.8, end: 1.0).animate(
  CurvedAnimation(parent: _ctrl, curve: const Interval(0.6, 1.0, curve: Curves.easeOutBack)),
);

// 3 anim chạy theo timeline cùng 1 controller — staggered.

8. Hero — shared element transition

// Trong list page
GestureDetector(
  onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => DetailPage(item: item))),
  child: Hero(
    tag: 'item-${item.id}',
    child: Image.network(item.thumbUrl, width: 100, height: 100),
  ),
)

// Trong detail page
Hero(
  tag: 'item-${item.id}',    // CÙNG tag
  child: Image.network(item.fullUrl, width: screenWidth, height: 300),
)
// Flutter tự animate giữa 2 vị trí + size.

9. Lottie / Rive

flutter pub add lottie
flutter pub add rive
// Lottie — JSON từ Adobe After Effects
Lottie.asset('assets/loading.json', width: 200, height: 200)
Lottie.network('https://lottiefiles.com/some.json')

// Rive — vector animation với state machine interactive
RiveAnimation.asset('assets/character.riv')
🔀 Lottie vs Rive
  • Lottie: one-shot animation export từ AE. Đẹp cho loading, splash, illustration.
  • Rive: interactive — state machine với input/event. Game-like, character animation.

10. Custom page route transition

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (_, __, ___) => const NextPage(),
    transitionDuration: const Duration(milliseconds: 300),
    transitionsBuilder: (_, anim, __, child) {
      return SlideTransition(
        position: Tween<Offset>(
          begin: const Offset(0, 1),    // slide từ dưới
          end: Offset.zero,
        ).animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
        child: child,
      );
    },
  ),
)

11. AnimatedList

final _listKey = GlobalKey<AnimatedListState>();
final _items = <Item>[];

AnimatedList(
  key: _listKey,
  initialItemCount: _items.length,
  itemBuilder: (context, index, animation) => SizeTransition(
    sizeFactor: animation,
    child: ListTile(title: Text(_items[index].title)),
  ),
)

// Add với animation
void _addItem(Item item) {
  _items.insert(0, item);
  _listKey.currentState?.insertItem(0, duration: const Duration(milliseconds: 300));
}

// Remove với animation
void _removeItem(int index) {
  final removed = _items.removeAt(index);
  _listKey.currentState?.removeItem(index,
    (context, animation) => SizeTransition(
      sizeFactor: animation,
      child: ListTile(title: Text(removed.title)),
    ),
  );
}

12. Physics-based animation (intro)

// SpringSimulation — physics
final spring = SpringSimulation(
  SpringDescription(mass: 1, stiffness: 100, damping: 10),
  0,    // start
  1,    // end
  0,    // initial velocity
);
_ctrl.animateWith(spring);

// BouncingScrollPhysics — đã quen từ ListView iOS-style
ListView(
  physics: const BouncingScrollPhysics(),
  children: [/*...*/],
)

13. RepaintBoundary — performance

// Isolate paint layer — parent rebuild không trigger child repaint
RepaintBoundary(
  child: ExpensiveAnimation(),
)

// Khi animation trong RepaintBoundary chạy, chỉ paint phần đó.
// Parent không liên quan animation → không bị repaint.

14. Performance Overlay

MaterialApp(
  showPerformanceOverlay: true,   // 2 bar đo UI + Raster thread
)

// 2 thanh:
// - UI thread (Dart): build/layout time
// - Raster thread: GPU paint time
// Đỏ = vượt 16ms (60fps) → jank.

15. Bài tập

Theme toggle animated

Toggle theme button với AnimatedContainer đổi background + icon rotate 180°. Mỗi click swap.

💡 Gợi ý đáp án

AnimatedContainer width/color, RotationTransition cho icon. Hoặc AnimatedRotation.

Like animation

Tap heart icon → scale up + color change. Dùng explicit (AnimationController + Tween). Bonus: thêm particle burst nhỏ.

💡 Gợi ý đáp án

AnimationController duration 400ms. Tween scale 1 → 1.4 → 1 (use TweenSequence). Color animation từ grey → red. Particle: stack với nhiều dot animate radial out.

Hero detail page

Grid product list → tap → detail page. Hero transition thumbnail → full image.

💡 Gợi ý đáp án

Tag unique per item: 'product-${item.id}'. Cùng tag ở 2 page, Flutter tự animate.

Lottie loading screen

Loading screen với Lottie animation tải free từ LottieFiles.

💡 Gợi ý đáp án

Download từ lottiefiles.com → put vào assets. Lottie.asset('assets/loading.json').

Bouncing notification badge

Notification badge: số đếm tăng → badge scale 0 → 1.2 → 1 với spring. Dùng SpringSimulation hoặc Curves.elasticOut.

💡 Gợi ý đáp án
TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: 1),
  duration: const Duration(milliseconds: 500),
  curve: Curves.elasticOut,
  builder: (_, value, __) => Transform.scale(scale: value, child: badge),
)

16. Quiz

Q1

AnimatedContainer cần controller không?

Xem đáp án

Đáp án: Không. Implicit tự nội — chỉ cần đổi prop + duration + curve, Flutter tween.

Q2

vsync parameter — vai trò?

Xem đáp án

Đáp án: Sync animation với ticker (frame refresh). Pause khi screen off để save CPU/battery.

Q3

AnimationController không dispose — chuyện gì?

Xem đáp án

Đáp án: Memory leak + animation tiếp chạy nền sau dispose widget. super.dispose() không tự dispose controller.

Q4

Hero tag duplicate giữa 2 page — chuyện gì?

Xem đáp án

Đáp án: Throw error "There are multiple heroes that share the same tag within a subtree". Tag phải unique.

Q5

Curves.easeInOut vs Curves.linear — khác feel?

Xem đáp án

Đáp án: easeInOut "natural", linear "robot-like". Easing usually preferred trừ progress indicator (cần linear).

Q6

RepaintBoundary giúp gì?

Xem đáp án

Đáp án: Isolate paint layer. Repaint chỉ phần thay đổi, không repaint cả tree. Performance hint.

Q7

Lottie vs Rive — chọn?

Xem đáp án

Đáp án: Lottie cho one-shot animation export từ AE. Rive cho interactive với state machine.

Q8

AnimatedBuilder builder gọi mỗi frame — performance OK?

Xem đáp án

Đáp án: OK nếu widget trong builder rẻ. Đặt heavy widget ngoài (pass qua child param), chỉ widget animated trong builder.

17. Tổng kết

  • ✅ Implicit (Animated*) cho 80% case — declare target, framework tween.
  • ✅ Explicit (AnimationController + Tween) cho complex sequence.
  • vsync + SingleTickerProviderStateMixin.
  • ✅ Curve quan trọng hơn duration — easeOutCubic default.
  • ✅ AnimatedBuilder pattern: heavy child ngoài, transform trong builder.
  • ✅ Staggered animation — 1 controller, nhiều Interval.
  • ✅ Hero — shared element transition giữa route.
  • ✅ Lottie (one-shot) vs Rive (interactive).
  • ✅ AnimatedList cho insert/remove animation.
  • ✅ Physics — SpringSimulation.
  • ✅ RepaintBoundary isolate paint zone.
  • ✅ Performance Overlay đo UI + Raster thread time.

18. Kết nối