에러 해결방법

[트러블슈팅] Hero 애니메이션 안되는 이유(feat. tag 설정)

개발짜 2024. 12. 31. 16:49

문제 설명

TMDB 에서 영화 데이터를 API 통해 받아와서 인기순, 상영 중인 영화 등을 보여주는 영화 앱을 만들고 있다. 영화 포스터를 누르면 hero 애니메이션이 적용되면서 영화의 자세한 정보를 확인할 수 있는 페이지로 이동하고 싶다. 하지만 오류가 나면서 일반적인 페이지 이동만 되는 상황이다.

 

내가 원하는 기능

 

실제로 되는 기능

 

문제

There are multiple heroes that share the same tag within a subtree. 

서브 트리 내에 동일한 태그를 공유하는 여러 hero 가 있다고 한다. 태그 이름이 중복돼서 오류가 나는 거 같아 코드를 확인했다.

 

처음 작성했던 코드

class MovieImage extends StatelessWidget {
  MovieImage({
    required this.movie,
    required this.height,
    required this.width,
  });

  Movie movie;
  double height;
  double width;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) {
              return DetailPage(movie);
            },
          ),
        );
      },
      child: Hero(
        tag: 'movie-image', // tag 설정
        child: SizedBox(
          height: height,
          width: width,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(10),
            child: Image.network(
              movie.posterPath,
              fit: BoxFit.fill,
            ),
          ),
        ),
      ),
    );
  }
}

 

class DetailPage extends StatelessWidget {

  Movie movie;
  DetailPage(this.movie);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          // 영화 이미지
          Hero(
            tag: 'movie-image', // tag 설정
            child: SizedBox(
              height: 620,
              width: double.infinity,
              child: ClipRRect(
                borderRadius: BorderRadius.circular(10),
                child: Image.network(
                  movie.posterPath,
                  fit: BoxFit.fill,
                ),
              ),
            ),
          ),
          ...
}

 

hero 위젯의 tag 이름을 'movie-image' 로 지정을 했다. 위 태그로 지정할 경우 각 hero 위젯은 동일한 'movie-image' 가 되기 때문에 동일한 태그를 공유한다는 오류가 발생한다. 처음에 헤맸던 이유는 각 hero 위젯이 다른 객체처럼? 분리되어 있다고 생각해서 태그 속성이 공유되지 않을 것이라 생각했기 때문에 오류의 이유를 파악하지 못했다... 모든 hero 위젯은 한 쌍이 고유한 tag 를 가져야만 애니메이션이 동작한다. tag 값을 를 시각적으로 확인하면 아래의 사진과 같이 tagging 이 되어 있다고 볼 수 있다.

 

 

시도했던 해결 방법

tag 를 movie 객체의 id 값으로 지정하면 되지 않을까 싶어 시도해봤다.

class MovieImage extends StatelessWidget {
  MovieImage({
    required this.movie,
    required this.height,
    required this.width,
  });

  Movie movie;
  double height;
  double width;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) {
              return DetailPage(movie);
            },
          ),
        );
      },
      child: Hero(
        tag: movie.id, // tag 설정
        child: SizedBox(
          height: height,
          width: width,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(10),
            child: Image.network(
              movie.posterPath,
              fit: BoxFit.fill,
            ),
          ),
        ),
      ),
    );
  }
}

 

class DetailPage extends StatelessWidget {

  Movie movie;
  DetailPage(this.movie);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          // 영화 이미지
          Hero(
            tag: movie.id, // tag 설정
            child: SizedBox(
              height: 620,
              width: double.infinity,
              child: ClipRRect(
                borderRadius: BorderRadius.circular(10),
                child: Image.network(
                  movie.posterPath,
                  fit: BoxFit.fill,
                ),
              ),
            ),
          ),
          ...
}

 

문제는 동일한 영화가 여러 순위권에 들면 중복된 id 를 tag 값으로 사용하기 때문에 해당 방법도 적절치 않다.

 

해결

tag 속성을 고유한 String 값으로 지정해야하는데, 어떻게 해야할까 를 중점으로 두고 방법을 찾아봤다.

1. hero 에 지정된 tag 값을 List 로 모아서 중복된 값이 있을 경우 #1, #2 ... 같은 문자를 추가하여 중복 데이터를 방지

2. movie.id 값 + 위젯을 만들어지는 시간 을 tag 값으로 사용

하지만 image 위젯을 만들면서 매번 중복 데이터를 체크하는 것은 성능적으로 좋지 않을 거 같아 2번 방법을 채택했다.

 

