Skip to content

[BUG] Hero animation breaks when source image fails (CachedNetworkImage / errorBuilder) #573

@KiddoV

Description

@KiddoV

Description

When using PhotoViewGallery with PhotoViewHeroAttributes, the Hero transition does not animate correctly if the source image fails to load.

This commonly happens when the source widget uses CachedNetworkImage with an errorWidget.

Even when the same hero tag and ImageProvider are used, the Hero animation jumps or fades instead of animating smoothly.

Code Sample

// ◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤ //
///
class AppImageViewer extends HookWidget {
  final List<AppImageSource> images;
  final int initialIndex;
  ///
  const AppImageViewer({
    super.key,
    required this.images,
    this.initialIndex = 0,
  });
  ////
  @override
  Widget build(BuildContext context) {
    final pageController = usePageController(initialPage: initialIndex);
    final currentIndex = useState(initialIndex);
    ///
    return Scaffold(
      backgroundColor: Colors.black.withValues(alpha: 0.95),
      body: Stack(
        children: [
          /// MAIN IMAGE VIEWER
          GestureDetector(
            onVerticalDragUpdate: (d) {
              // Swipe down to dismiss page
              if (d.delta.dy > 12) Navigator.of(context).pop();
            },
            child: PhotoViewGallery.builder(
              itemCount: images.length,
              pageController: pageController,
              backgroundDecoration: const BoxDecoration(color: Colors.transparent),
              scrollPhysics: const BouncingScrollPhysics(),
              onPageChanged: (i) => currentIndex.value = i,
              builder: (ctx, i) => PhotoViewGalleryPageOptions(
                imageProvider: images[i].provider,
                heroAttributes: PhotoViewHeroAttributes(tag: images[i].heroTag),
                minScale: PhotoViewComputedScale.contained,
                maxScale: PhotoViewComputedScale.covered * 3,
                errorBuilder: (ctx, err, st) => Center(
                  child: LayoutBuilder(
                    builder: (ctx, cons) => Padding(
                      padding: EdgeInsets.all(AppLayout.getMainPagePadding(ctx).horizontal / 2),
                      child: Column(
                        spacing: 10,
                        mainAxisAlignment: .center,
                        children: [
                          FaIcon(FontAwesomeIcons.circleExclamation, size: cons.biggest.width * 0.4, color: Theme.of(context).colorScheme.error.withValues(alpha: 0.5)),
                          Text("Failed to load image, ${err.errMessage}", textAlign: .center, style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Theme.of(context).colorScheme.error)),
                        ],
                      ),
                    ),
                  )
                ),
              ),
            ),
          ),
          /// CLOSE BUTTON
          SafeArea(
            child: Align(
              alignment: Alignment.topRight,
              child: IconButton(
                icon: const FaIcon(FontAwesomeIcons.xmark, color: Colors.white),
                onPressed: () => Navigator.of(context).pop(),
              ),
            ),
          ),
          /// BOTTOM THUMBNAIL CAROUSEL
          if (images.length > 1) Positioned.fill(
            top: null,
            bottom: 20,
            child: Align(
              alignment: .center,
              child: SizedBox(
                height: 50,
                child: ListView.separated(
                  shrinkWrap: true,
                  padding: .zero,
                  scrollDirection: Axis.horizontal,
                  itemCount: images.length,
                  separatorBuilder: (_, __) => const SizedBox(width: 8),
                  itemBuilder: (ctx, idx) {
                    final isActive = idx == currentIndex.value;
                    return GestureDetector(
                      onTap: () => pageController.animateToPage(idx, duration: const Duration(milliseconds: 250), curve: Curves.easeOut),
                      child: AnimatedContainer(
                        clipBehavior: Clip.hardEdge,
                        width: 42,
                        height: 42,
                        duration: const Duration(milliseconds: 200),
                        decoration: BoxDecoration(borderRadius: .circular(6), border: isActive ? .all(color: Colors.grey, width: 1) : null),
                        child: Image(
                          image: images[idx].provider,
                          fit: BoxFit.cover,
                          errorBuilder: (ctx, err, st) => Center(
                            child: LayoutBuilder(
                              builder: (ctx, cons) => FaIcon(FontAwesomeIcons.circleExclamation, size: cons.biggest.width * 0.4, color: Theme.of(context).colorScheme.error.withValues(alpha: 0.5)),
                            ),
                          ),
                        ),
                      )
                    );
                  }
                )
              ),
            ),
          )
        ],
      )
    );
  }
}

// ◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤ //
// Call hero
return GestureDetector(
  onTap: () {
    // Open and push on top of NavBar
    Navigator.of(context, rootNavigator: true).push(
      PageRouteBuilder(
        opaque: false,
        pageBuilder: (ctx, ann1, ann2) => AppImageViewer(
          images: feedback.imageUrls.map((el) => NetworkImageSrc(el)).toList(),
          initialIndex: idx,
        ),
      ),
    );
  },
  child: Container(
    alignment: .center,
    clipBehavior: Clip.hardEdge,
    decoration: BoxDecoration(
      color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.1),
      borderRadius: BorderRadius.circular(8)
    ),
    child: Stack(
      fit: StackFit.expand,
      children: [
        Hero(
          tag: NetworkImageSrc(imgUrl).heroTag,
          child: CachedNetworkImage(
            imageUrl: imgUrl,
            fit: BoxFit.cover,
            errorWidget: (ctx, err, st) {
              return Center(
                child: LayoutBuilder(
                  builder: (ctx, cons) => FaIcon(
                      FontAwesomeIcons.circleExclamation,
                      size: cons.biggest.width * 0.4,
                      color: Theme.of(context).colorScheme.error.withValues(alpha: 0.5),
                    ),
                ),
              );
            },
          ),
        ),
        if (isLast)
          Container(
            color: Colors.black.withValues(alpha: 0.3),
            alignment: Alignment.center,
            child: Text(
              "+$extraCount",
              style: const TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold),
            ),
          )
      ],
    ),
  ),
);

Screenshots

The video below shows that the Hero animation works on normal images, but doesn't work on failed-to-loaded images.

hero_bug.mp4

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions