In a Flutter furniture store, what do you check when a product thumbnail wrapped in Hero silently stops flying into its detail page?
answer
- no pair, no flight
- tag equality, not tag intent
- destination hero on the first frame
- route type and transition duration
- back swipe needs transitionOnUserGestures
basics
~20 sA hero skips its flight silently when no pair exists at transition start: unequal tags, a destination Hero built only after data loads, a non-PageRoute or zero-duration route, HeroMode(enabled: false), or an iOS back swipe without transitionOnUserGestures.
solid answer
~50 sI check the conditions the `HeroController` needs, in order. First, **tag equality**: the grid might tag with a `Product` object and the detail page with a freshly fetched one that lacks value equality - switch both to `'product-${id}'`. Second, **the destination Hero must exist on the first frame** of the transition; if the detail page shows a spinner until its request completes, there is nothing to fly to, so build the Hero immediately from the thumbnail URL already in hand. Third, **the route**: both must be `PageRoute`s, and a push whose route animation is already at 1.0 - a zero-duration transition - is skipped. Fourth, an ancestor **`HeroMode(enabled: false)`** removes heroes from consideration. Fifth, on an iOS back swipe, flights need `transitionOnUserGestures: true` on both heroes. Nested navigators without their own `HeroController` are a separate case.
code
dart · 36 linesimport 'package:flutter/material.dart';
String productHeroTag(String productId) => 'product-$productId';
// Before: the Hero appears only after the request completes, so the
// transition finds no destination hero and silently skips the flight.
//
// FutureBuilder<ProductDetails>(
// future: _details,
// builder: (context, snapshot) => snapshot.hasData
// ? Hero(tag: snapshot.data!, child: Image.network(snapshot.data!.photoUrl))
// : const CircularProgressIndicator(),
// )
// After: the Hero is built on the first frame from data already on hand.
class ProductHeader extends StatelessWidget {
const ProductHeader({
super.key,
required this.productId,
required this.thumbnailUrl,
});
final String productId;
final String thumbnailUrl;
@override
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 4 / 3,
child: Hero(
tag: productHeroTag(productId),
child: Image.network(thumbnailUrl, fit: BoxFit.cover),
),
);
}
}go deeper
Recall that a hero flies only when both screens have a Hero with an equal tag at the moment of navigation.
Explain why pairing is a one-time check at transition start and how tag equality, route type and gesture pops each prevent it.
Diagnose silent failures methodically, restructure detail pages so the hero exists on the first frame, and centralise tag creation so screens cannot drift.
Treat shared-element transitions as a cross-screen contract, with a tag convention and a test that proves the pair exists, rather than a detail each screen reinvents.
## Why hero failures are silent A **hero flight** only starts when the app's `HeroController` finds a **pair** of `Hero` widgets with equal tags on the outgoing and incoming `PageRoute`s at the moment the transition begins. If it finds no pair, there is nothing wrong from the framework's point of view: the thumbnail simply leaves with its route and the detail photo arrives with its route. No assertion, no log line. The only loud hero failures are duplicate tags on one route and a `Hero` nested inside another `Hero`. So diagnosing a silent one means walking the pairing conditions one by one. ## The checklist | Check | Silent failure it catches | Fix | |---|---|---| | Tags compare equal | object tags without `==`, or `product.id` versus `'product-${product.id}'` | one tag helper used by both screens | | Destination Hero exists on frame one | detail page shows a spinner until data loads | build the `Hero` immediately from data already on hand | | Both routes are `PageRoute`s | detail opened in a dialog or bottom sheet | open it with a page route | | The transition has a duration | a no-transition page, or `Duration.zero` | give the route a real transition | | No `HeroMode(enabled: false)` above either hero | a wrapper added to silence another flight | narrow the `HeroMode` or restore `enabled: true` | | Gesture-driven pop | iOS back swipe never flies | `transitionOnUserGestures: true` on both heroes | | Same Navigator | hero inside a nested Navigator's route | give that Navigator its own `HeroController` | ## The two most common causes **1. Tags that look equal but are not.** `Hero.tag` is an `Object`, and pairing uses `==`. A grid that tags with the `Product` instance from its list and a detail page that tags with a `Product` re-fetched from the API produce two different objects; unless `Product` implements value equality, they never match. Strings built from a stable id avoid the whole class of bug: ```dart String productHeroTag(String productId) => 'product-$productId'; ``` **2. The destination Hero appears too late.** The framework documents that for a hero animation to trigger, the `Hero` has to exist on the very first frame of the new page's animation. A detail page written as `FutureBuilder` → spinner → `Hero` has no hero when the transition starts, so the flight is skipped and nothing retries it when the data arrives. Pass what the hero needs - here, the thumbnail URL - into the detail page, build the `Hero` straight away, and load the rest of the page around it. ## The route-level causes - **Route type.** The controller returns early unless both routes are `PageRoute`s. `DialogRoute` and `ModalBottomSheetRoute` are `PopupRoute`s. - **No transition.** On a push, the controller skips the flight if the new route's animation is already complete - which is what a zero-duration transition produces. - **User gestures.** When an iOS back swipe drives the pop, heroes join only if both set `transitionOnUserGestures: true` (default `false`), and the route being revealed must keep its state (`maintainState`, `true` for ordinary page routes). ## Verifying the fix 1. Slow the animation down so the flight is visible, and confirm the shuttle leaves the thumbnail's exact position. 2. Print or assert the tags on both screens during debugging; a mismatch is obvious once both strings are side by side. 3. Add a widget test that pushes the detail route, pumps one frame, and expects a `Hero` with the product's tag to be present in the new route. ## Loud neighbours of the silent failures - Two heroes with the same tag on one route: a `FlutterError` when the transition collects heroes. - A `Hero` inside another `Hero`: an assertion at build time. - A rotated hero: not an error, but the framework warns it fails *in a rather ugly fashion* because heroes and the overlay must be axis-aligned.
- Why does the flight not simply start once the detail data arrives?Hero pairing happens once, when the `HeroController` is told the top route changed. It collects heroes on both routes at that moment and starts flights for the pairs it finds. A `Hero` that appears later on the detail page is never considered for that transition, so the only fix is to have it present on the first frame.
- The flight works on push but not when the user swipes back on iOS. Why?A back swipe is a user-gesture pop, and `Hero.transitionOnUserGestures` defaults to `false`. Heroes join gesture-driven transitions only when both carry `transitionOnUserGestures: true`, and the route being revealed must have `maintainState` set, which ordinary page routes do.
- Is using the Product object itself as the tag always wrong?No. Any object works if both screens use equal instances, which means the same instance or a class with value equality on its identity fields. It fails when the detail page builds or fetches a new instance of a class that uses identity equality. A string from the id avoids the question.
saying these in an interview costs you the question
- A hero that fails to pair always throws an error you can see in the console.
- The flight starts later once the destination Hero finishes loading.
- Two Product objects with the same field values always match as tags.
- transitionOnUserGestures is on by default, so iOS back swipes fly heroes.
- Any route pushed on the Navigator, including dialogs, can host a hero flight.