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 で囲むと安全! かっこいいアプリが作れる! 🦸♂️✨
