Tutorial · 15 min · intermediate
A story viewer with Hero
The Instagram stories pattern — an avatar that morphs into a fullscreen viewer with Hero, and a drag-down gesture that collapses it back.
What you'll build — running on device.
Shared-element transitions are the moment an app stops feeling like
screens and starts feeling like objects: instead of one page sliding away
and another sliding in, a single element — here, an avatar — travels
between the two. You’ll build the Instagram story-viewer pattern: a named
avatar, a tap that morphs the circle into a fullscreen photo, and a
drag-down that collapses it back — with DartNative’s Hero doing the
morph between two real native view trees.
What you need
- A project from Your first DartNative app
- Two images in your assets — the avatar and the fullscreen photo:
dartnative:
assets:
- assets/avatar.jpg
- assets/hero_demo.jpg
Step 1 — Model the story
A story is just an id, a name, and a timestamp:
class _StoryTile {
final String id;
final String name;
final String timeAgo;
const _StoryTile({
required this.id,
required this.name,
required this.timeAgo,
});
}
The images are two bundled assets — assets/avatar.jpg for the circle
and assets/hero_demo.jpg for the fullscreen photo — so both sides of the
morph decode locally and the viewer never opens onto a spinner. A morph
that lands on a loading spinner isn’t a morph. (If your stories come from
a network, pre-warm the fullscreen image before the push — the avatar tap
is your loading window.)
Step 2 — Tag the source
Hero is DartNative’s shared-element widget. It doesn’t animate anything
by itself — it marks a view, and the tag is the pairing key: when a route
push finds the same tag on both sides, the framework animates the tagged
view from its source rectangle to its destination rectangle. Wrap the
avatar circle:
Hero(
tag: 'story-${tile.id}',
child: Container(
color: Colors.transparent,
width: 96,
height: 96,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF0A84FF), width: 3),
),
padding: const EdgeInsets.all(3),
child: ClipOval(
child: Image.asset(
'assets/avatar.jpg',
width: 86,
height: 86,
fit: BoxFit.cover,
),
),
),
)
The tag includes the story id so every story stays its own morph pair — add more avatars and none of them will grab another’s animation.
Step 3 — Push with no route transition
Tapping the avatar pushes the viewer route — and here is the counterintuitive step:
onTap: () => Navigator.push(
context,
PageRoute(
builder: (_) => _StoryViewerRoute(tile: tile),
// RouteTransition.none keeps the new route fully opaque from
// t=0 so the Hero morph (running INSIDE the new route's view
// tree) is visible the whole way. With .fade the route's own
// alpha animation would hide our morph until late in the
// transition, since the morphing view lives in the fading
// tree.
transition: RouteTransition.none,
duration: const Duration(milliseconds: 250),
),
),
RouteTransition.none because the Hero morph is the transition. The
morphing view lives inside the incoming route’s view tree, so a fade or
slide would run on top of it and hide the morph until the route finishes
animating. With .none the new route is fully opaque from the first
frame, and all the motion you see is the avatar travelling. duration
still matters — it times the morph.
Step 4 — Tag the destination
Inside the viewer, the same tag — fullscreen this time:
Positioned.fill(
child: Hero(
tag: 'story-${widget.tile.id}',
// Inner Stack > Positioned.fill > Image.asset so
// the image fills regardless of how the wrapping
// Hero element lays out its child.
child: Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/hero_demo.jpg',
fit: BoxFit.cover,
),
),
],
),
),
),
Same tag, both routes on screen during the push: the framework pairs the
two Heroes and interpolates position, size, and shape — the circle
un-rounds as it grows into the full screen. The inner
Stack/Positioned.fill makes the image fill the screen no matter how
the Hero element sizes its child.
Step 5 — Drag down to close
Closing shouldn’t need a button (there is one, but it’s the fallback). The viewer accumulates vertical drag and pops past a threshold:
onPanUpdate: (d) {
// Only respond to mostly-vertical, downward drags. Horizontal
// motion is ignored so any sideways gestures inside the viewer
// (future "navigate to next story" pager) can co-exist.
if (d.delta.dy.abs() > d.delta.dx.abs() && d.delta.dy > 0) {
setState(() => _dragY += d.delta.dy);
}
},
onPanEnd: (_) {
if (_dragY > _popThreshold) {
Navigator.pop(context);
} else {
setState(() => _dragY = 0);
}
},
Past the threshold, the pop makes the same Hero run in reverse; under it,
resetting _dragY to zero snaps the photo back into place.
_dragY drives a Transform.translate that shifts the whole screen
— image, top bar, scrims, and hint move as one unit:
child: Transform.translate(
offset: Offset(0, _dragY),
child: Stack(
...
That placement is the point: because the Hero lives inside the
Transform, its capture rect at pop time is taken from the dragged-down
position — the reverse morph starts exactly where your finger left the
photo, then collapses it back into the avatar circle.
Step 6 — Let the photo own the window
A story viewer is a fullscreen photo, so give it the whole window. In the
viewer’s initState, make both system bars transparent with light icons:
SystemChrome.setSystemUIOverlayStyle(playgroundOverlayStyle());
playgroundOverlayStyle() is a small helper from the demo UI kit that
ships with the finished code: it returns a SystemUiOverlayStyle with a
transparent status bar and system nav bar, and icon brightness matched to
the palette. In your own app, pass the equivalent SystemUiOverlayStyle
inline — the transparency is what matters.
Then let the Scaffold bleed behind the bars with
extendBodyBehindAppBar: true + extendBody: true. Two gradient scrims
(a scrim is a soft dark gradient laid over an image to keep text on top of
it readable) fade ~50% black into transparent over 150-px strips at the
top and bottom; each is wrapped in IgnorePointer so it never steals the
drag gesture from the photo underneath.
Why this is native
The morph animates a real native view between two native view trees — position, size, and corner radius interpolated by the platform’s animator, not redrawn per-frame on a canvas. And because the routes are real
UIViewControllers, the reverse morph composes with the actual pop — not a simulated one.
The finished code
In the public repo — dn create ., dn run. The screen is the
playground’s Hero demo carried byte-for-byte (lib/screens/hero_demo.dart,
plus the playground’s shared UI kit in lib/screens/home/demo_ui.dart),
with its two images bundled under assets/ and a thin main.dart that
registers the plugins and launches it: when the playground screen improves,
this tutorial inherits it by copying the file again.