// ◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤◢◤ //
///
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),
),
)
],
),
),
);
The video below shows that the Hero animation works on normal images, but doesn't work on failed-to-loaded images.
Description
When using
PhotoViewGallerywithPhotoViewHeroAttributes, the Hero transition does not animate correctly if the source image fails to load.This commonly happens when the source widget uses
CachedNetworkImagewith anerrorWidget.Even when the same hero tag and
ImageProviderare used, the Hero animation jumps or fades instead of animating smoothly.Code Sample
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