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

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.

Open the finished code →