Hero 애니메이션이란 무엇이며 어떻게 구현하나요?
질문
Flutter에서 Hero 애니메이션은 무엇이며, 어떻게 구현하나요?
답변
Hero 애니메이션은 Flutter에서 화면 간 전환 시 요소가 한 위치에서 다른 위치로 부드럽게 애니메이션되는 패턴입니다. 주로 목록 화면에서 상세 화면으로 이동할 때 이미지나 아이콘과 같은 요소가 자연스럽게 확대되거나 이동하는 효과를 만드는 데 사용됩니다. 이러한 애니메이션은 사용자에게 두 화면이 서로 연결되어 있다는 시각적 단서를 제공하여 앱 탐색 경험을 향상시킵니다.
Hero 애니메이션의 작동 원리
Hero 애니메이션의 핵심 아이디어는 간단합니다:
- 시작 화면과 목적지 화면 모두에 동일한 위젯(예: 이미지)이 존재합니다.
- 두 위젯 모두
Hero
위젯으로 감싸고 동일한tag
를 부여합니다. - 화면 전환이 시작되면 Flutter는 해당
tag
를 가진 두 Hero 위젯을 인식하고, 첫 번째 위치에서 두 번째 위치로 위젯을 애니메이션합니다.
기본 구현 예시
다음은 목록 화면에서 상세 화면으로 이미지를 Hero 애니메이션으로 전환하는 기본 예시입니다:
// 목록 화면
class ListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Hero 애니메이션 예제')),
body: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.0,
),
itemCount: 10,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailScreen(index: index),
),
);
},
child: Card(
child: Column(
children: [
// Hero 위젯으로 이미지 감싸기
Hero(
tag: 'imageHero$index', // 고유한 태그
child: Image.network(
'https://picsum.photos/id/${index + 50}/200/200',
width: 100,
height: 100,
),
),
SizedBox(height: 8),
Text('아이템 $index'),
],
),
),
);
},
),
);
}
}
// 상세 화면
class DetailScreen extends StatelessWidget {
final int index;
DetailScreen({required this.index});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('상세 화면')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 같은 태그를 가진 Hero 위젯
Hero(
tag: 'imageHero$index',
child: Image.network(
'https://picsum.photos/id/${index + 50}/400/400',
width: 300,
height: 300,
),
),
SizedBox(height: 16),
Text(
'아이템 $index 상세 정보',
style: TextStyle(fontSize: 24),
),
Text('여기에 추가 정보를 표시합니다.'),
],
),
),
);
}
}
Hero 애니메이션 고급 기능
1. 플레이스홀더 이미지 및 로딩 상태 처리
네트워크 이미지를 사용할 때는 로딩 시간을 고려해야 합니다:
Hero(
tag: 'imageHero$index',
child: Image.network(
'https://example.com/image.jpg',
width: 300,
height: 300,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: null,
),
);
},
),
)
2. 크기와 위치 변환
Hero 애니메이션 중에는 크기와 위치뿐만 아니라 다른 속성들도 변환됩니다:
// 첫 번째 화면
Hero(
tag: 'imageHero',
child: Container(
height: 100,
width: 100,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.star, color: Colors.white),
),
)
// 두 번째 화면
Hero(
tag: 'imageHero',
child: Container(
height: 200,
width: 200,
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(100),
),
child: Icon(Icons.star, color: Colors.white, size: 100),
),
)
3. Hero 애니메이션 커스터마이징
createRectTween
매개변수를 사용하여 애니메이션 경로를 사용자 정의할 수 있습니다:
Hero(
tag: 'customHero',
createRectTween: (begin, end) {
return RectTween(begin: begin, end: end).chain(CurveTween(curve: Curves.bounceInOut));
},
child: Image.asset('assets/logo.png'),
)
4. flightShuttleBuilder 사용하기
flightShuttleBuilder
를 사용하면 애니메이션 중에 표시될 위젯을 사용자 정의할 수 있습니다:
Hero(
tag: 'imageHero',
flightShuttleBuilder: (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
return AnimatedBuilder(
animation: animation,
child: Image.asset('assets/image.jpg'),
builder: (context, child) {
return Transform.rotate(
angle: animation.value * 2.0 * pi,
child: child,
);
},
);
},
child: Image.asset('assets/image.jpg'),
)
5. placeholderBuilder 사용하기
placeholderBuilder
를 사용하면 Hero가 다른 화면으로 "비행" 중일 때 원래 위치에 표시할 위젯을 지정할 수 있습니다:
Hero(
tag: 'imageHero',
placeholderBuilder: (context, heroSize, child) {
return Container(
width: heroSize.width,
height: heroSize.height,
color: Colors.grey.withOpacity(0.5),
);
},
child: Image.asset('assets/image.jpg'),
)
실제 사용 사례: 제품 카탈로그 앱
다음은 제품 카탈로그 앱에서 Hero 애니메이션을 사용하는 실제 예시입니다:
// 제품 모델
class Product {
final int id;
final String name;
final String imageUrl;
final double price;
final String description;
Product({
required this.id,
required this.name,
required this.imageUrl,
required this.price,
required this.description,
});
}
// 더미 데이터
final List<Product> products = List.generate(
20,
(index) => Product(
id: index,
name: '제품 ${index + 1}',
imageUrl: 'https://picsum.photos/id/${index + 100}/400/400',
price: (index + 1) * 10.99,
description: '제품 ${index + 1}에 대한 상세 설명입니다. 이 제품은 고품질이며 다양한 용도로 사용할 수 있습니다.',
),
);
// 제품 목록 화면
class ProductListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('제품 카탈로그')),
body: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
contentPadding: EdgeInsets.all(8),
leading: Hero(
tag: 'productImage${product.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
product.imageUrl,
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
),
title: Text(product.name),
subtitle: Text('₩${product.price.toStringAsFixed(2)}'),
trailing: Icon(Icons.arrow_forward_ios),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductDetailScreen(product: product),
),
);
},
),
);
},
),
);
}
}
// 제품 상세 화면
class ProductDetailScreen extends StatelessWidget {
final Product product;
ProductDetailScreen({required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(product.name)),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Hero 위젯으로 이미지 감싸기
Hero(
tag: 'productImage${product.id}',
child: Image.network(
product.imageUrl,
width: double.infinity,
height: 300,
fit: BoxFit.cover,
),
),
Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
product.name,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Text(
'₩${product.price.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
SizedBox(height: 16),
Text(
'제품 설명',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(
product.description,
style: TextStyle(fontSize: 16),
),
SizedBox(height: 24),
ElevatedButton(
onPressed: () {
// 장바구니에 추가하는 로직
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} 장바구니에 추가됨')),
);
},
style: ElevatedButton.styleFrom(
minimumSize: Size(double.infinity, 50),
),
child: Text('장바구니에 추가'),
),
],
),
),
],
),
),
);
}
}
여러 Hero 애니메이션 동시 사용
한 화면에서 여러 요소에 Hero 애니메이션을 적용할 수도 있습니다:
// 목록 화면의 카드
Card(
child: Column(
children: [
// 이미지에 Hero 적용
Hero(
tag: 'image-$index',
child: Image.network('https://example.com/image$index.jpg'),
),
// 제목에도 Hero 적용
Hero(
tag: 'title-$index',
child: Material(
color: Colors.transparent,
child: Text(
'제목 $index',
style: TextStyle(fontSize: 16),
),
),
),
// 가격에도 Hero 적용
Hero(
tag: 'price-$index',
child: Material(
color: Colors.transparent,
child: Text(
'₩${(index * 10).toStringAsFixed(2)}',
style: TextStyle(color: Colors.green),
),
),
),
],
),
)
// 상세 화면에서 매칭되는 Hero 위젯들
Column(
children: [
Hero(
tag: 'image-$index',
child: Image.network('https://example.com/image$index.jpg', width: 300),
),
Hero(
tag: 'title-$index',
child: Material(
color: Colors.transparent,
child: Text(
'제목 $index',
style: TextStyle(fontSize: 24),
),
),
),
Hero(
tag: 'price-$index',
child: Material(
color: Colors.transparent,
child: Text(
'₩${(index * 10).toStringAsFixed(2)}',
style: TextStyle(fontSize: 20, color: Colors.green),
),
),
),
],
)
주의사항 및 모범 사례
고유한 태그 사용하기: 각 Hero 위젯 쌍은 고유한 태그를 가져야 합니다. 동일한 화면에 동일한 태그를 가진 여러 Hero가 있으면 예상치 못한 동작이 발생할 수 있습니다.
Material 위젯 사용하기: 텍스트와 같은 비-Material 위젯을 Hero로 감쌀 때는
Material
위젯으로 한 번 더 감싸는 것이 좋습니다:Hero( tag: 'textHero', child: Material( color: Colors.transparent, child: Text('Hero 텍스트'), ), )
간결하게 유지하기: Hero 애니메이션은 화면 전환 중에 발생하므로 Hero 내부의 위젯은 가능한 한 가볍게 유지하세요.
네비게이션 전환: 특정 네비게이션 전환(예: iOS 스타일 슬라이드)은 Hero 애니메이션과 잘 어울리지 않을 수 있습니다. PageRouteBuilder를 사용하여 사용자 정의 전환을 만들 수 있습니다.
페이드 전환 사용하기: Hero 애니메이션은 일반적으로 페이드 전환과 잘 어울립니다:
Navigator.push( context, PageRouteBuilder( transitionDuration: Duration(milliseconds: 700), pageBuilder: (_, __, ___) => DetailScreen(), transitionsBuilder: (_, animation, __, child) { return FadeTransition( opacity: animation, child: child, ); }, ), );
결론
Hero 애니메이션은 Flutter에서 화면 간의 시각적 연속성을 제공하는 강력한 도구입니다. 목록에서 상세 화면으로의 전환, 갤러리에서 전체 화면 이미지로의 전환 등 다양한 상황에서 사용할 수 있습니다. 태그 시스템은 단순하지만 강력하며, 여러 고급 사용자 정의 옵션을 통해 원하는 모든 효과를 만들 수 있습니다.
Hero 애니메이션을 올바르게 사용하면 앱의 사용자 경험을 크게 향상시키고 더 직관적이고 흥미로운 인터페이스를 만들 수 있습니다. UI 요소가 갑자기 나타나거나 사라지는 것이 아니라, 부드럽게 전환되는 경험을 통해 사용자는 앱에서 더 자연스럽게 탐색할 수 있습니다.