Flutter: アニメーションシステム
アニメーションはUIの魂 — アニメーションのないインターフェースはスライドショー、あるものは映画のようです。
📋 前提条件: 以下に既に慣れている必要があります
- レッスン5:StatefulWidgetとインタラクション
1. このレッスンで学ぶこと
- 暗黙的アニメーション:AnimatedContainer、AnimatedOpacity、AnimatedSwitcher、AnimatedCrossFade
- 明示的アニメーション:AnimationController + Tween + CurvedAnimation
- HeroトランジションアニメーションとPageRouteBuilderカスタムページ遷移
- AnimatedBuilder / AnimatedWidgetによるリビルドスコープの精密制御
- ShopApp:商品カードのカート飛びアニメーション + 共有要素ページ遷移
2. 硬いインターフェースのリアルなストーリー
(1) 悩み:状態変化がスライドのように見える
BobのShopApp商品詳細ページでは「カートに追加」をタップすると瞬時に切り替わります — 数量が0から1にジャンプし、価格が$0から$99.99にジャンプし、カートバッジが突然「3」と表示されます。ユーザーはアプリが「フリーズした」または「バグがある」と思います — 実際にはトランジションアニメーションがないだけです。アニメーションなしのバージョンでは35%のユーザーが「UIラグ」を報告しました。
(2) 暗黙的アニメーションソリューション
Flutterの暗黙的アニメーションウィジェットはターゲット値を設定するだけで、フレームワークが自動的にトランジションアニメーションを生成します。
DART
import 'package:flutter/material.dart';
// Before: instant change (looks like a bug)
Text('\$$_total')
// After: smooth animation
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text('\$$_total', key: ValueKey(_total)),
)
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(3) 成果:知覚される滑らかさの向上
Bobがアニメーションを追加した後、「UIラグ」の報告は35%から2%に減少し、ユーザーセッション時間が20%増加しました。
3. アニメーションシステム概要
graph TD
A[Animation System] --> IA[Implicit Animation]
A --> EA[Explicit Animation]
IA --> AC[AnimatedContainer]
IA --> AO[AnimatedOpacity]
IA --> AS2[AnimatedSwitcher]
EA --> ACTRL[AnimationController]
ACTRL --> TW[Tween]
ACTRL --> CURVE[CurvedAnimation]
EA --> AB[AnimatedBuilder]
EA --> HERO[Hero Transition]
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
(1) 暗黙的 vs 明示的アニメーション
| 側面 | 暗黙的アニメーション | 明示的アニメーション |
|---|---|---|
| 制御 | 自動(ターゲット値を設定) | 手動(Controller駆動) |
| 複雑さ | 低い | 高い |
| 柔軟性 | 限定 | フルコントロール |
| ループ/逆再生 | 非対応 | 対応 |
| ユースケース | シンプルなトランジション | 複雑なカスタムアニメーション |
4. 暗黙的アニメーション
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:AnimatedContainer展開/折りたたみ
DART
import 'package:flutter/material.dart';
class ExpandableCard extends StatefulWidget {
const ExpandableCard({super.key});
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutCubic,
height: _expanded ? 200 : 80,
padding: EdgeInsets.all(_expanded ? 16 : 8),
decoration: BoxDecoration(
color: _expanded ? Colors.blue[50] : Colors.white,
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
boxShadow: [BoxShadow(blurRadius: _expanded ? 12 : 4, color: Colors.black12)],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Pro Laptop', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
if (_expanded) ...[
const SizedBox(height: 12),
const Text('Latest model with M3 chip. 16GB RAM, 512GB SSD.'),
const SizedBox(height: 8),
const Text('\$1,299.99', style: TextStyle(fontSize: 20, color: Colors.green)),
],
]),
),
);
}
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:AnimatedSwitcher数値切り替え
DART
import 'package:flutter/material.dart';
class AnimatedPrice extends StatelessWidget {
final double price;
const AnimatedPrice({super.key, required this.price});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
child: Text('\$${price.toStringAsFixed(2)}',
key: ValueKey(price),
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.green),
),
);
}
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:AnimatedCrossFadeビュー切り替え
DART
import 'package:flutter/material.dart';
// Assume isFavorite is defined elsewhere
// bool isFavorite = false;
AnimatedCrossFade(
firstChild: const Icon(Icons.favorite_border, size: 32),
secondChild: const Icon(Icons.favorite, size: 32, color: Colors.red),
crossFadeState: isFavorite ? CrossFadeState.showSecond : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 300),
)
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
5. 明示的アニメーション
(1) AnimationController + Tween + Curve
| コンポーネント | 責務 |
|---|---|
AnimationController |
アニメーションのタイミング、方向、状態を制御 |
Tween<T> |
開始値と終了値を定義 |
CurvedAnimation |
イージングカーブを適用 |
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:商品カート飛びアニメーション
DART
import 'package:flutter/material.dart';
class FlyToCartAnimation extends StatefulWidget {
final GlobalKey cartKey;
final Widget child;
final VoidCallback onComplete;
const FlyToCartAnimation({super.key, required this.cartKey, required this.child, required this.onComplete});
@override
State<FlyToCartAnimation> createState() => _FlyToCartAnimationState();
}
class _FlyToCartAnimationState extends State<FlyToCartAnimation> with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: const Duration(milliseconds: 600), vsync: this);
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOutCubic);
_controller.addStatusListener((status) {
if (status == AnimationStatus.completed) widget.onComplete();
});
}
void startFly() {
_controller.forward(from: 0);
}
@override
void dispose() { _controller.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(animation: _animation, builder: (context, child) {
final cartBox = widget.cartKey.currentContext?.findRenderObject() as RenderBox?;
if (cartBox == null) return child!;
final cartPos = cartBox.localToGlobal(Offset.zero);
return Transform.translate(
offset: Offset(cartPos.dx * _animation.value, cartPos.dy * _animation.value),
child: Transform.scale(scale: 1 - _animation.value * 0.5, child: child),
);
}, child: widget.child);
}
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
6. Heroトランジションアニメーション
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:商品画像共有要素トランジション
DART
import 'package:flutter/material.dart';
// Custom class definition source:
// - product: see Lesson 11 json_serializable model
// In product list
Hero(
tag: 'product_${product.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(product.imageUrl, fit: BoxFit.cover),
),
)
// In product detail page
Hero(
tag: 'product_${product.id}',
child: Image.network(product.imageUrl, fit: BoxFit.cover),
)
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
▶ サンプル
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
:PageRouteBuilderカスタム遷移
DART
import 'package:flutter/material.dart';
// Custom class definition source:
// - DetailPage/product: see Lesson 11 / Lesson 14
class SlideUpRoute extends PageRouteBuilder {
final Widget page;
SlideUpRoute(this.page) : super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 400),
);
}
// Usage
Navigator.push(context, SlideUpRoute(DetailPage(product: product)));
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
7. Curvesイージングカーブ
| カーブ | 効果 | ユースケース |
|---|---|---|
Curves.linear |
一定速度 | ほとんど使われない |
Curves.easeIn |
遅い → 速い | 終了 |
Curves.easeOut |
速い → 遅い | 開始 |
Curves.easeInOut |
遅い → 速い → 遅い | 汎用 |
Curves.easeOutCubic |
早い到着 | 弾力効果 |
Curves.elasticOut |
弾力バウンス | 遊び心のあるアニメーション |
Curves.bounceOut |
バウンス | ゲーム的な感覚 |
8. 完全な例:ShopApp商品カードアニメーションスイート
DART
import 'package:flutter/material.dart';
// Custom class definition source:
// - Product: see Lesson 11 json_serializable model
class AnimatedProductCard extends StatefulWidget {
final Product product;
final VoidCallback onAddToCart;
const AnimatedProductCard({super.key, required this.product, required this.onAddToCart});
@override
State<AnimatedProductCard> createState() => _AnimatedProductCardState();
}
class _AnimatedProductCardState extends State<AnimatedProductCard> with SingleTickerProviderStateMixin {
bool _isFavorite = false;
bool _isAdded = false;
late AnimationController _scaleController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_scaleController = AnimationController(duration: const Duration(milliseconds: 200), vsync: this);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.easeInOut));
}
void _onTap() {
_scaleController.forward().then((_) => _scaleController.reverse());
}
void _addToCart() {
setState(() => _isAdded = true);
widget.onAddToCart();
Future.delayed(const Duration(seconds: 2), () => setState(() => _isAdded = false));
}
@override
void dispose() { _scaleController.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => _scaleController.forward(),
onTapUp: (_) => _scaleController.reverse(),
onTapCancel: () => _scaleController.reverse(),
child: ScaleTransition(
scale: _scaleAnimation,
child: Card(
clipBehavior: Clip.antiAlias,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Expanded(flex: 3, child: Stack(children: [
Hero(tag: 'product_${widget.product.id}',
child: Image.network(widget.product.imageUrl, fit: BoxFit.cover, width: double.infinity)),
Positioned(top: 8, right: 8,
child: AnimatedSwitcher(duration: const Duration(milliseconds: 300),
child: IconButton(key: ValueKey(_isFavorite),
icon: Icon(_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? Colors.red : Colors.white),
onPressed: () => setState(() => _isFavorite = !_isFavorite),
style: IconButton.styleFactory(backgroundColor: Colors.white70)))),
]),
Expanded(flex: 2, child: Padding(padding: const EdgeInsets.all(8), child: Column(
crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.product.name, maxLines: 1, overflow: TextOverflow.ellipsis),
const Spacer(),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('\$${widget.product.price.toStringAsFixed(2)}', style: const TextStyle(color: Colors.green)),
AnimatedSwitcher(duration: const Duration(milliseconds: 300),
child: _isAdded ? const Icon(Icons.check_circle, color: Colors.green, key: ValueKey('added'))
: IconButton(icon: const Icon(Icons.add_shopping_cart, size: 20),
onPressed: _addToCart, key: const ValueKey('add'))),
]),
])))),
]),
),
),
);
}
}
TEXT
> 出力: ローカルのFlutter SDKで実行してください(Flutter 3.x / Dart 3.x)。PistonサーバーにはFlutterがインストールされていません — 手元のマシンで`flutter run`して動作確認してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。
❓ よくある質問
Q 暗黙的と明示的アニメーションのどちらを選ぶべきですか?
A 可能な限り暗黙的を使いましょう(シンプルで安全)。ループ、逆再生、精密制御が必要な場合は明示的を使います。
Q AnimationControllerはdispose()で必ず破棄する必要がありますか?
A はい。破棄しないとメモリリークが発生します。vsyncの提供にはSingleTickerProviderStateMixinを使用してください。
Q AnimatedBuilderとAnimatedWidgetの違いは何ですか?
A AnimatedBuilderはコールバックパターンを使用し、AnimatedWidgetはサブクラス化が必要です。AnimatedBuilderのほうが柔軟で推奨されます。
Q Heroタグは両方のページで同じである必要がありますか?
A はい。Heroはタグを使ってページ間でウィジェットをマッチングします — タグはグローバルに一意で一貫している必要があります。
Q アニメーションのカクつきをどう最適化しますか?
A 1) AnimatedBuilderでリビルドスコープを限定。2) RepaintBoundaryで再描画を分離。3) アニメーションコールバック内の重い処理を避ける。
Q 複数のアニメーションをどう連携させますか?
A Intervalを使って複数のアニメーションをずらすか、AnimationControllerのaddStatusListenerで手動でチェーンします。
📖 まとめ
- 暗黙的アニメーション:ターゲット値を設定すればFlutterがトランジションを処理(AnimatedContainer/Switcher)
- 明示的アニメーション:AnimationControllerがフルコントロールを提供、ループと逆再生に対応
- Heroはページ間の共有要素トランジションを実装
- Curvesがアニメーションのペースを制御 — easeOutCubicが最も一般的
- AnimatedBuilderがリビルドスコープを限定し、アニメーションパフォーマンスを最適化
📝 練習問題
- 基本 (⭐):AnimatedContainerを使ってタップで展開/折りたたみするカードを作成し、高さと背景色を切り替えてください。
- 中級 (⭐⭐):商品リストから詳細ページへのHeroトランジションアニメーションを実装し、画像がカード位置から詳細ページのヒーロー画像に飛ぶようにしてください。
- チャレンジ (⭐⭐⭐):完全な「カート飛び」アニメーションを実装してください:カート追加タップ後、商品サムネイルがカードからAppBarのカートアイコンに飛び、飛行中に縮小・フェードアウトします。