Flutterヒーローアニメーション

Hero アニメーションは、超簡単なのに超かっこいい! アニメーションです! 🦸‍♂️
比喩で言うと: 映画のヒーローのようなもの。2つの場面(画面)を華麗につなぐ!

目次

Hero アニメーションって何?

Hero アニメーションは、画面遷移時に、同じ要素が滑らかに移動・拡大するアニメーション!

リスト→詳細小さい画像 → 大きい画像
サムネイル→拡大タップすると画像が拡大
カード→詳細カード全体が拡大して詳細画面に
  • Instagram: 投稿タップ → 画像が拡大
  • Google Photos: サムネイル → フルスクリーン
  • App Store: アプリアイコン → 詳細画面

基本的な使い方

たった2ステップ!
ステップ1: 元の画面に Hero を付ける
ステップ2: 遷移先の画面にも同じ tag の Hero を付ける
それだけ! Flutterが自動でアニメーションしてくれます! 🎉

🎨 実践例1: 画像の拡大

画面A (リスト画面):

import 'package:flutter/material.dart';

class ImageListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('画像リスト')),
      body: GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 3,
          crossAxisSpacing: 4,
          mainAxisSpacing: 4,
        ),
        itemCount: 9,
        itemBuilder: (context, index) {
          return GestureDetector(
            onTap: () {
              // 詳細画面へ遷移
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => ImageDetailPage(imageIndex: index),
                ),
              );
            },
            child: Hero(
              // ========================================
              // Hero の設定(ステップ1)
              // ========================================
              tag: 'image_$index',  // ユニークなタグ(重要!)
              child: Image.network(
                'https://picsum.photos/200/200?random=$index',
                fit: BoxFit.cover,
              ),
            ),
          );
        },
      ),
    );
  }
}

画面B (詳細画面):

class ImageDetailPage extends StatelessWidget {
  final int imageIndex;
  
  const ImageDetailPage({required this.imageIndex});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
      ),
      body: Center(
        child: Hero(
          // ========================================
          // Hero の設定(ステップ2)
          // 同じタグを使う!
          // ========================================
          tag: 'image_$imageIndex',  // 画面Aと同じタグ
          child: Image.network(
            'https://picsum.photos/800/800?random=$imageIndex',
            fit: BoxFit.contain,
          ),
        ),
      ),
    );
  }
}

たったこれだけ! 😊
動き:

小さい画像をタップ
→ スーッと移動しながら拡大
→ 詳細画面に到達
戻るボタン → 逆のアニメーション!

実践例2: カード全体をアニメーション

リスト画面:

class StudentListPage extends StatelessWidget {
  final List<Student> students = [
    Student(name: '田中太郎', fee: 8000, photoUrl: 'https://i.pravatar.cc/150?img=1'),
    Student(name: '佐藤花子', fee: 8000, photoUrl: 'https://i.pravatar.cc/150?img=2'),
    Student(name: '鈴木一郎', fee: 9000, photoUrl: 'https://i.pravatar.cc/150?img=3'),
  ];
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('生徒一覧')),
      body: ListView.builder(
        itemCount: students.length,
        itemBuilder: (context, index) {
          final student = students[index];
          
          return GestureDetector(
            onTap: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => StudentDetailPage(student: student),
                ),
              );
            },
            child: Hero(
              tag: 'student_${student.name}',  // 生徒名をタグに
              child: Card(
                margin: EdgeInsets.all(8),
                child: ListTile(
                  leading: CircleAvatar(
                    backgroundImage: NetworkImage(student.photoUrl),
                  ),
                  title: Text(student.name),
                  subtitle: Text('月謝: ¥${student.fee}'),
                  trailing: Icon(Icons.arrow_forward_ios),
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

class Student {
  final String name;
  final int fee;
  final String photoUrl;
  
  Student({required this.name, required this.fee, required this.photoUrl});
}

詳細画面:

class StudentDetailPage extends StatelessWidget {
  final Student student;
  
  const StudentDetailPage({required this.student});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(student.name)),
      body: Column(
        children: [
          // ========================================
          // 同じタグのHero
          // ========================================
          Hero(
            tag: 'student_${student.name}',  // 同じタグ!
            child: Material(  // Material で囲むと、Cardの見た目を維持
              child: Container(
                width: double.infinity,
                padding: EdgeInsets.all(16),
                color: Colors.white,
                child: Row(
                  children: [
                    CircleAvatar(
                      radius: 40,
                      backgroundImage: NetworkImage(student.photoUrl),
                    ),
                    SizedBox(width: 16),
                    Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          student.name,
                          style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
                        ),
                        Text('月謝: ¥${student.fee}', style: TextStyle(fontSize: 18)),
                      ],
                    ),
                  ],
                ),
              ),
            ),
          ),
          
          // その他の詳細情報
          Expanded(
            child: Padding(
              padding: EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('出席状況', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
                  SizedBox(height: 16),
                  Text('2026年1月: 4回出席'),
                  Text('2025年12月: 4回出席'),
                  // ...
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

動き: カード全体がスーッと上に移動して拡大! かっこいい! ✨

カスタマイズ

アニメーションの時間を変える

Hero(
  tag: 'my_hero',
  flightShuttleBuilder: (
    flightContext,
    animation,
    flightDirection,
    fromHeroContext,
    toHeroContext,
  ) {
    // カスタムアニメーション
    return DefaultTextStyle(
      style: DefaultTextStyle.of(toHeroContext).style,
      child: toHeroContext.widget,
    );
  },
  child: Image.network('...'),
)

異なる形状の間でアニメーション

// 画面A: 丸い画像
Hero(
  tag: 'profile',
  child: CircleAvatar(
    radius: 30,
    backgroundImage: NetworkImage('...'),
  ),
)

// 画面B: 四角い画像
Hero(
  tag: 'profile',
  child: Container(
    width: 200,
    height: 200,
    child: Image.network('...', fit: BoxFit.cover),
  ),
)

Flutter が自動で形を変形させてくれる! 円 → 四角にスムーズに変化!

背景色を変える

// 画面A
Hero(
  tag: 'card',
  child: Material(
    color: Colors.blue[100],
    child: Container(
      padding: EdgeInsets.all(16),
      child: Text('カード'),
    ),
  ),
)

// 画面B
Hero(
  tag: 'card',
  child: Material(
    color: Colors.white,
    child: Container(
      padding: EdgeInsets.all(32),
      child: Text('詳細'),
    ),
  ),
)

色も滑らかに変化! 青 → 白

複数の Hero を同時に

// リスト画面
Row(
  children: [
    Hero(
      tag: 'avatar_${student.id}',
      child: CircleAvatar(...),
    ),
    Hero(
      tag: 'name_${student.id}',
      child: Text(student.name),
    ),
    Hero(
      tag: 'fee_${student.id}',
      child: Text('¥${student.fee}'),
    ),
  ],
)

// 詳細画面
Column(
  children: [
    Hero(
      tag: 'avatar_${student.id}',
      child: CircleAvatar(radius: 50, ...),
    ),
    Hero(
      tag: 'name_${student.id}',
      child: Text(student.name, style: TextStyle(fontSize: 32)),
    ),
    Hero(
      tag: 'fee_${student.id}',
      child: Text('¥${student.fee}', style: TextStyle(fontSize: 24)),
    ),
  ],
)

動き:

▶️ ボタン: 50 → 300 に拡大
⏪ ボタン: 300 → 50 に縮小
🔄 ボタン: 50 にリセット

実践例2: 回転するアイコン

class RotationDemo extends StatefulWidget {
  @override
  _RotationDemoState createState() => _RotationDemoState();
}

class _RotationDemoState extends State<RotationDemo>
    with SingleTickerProviderStateMixin {
  
  late AnimationController _controller;
  
  @override
  void initState() {
    super.initState();
    
    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 2),
    );
  }
  
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Rotation Animation')),
      body: Center(
        child: AnimatedBuilder(
          animation: _controller,
          builder: (context, child) {
            return Transform.rotate(
              angle: _controller.value * 2 * 3.14159,  // 0〜2π (1回転)
              child: Icon(
                Icons.refresh,
                size: 100,
                color: Colors.blue,
              ),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          if (_controller.isAnimating) {
            _controller.stop();  // 停止
          } else {
            _controller.repeat();  // 繰り返し回転
          }
        },
        child: Icon(
          _controller.isAnimating ? Icons.pause : Icons.play_arrow,
        ),
      ),
    );
  }
}

それぞれが独立して移動・拡大! まるでパズルのピースが組み替わるような動き! 🧩

⚠️ 注意点とコツ

tag は必ずユニーク!

// ❌ ダメ - 同じタグが複数
ListView.builder(
  itemBuilder: (context, index) {
    return Hero(
      tag: 'image',  // 全部同じタグ!
      child: Image.network('...'),
    );
  },
)

// ✅ OK - ユニークなタグ
ListView.builder(
  itemBuilder: (context, index) {
    return Hero(
      tag: 'image_$index',  // インデックスで区別
      // または
      // tag: 'image_${item.id}',  // IDで区別
      child: Image.network('...'),
    );
  },
)

Material で囲むと良い

// ❌ これだとテキストが消える
Hero(
  tag: 'card',
  child: Card(
    child: Text('Hello'),
  ),
)

// ✅ Material で囲む
Hero(
  tag: 'card',
  child: Material(
    child: Card(
      child: Text('Hello'),
    ),
  ),
)

理由: Heroアニメーション中も Material の効果(影など)が保たれる!

サイズが大きく違うとき

// 小さい画像
Hero(
  tag: 'photo',
  child: Image.network(
    'https://picsum.photos/100/100',  // 100x100
    fit: BoxFit.cover,  // ← これ重要
  ),
)

// 大きい画像
Hero(
  tag: 'photo',
  child: Image.network(
    'https://picsum.photos/800/800',  // 800x800
    fit: BoxFit.cover,  // ← 同じにする
  ),
)

fit を統一すると滑らかに!

背景色を合わせる

// 画面A
Hero(
  tag: 'box',
  child: Material(
    color: Colors.transparent,  // ← 透明
    child: Container(color: Colors.blue, ...),
  ),
)

// 画面B
Hero(
  tag: 'box',
  child: Material(
    color: Colors.transparent,  // ← 同じく透明
    child: Container(color: Colors.blue, ...),
  ),
)

Material の color を透明にすると、背景が見える!

応用: ページ遷移全体をカスタマイズ

Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) {
      return StudentDetailPage(student: student);
    },
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      // フェードイン
      return FadeTransition(
        opacity: animation,
        child: child,
      );
    },
    transitionDuration: Duration(milliseconds: 500),  // 遷移時間
  ),
);

Hero + カスタムページ遷移 = 超かっこいい! ✨

まとめ: Hero アニメーション

使い方:
両方の画面に Hero を配置
同じ tag を設定
完成! 🎉

ポイント:
tag は必ずユニーク
Material で囲むと安全
fit を統一すると滑らか
複数の Hero を同時に使える

いつ使う?

用途効果
画像拡大サムネイル → フルスクリーン
リスト→詳細カード → 詳細画面
プロフィール小アイコン → 大アイコン
商品商品カード → 商品詳細


一言まとめ:
Hero アニメーションは超簡単!
両方の画面に Hero(tag: ‘同じタグ’) を付けるだけ。
画面遷移時に自動でスムーズに移動・拡大。
リスト→詳細画面で大活躍! tag はユニークに、
Material で囲むと安全! かっこいいアプリが作れる! 🦸‍♂️✨

目次