Flutter: アニメーションシステム

アニメーションはUIの魂 — アニメーションのないインターフェースはスライドショー、あるものは映画のようです。

📋 前提条件: 以下に既に慣れている必要があります

1. このレッスンで学ぶこと


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. アニメーションシステム概要

100%
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で手動でチェーンします。

📖 まとめ


📝 練習問題

  1. 基本 (⭐):AnimatedContainerを使ってタップで展開/折りたたみするカードを作成し、高さと背景色を切り替えてください。
  2. 中級 (⭐⭐):商品リストから詳細ページへのHeroトランジションアニメーションを実装し、画像がカード位置から詳細ページのヒーロー画像に飛ぶようにしてください。
  3. チャレンジ (⭐⭐⭐):完全な「カート飛び」アニメーションを実装してください:カート追加タップ後、商品サムネイルがカードからAppBarのカートアイコンに飛び、飛行中に縮小・フェードアウトします。

← 前のレッスン | 次のレッスン →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%