Hero 애니메이션과 페이지 전환 — CustomPageRoute

화면 전환 시 특정 위젯이 자연스럽게 이동하는 Hero 애니메이션은 사용자 경험을 크게 향상시킵니다. 인스타그램의 사진 확대, 쇼핑몰의 상품 상세 진입 등에서 자주 볼 수 있는 패턴입니다.


Hero 애니메이션 기본

같은 tag를 가진 Hero 위젯이 두 화면에 있으면, 화면 전환 시 자동으로 이동 애니메이션이 적용됩니다.

DART
// 목록 화면
class ProductListScreen extends StatelessWidget {
  const ProductListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
      ),
      itemCount: products.length,
      itemBuilder: (context, index) {
        final product = products[index];
        return GestureDetector(
          onTap: () {
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => ProductDetailScreen(product: product),
              ),
            );
          },
          child: Hero(
            // 고유한 tag가 핵심!
            tag: 'product-image-${product.id}',
            child: Image.network(
              product.imageUrl,
              fit: BoxFit.cover,
            ),
          ),
        );
      },
    );
  }
}

// 상세 화면
class ProductDetailScreen extends StatelessWidget {
  final Product product;
  const ProductDetailScreen({super.key, required this.product});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          // 같은 tag의 Hero 위젯
          Hero(
            tag: 'product-image-${product.id}',
            child: Image.network(
              product.imageUrl,
              width: double.infinity,
              height: 300,
              fit: BoxFit.cover,
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              product.name,
              style: const TextStyle(fontSize: 24),
            ),
          ),
        ],
      ),
    );
  }
}

Hero의 주의사항

  • tag는 화면 내에서 고유 해야 합니다 (중복 tag는 에러)
  • Hero 위젯은 같은 타입의 자식을 가져야 자연스럽습니다
  • 텍스트 Hero는 Material 위젯으로 감싸면 전환 시 깜빡임을 방지할 수 있습니다
DART
// 텍스트 Hero
Hero(
  tag: 'product-title-${product.id}',
  child: Material(
    color: Colors.transparent,
    child: Text(
      product.name,
      style: const TextStyle(fontSize: 24),
    ),
  ),
)

CustomPageRoute — 커스텀 전환 효과

페이드 전환

DART
class FadePageRoute<T> extends PageRouteBuilder<T> {
  final Widget page;

  FadePageRoute({required this.page})
      : super(
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            return FadeTransition(
              opacity: animation,
              child: child,
            );
          },
          transitionDuration: const Duration(milliseconds: 300),
        );
}

// 사용
Navigator.push(
  context,
  FadePageRoute(page: const DetailScreen()),
);

슬라이드 전환

DART
class SlidePageRoute<T> extends PageRouteBuilder<T> {
  final Widget page;

  SlidePageRoute({required this.page})
      : super(
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            // 오른쪽에서 슬라이드
            final offsetAnimation = Tween<Offset>(
              begin: const Offset(1.0, 0.0),
              end: Offset.zero,
            ).animate(CurvedAnimation(
              parent: animation,
              curve: Curves.easeInOut,
            ));

            return SlideTransition(
              position: offsetAnimation,
              child: child,
            );
          },
        );
}

스케일 + 페이드 조합

DART
class ScaleFadePageRoute<T> extends PageRouteBuilder<T> {
  final Widget page;

  ScaleFadePageRoute({required this.page})
      : super(
          pageBuilder: (context, animation, secondaryAnimation) => page,
          transitionsBuilder: (context, animation, secondaryAnimation, child) {
            final curved = CurvedAnimation(
              parent: animation,
              curve: Curves.easeOutCubic,
            );

            return FadeTransition(
              opacity: curved,
              child: ScaleTransition(
                scale: Tween(begin: 0.8, end: 1.0).animate(curved),
                child: child,
              ),
            );
          },
          transitionDuration: const Duration(milliseconds: 400),
        );
}

아래에서 위로 슬라이드 (바텀시트 스타일)

DART
class BottomSlideRoute<T> extends PageRouteBuilder<T> {
  final Widget page;

  BottomSlideRoute({required this.page})
      : super(
          opaque: false,
          barrierColor: Colors.black54,
          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.easeOut,
              )),
              child: child,
            );
          },
        );
}

GoRouter에서 커스텀 전환

DART
GoRoute(
  path: '/detail/:id',
  pageBuilder: (context, state) {
    return CustomTransitionPage(
      key: state.pageKey,
      child: DetailScreen(id: state.pathParameters['id']!),
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        return FadeTransition(
          opacity: CurvedAnimation(
            parent: animation,
            curve: Curves.easeInOut,
          ),
          child: child,
        );
      },
    );
  },
)

전환 애니메이션 선택 가이드

전환 효과적합한 상황
오른쪽 슬라이드일반적인 화면 이동 (기본)
페이드같은 레벨의 화면 전환
스케일모달, 팝업 느낌
아래에서 위로바텀시트, 모달 화면
Hero요소 간 연결감 강조

정리

  • Hero 애니메이션은 같은 tag만 지정하면 자동으로 동작합니다
  • tag는 화면 내에서 고유해야 하며, 보통 ID를 포함시킵니다
  • PageRouteBuilder로 페이드, 슬라이드, 스케일 등 커스텀 전환을 구현합니다
  • 전환 효과는 UX 맥락에 맞게 선택하세요
  • GoRouter에서는 CustomTransitionPage를 사용합니다
댓글 로딩 중...