class MovieImage extends StatelessWidget {
  MovieImage({
    required this.movie,
    required this.height,
    required this.width,
  });

  Movie movie;
  double height;
  double width;

  @override
  Widget build(BuildContext context) {
    DateTime time = DateTime.now();
    // 영화 id + 시간
    String tag = '${movie.id}#$time';
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) {
              // DetailPage 에 tag 값 전달
              return DetailPage(movie, tag);
            },
          ),
        );
      },
      child: Hero(
        tag: tag,
        child: SizedBox(
          height: height,
          width: width,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(10),
            child: Image.network(
              movie.posterPath,
              fit: BoxFit.fill,
            ),
          ),
        ),
      ),
    );
  }
}

 

class DetailPage extends StatelessWidget {
  Movie movie;
  String heroTag;
  DetailPage(this.movie, this.heroTag);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          // 영화 이미지
          Hero(
            // 전달받은 heroTag 로 지정
            tag: heroTag,
            child: SizedBox(
              height: 620,
              width: double.infinity,
              child: ClipRRect(
                borderRadius: BorderRadius.circular(10),
                child: Image.network(
                  movie.posterPath,
                  fit: BoxFit.fill,
                ),
              ),
            ),
          ),

 

또 다른 문제 상황...

영화의 자세한 정보를 가져올 때 시간이 걸리기 때문에 데이터가 null 일 동안 앱 화면이 오류 화면으로 바뀐다. 오류 화면은 치명적인 버그이므로 movieDetail 에 정보가 담기기 전에(movieDetail == null) 데이터 로딩하는 위젯을 보여주는 코드를 추가했다. 이 코드를 추가하면서 hero 애니메이션이 동작하지 않는 이슈가 발생했다. 

 

위젯 구조 시각화

 

 

class DetailPage extends StatelessWidget {
  Movie movie;
  String heroTag;
  DetailPage(this.movie, this.heroTag);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Consumer(builder: (context, ref, child) {
        final movieDetail = ref.watch(detailViewModelProvider(movie.id));
        // movieDetail 정보가 fetch 되기 전에는 CircularProgressIndicator 보여주어 null 값 처리
        return movieDetail == null
            ? const Padding(
                padding: EdgeInsets.only(top: 20.0),
                child: Center(child: CircularProgressIndicator()),
              )
            // hero 애니메이션이 Consumer 위젯 안에 있을 경우 애니메이션 동작이 제대로 안되는 문제가 있어서 분리함
            : ListView(
                children: [
                  // 영화 이미지
                  Hero(
                    tag: heroTag,
                    child: SizedBox(
                      height: 620,
                      width: double.infinity,
                      child: ClipRRect(
                        borderRadius: BorderRadius.circular(10),
                        child: Image.network(
                          movie.posterPath,
                          fit: BoxFit.fill,
                        ),
                      ),
                    ),
                  ),
                  ...
}

 

해결

페이지를 이동하면서 hero 애니메이션이 동작하다가 데이터 null 처리로 다른 위젯을 보여주면서 애니메이션 흐름이 끊겨서 발생한 것으로 보여진다. 따라서 null 처리를 hero 위젯 아래에서 처리하여 애니메이션 흐름이 이어지도록 변경하면 된다.

 

class DetailPage extends StatelessWidget {
  Movie movie;
  String heroTag;
  DetailPage(this.movie, this.heroTag);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          // 영화 이미지
          Hero(
            tag: heroTag,
            child: SizedBox(
              height: 620,
              width: double.infinity,
              child: ClipRRect(
                borderRadius: BorderRadius.circular(10),
                child: Image.network(
                  movie.posterPath,
                  fit: BoxFit.fill,
                ),
              ),
            ),
          ),

          Consumer(
            builder: (context, ref, child) {
              final movieDetail = ref.watch(detailViewModelProvider(movie.id));
              // movieDetail 정보가 fetch 되기 전에는 CircularProgressIndicator 보여주어 null 값 처리
              return movieDetail == null
                  ? const Padding(
                      padding: EdgeInsets.only(top: 20.0),
                      child: Center(child: CircularProgressIndicator()),
                    )
                  // hero 애니메이션이 Consumer 위젯 안에 있을 경우 애니메이션 동작이 제대로 안되는 문제가 있어서 분리함
                  : Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        // 영화 요약 소개
                        DetailSummary(movieDetail),
                        divider(),

                        // 카테고리
                        DetailGenre(movieDetail.genres),
                        divider(),

                        // 영화 내용
                        DetailOverview(movieDetail.overview),
                        divider(),
                        ...
                        
}