Hero 애니메이션이란 무엇이며 어떻게 구현하나요?

질문

Flutter에서 Hero 애니메이션은 무엇이며, 어떻게 구현하나요?

답변

Hero 애니메이션은 Flutter에서 화면 간 전환 시 요소가 한 위치에서 다른 위치로 부드럽게 애니메이션되는 패턴입니다. 주로 목록 화면에서 상세 화면으로 이동할 때 이미지나 아이콘과 같은 요소가 자연스럽게 확대되거나 이동하는 효과를 만드는 데 사용됩니다. 이러한 애니메이션은 사용자에게 두 화면이 서로 연결되어 있다는 시각적 단서를 제공하여 앱 탐색 경험을 향상시킵니다.

Hero 애니메이션의 작동 원리

Hero 애니메이션의 핵심 아이디어는 간단합니다:

  1. 시작 화면과 목적지 화면 모두에 동일한 위젯(예: 이미지)이 존재합니다.
  2. 두 위젯 모두 Hero 위젯으로 감싸고 동일한 tag를 부여합니다.
  3. 화면 전환이 시작되면 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),
        ),
      ),
    ),
  ],
)

주의사항 및 모범 사례

  1. 고유한 태그 사용하기: 각 Hero 위젯 쌍은 고유한 태그를 가져야 합니다. 동일한 화면에 동일한 태그를 가진 여러 Hero가 있으면 예상치 못한 동작이 발생할 수 있습니다.

  2. Material 위젯 사용하기: 텍스트와 같은 비-Material 위젯을 Hero로 감쌀 때는 Material 위젯으로 한 번 더 감싸는 것이 좋습니다:

    Hero(
      tag: 'textHero',
      child: Material(
        color: Colors.transparent,
        child: Text('Hero 텍스트'),
      ),
    )
    
  3. 간결하게 유지하기: Hero 애니메이션은 화면 전환 중에 발생하므로 Hero 내부의 위젯은 가능한 한 가볍게 유지하세요.

  4. 네비게이션 전환: 특정 네비게이션 전환(예: iOS 스타일 슬라이드)은 Hero 애니메이션과 잘 어울리지 않을 수 있습니다. PageRouteBuilder를 사용하여 사용자 정의 전환을 만들 수 있습니다.

  5. 페이드 전환 사용하기: Hero 애니메이션은 일반적으로 페이드 전환과 잘 어울립니다:

    Navigator.push(
      context,
      PageRouteBuilder(
        transitionDuration: Duration(milliseconds: 700),
        pageBuilder: (_, __, ___) => DetailScreen(),
        transitionsBuilder: (_, animation, __, child) {
          return FadeTransition(
            opacity: animation,
            child: child,
          );
        },
      ),
    );
    

결론

Hero 애니메이션은 Flutter에서 화면 간의 시각적 연속성을 제공하는 강력한 도구입니다. 목록에서 상세 화면으로의 전환, 갤러리에서 전체 화면 이미지로의 전환 등 다양한 상황에서 사용할 수 있습니다. 태그 시스템은 단순하지만 강력하며, 여러 고급 사용자 정의 옵션을 통해 원하는 모든 효과를 만들 수 있습니다.

Hero 애니메이션을 올바르게 사용하면 앱의 사용자 경험을 크게 향상시키고 더 직관적이고 흥미로운 인터페이스를 만들 수 있습니다. UI 요소가 갑자기 나타나거나 사라지는 것이 아니라, 부드럽게 전환되는 경험을 통해 사용자는 앱에서 더 자연스럽게 탐색할 수 있습니다.

results matching ""

    No results matching ""