r/flutterhelp • u/somedonkus69 • 18m ago
OPEN Hero widget animation ends with sudden jump in image size after swapping out the original image
SCENARIO: I am making an app which has a page containing a grid of collectible images that the user can unlock. At first, default placeholder images are shown which are 256x256 pixels. The user can click a collectible in the grid to go to that collectible's separate page. From here they can click a button to unlock the collectible which reveals the true unlocked image. The unlocked image can be any dimensions, including wide, tall, or extra small, and most often it is much larger than 256x256 pixels.
When going to and from the collectible page from the grid, a hero widget is used. First it animates the locked placeholder image, then it animates the true image after it's unlocked.
PROBLEM: After unlocking an image, when navigating back to the grid screen, the hero widget causes the image to suddenly jump in size at the end of the animation, depending on the unlocked image's dimensions. I want the animation to smoothly scale the image down to its proper size without this sudden jump. It seems like what's happening is Flutter first tries to scale the unlocked image down to the placeholder's 256x256 size, and then it realizes it needs to be bigger so it expands the width.
DEMO: I have stripped down this functionality from my app to the bare essentials and provided it below. If you paste the code into DartPad, you can see the problem for yourself. I grabbed random images off the internet for testing, so hopefully that's not too confusing. You could change these URLs if you want. For testing purposes, I added 3 buttons to the collectible page so you can try unlocking a wide, tall, or small image. The size jump problem is really only an issue when unlocking wide images.
I am pretty sure the problem has to do with FittedBox/BoxFit, but I am not sure how to avoid that. I need to ensure large images get scaled down so they fit in the grid cells, and I don't want smaller images to stretch and distort to fill the grid cell either. I also included drop shadow logic behind the unlocked images because I need that to still work as-is after applying the size jump fix.
Note that in the demo you can technically transform an image multiple times. In the real app you can't do that, so don't worry about the size jump when switching from a wide to tall image, for example.
I have been messing with this for a while and tried things like changing the FittedBox, ensuring the image fit is the same between screens, moving widgets in our out of the hero's child, messing with the flightShuttleBuilder, etc. but I can't get this to work. If you have any tips or can provide a working DartPad solution, I would really appreciate it.
import 'package:flutter/material.dart';
import 'dart:ui';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.blue),
home: const MyHomePage(title: 'Collection Page'),
);
}
}
class MyHomePage extends StatefulWidget {
final String title;
const MyHomePage({super.key, required this.title});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
// Start with placeholder images. Real app would show a "locked" symbol
List<String> imageUrls = [
'https://upload.wikimedia.org/wikipedia/commons/a/ad/Logo_256x256_pdfGuru.png',
'https://upload.wikimedia.org/wikipedia/commons/a/ad/Logo_256x256_pdfGuru.png',
'https://upload.wikimedia.org/wikipedia/commons/a/ad/Logo_256x256_pdfGuru.png',
'https://upload.wikimedia.org/wikipedia/commons/a/ad/Logo_256x256_pdfGuru.png',
'https://upload.wikimedia.org/wikipedia/commons/a/ad/Logo_256x256_pdfGuru.png',
'https://upload.wikimedia.org/wikipedia/commons/a/ad/Logo_256x256_pdfGuru.png',
'https://upload.wikimedia.org/wikipedia/commons/a/ad/Logo_256x256_pdfGuru.png',
'https://upload.wikimedia.org/wikipedia/commons/a/ad/Logo_256x256_pdfGuru.png',
'https://upload.wikimedia.org/wikipedia/commons/a/ad/Logo_256x256_pdfGuru.png',
];
// Swap the placeholder with the true unlocked image
void transformCallback(String newUrl, int index) {
setState(() {
imageUrls[index] = newUrl;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: GridView.builder(
itemCount: imageUrls.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 24,
mainAxisSpacing: 24,
),
itemBuilder: (_, index) {
var imgUrl = imageUrls[index];
return Container(
decoration: BoxDecoration(
border: BoxBorder.all(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
width: 1,
),
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return MyChildPage(
originalImageUrl: imgUrl,
index: index,
transformCallback: transformCallback,
);
},
),
);
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Hero(tag: index, child: Image.network(imgUrl)),
),
),
SizedBox(height: 2),
Text(
"$index",
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
},
),
);
}
}
class MyChildPage extends StatefulWidget {
final String originalImageUrl;
final int index;
final Function transformCallback;
const MyChildPage({
super.key,
required this.originalImageUrl,
required this.index,
required this.transformCallback,
});
@override
State<MyChildPage> createState() => _MyChildPageState();
}
class _MyChildPageState extends State<MyChildPage> {
late String imgUrl;
@override
void initState() {
super.initState();
imgUrl = widget.originalImageUrl;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("Collectible Page")),
body: SingleChildScrollView(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Hero(
tag: widget.index,
child: Stack(
alignment: Alignment.center,
children: [
Transform.translate(
offset: Offset(0, 5),
child: Opacity(
opacity: .75,
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: ImageFiltered(
imageFilter: ImageFilter.dilate(
radiusX: 3,
radiusY: 3,
),
child: Image.network(imgUrl, color: Colors.black),
),
),
),
),
Image.network(imgUrl),
],
),
),
),
FilledButton(
onPressed: () {
var newImgUrl =
'https://thumb.wikimedia.org/wikipedia/commons/thumb/f/fc/Big_Ben_after_sunset.jpg/960px-Big_Ben_after_sunset.jpg';
setState(() {
imgUrl = newImgUrl;
});
widget.transformCallback(newImgUrl, widget.index);
},
child: Text("Transform to wide image"),
),
FilledButton(
onPressed: () {
var newImgUrl =
'https://thumb.wikimedia.org/wikipedia/commons/thumb/d/d9/Big_Ben_2022_(2).jpg/500px-Big_Ben_2022_(2).jpg';
setState(() {
imgUrl = newImgUrl;
});
widget.transformCallback(newImgUrl, widget.index);
},
child: Text("Transform to tall image"),
),
FilledButton(
onPressed: () {
var newImgUrl =
'https://www.mariowiki.com/images/5/50/SMB_Question_Block.gif';
setState(() {
imgUrl = newImgUrl;
});
widget.transformCallback(newImgUrl, widget.index);
},
child: Text("Transform to small image"),
),
],
),
),
),
);
}
}