Some time ago I posted a video on Twitter showing the image transitions in StoryArk and mentioned how they can be hard to get working right in Flutter. In this post I want to dig a bit deeper into the technicals.

The reason creating these so-called “hero” animations for images is tough, is because of two overlapping concepts in how elements are shown on screen: the drawing of pictures inside their containers, and the screen transition animations.

Image fit

In StoryArk we display pictures with different aspect ratios: albums (left) put them inside small squares; highlights (middle) show larger, rectangular thumbnails; and the gallery (right) shows the entire picture in full screen.

Example image
Album (left), Highlight (middle), Gallery (right)

Transition animations

Animations are a great way to give context to actions. StoryArk uses hero animations when transitioning between the thumbnail view and the gallery view by animating the tapped image’s expansion into full screen. This helps keeps the user’s focus on the image and create a connection between those two actions.

It also helps situate the user when coming back to the thumbnail grid since the image contracts back into its resting place.

The problem

Because thumbnail images and full screen ones have different fit parameters, the transition begins with a jarring blink from one into the other.

To illustrate the problem, let’s create a simple application with a typical master-detail setup:

import 'package:flutter/material.dart';

const imageUrl =
    'https://images.unsplash.com/photo-1622393168445-ed318ea0554f?w=1024&q=80';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  Widget build(BuildContext context) => MaterialApp(home: MasterPage());
}

class MasterPage extends StatelessWidget {
  Widget build(BuildContext context) => Scaffold(
        body: Padding(
          padding: const EdgeInsets.all(16),
          child: GestureDetector(
            child: Hero(
              tag: 'hero',
              child: Image.network(
                imageUrl,
                width: 100,
                height: 100,
                fit: BoxFit.cover,
              ),
            ),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (context) => DetailPage()),
            ),
          ),
        ),
      );
}

class DetailPage extends StatelessWidget {
  Widget build(BuildContext context) => Scaffold(
        body: Stack(
          children: [
            Center(
              child: Hero(
                tag: 'hero',
                child: Image.network(imageUrl),
              ),
            ),
            const BackButton(),
          ],
        ),
      );
}

Here’s the transition in slow motion:

So what’s happening here exactly?

A dirty secret of UI design is that a lot of what you see on screen is “faked”. In the specific case of hero transitions, it looks like there’s a single image widget which is being animated from its place in the album grid into its final position filling out the entire screen.

However, it isn’t really possible to remove an existing widget instance from one page and pass it on to the next, so we need to somehow make it look like that’s what’s happening. The solution is to create two widgets that coordinate in order to give that appearance.

And this is where Flutter’s Hero widget comes into play. The framework starts a transition by rendering the target Image widget and places it on top of the original one with its exact dimensions. The underlying image is hidden, and the target is animated towards its final size and position.

The rest of the page elements are loaded in behind our hero widget, and you’re now successfully transitioned into a different context. Invert the process and you get a transition back to the grid.

Let’s try replacing the images with a simple green square:

AspectRatio(
  aspectRatio: 1,
  child: Container(color: Colors.green),
)

The effect breaks when both widgets have different content or properties since the framework doesn’t know how to animate those. Let’s see what happens if we try to animate the green box into a red one:

// Master box
AspectRatio(
  aspectRatio: 1,
  child: Container(color: Colors.green),
)

// Detail box
AspectRatio(
  aspectRatio: 1,
  child: Container(color: Colors.red),
)

Wrapping it up

Knowing the problem, the solution starts to become obvious: the thumbnail and full screen widgets need to use the same fit property.

This is a good time to point out that using a cover fit on an Image widget with the exact same aspect ratio as the picture simply shows the entire image without cropping any of it out. It is also worth noting that an unconstrained Image widget will try to size itself to its picture’s aspect ratio.

So the first trivial solution that might work for some layouts is simply to set both Image fits to cover and see if it works for you.

If the above doesn’t work then it gets a bit more complex, as now you’ll have to inspect the image to extract its aspect ratio.
Once that’s done, you can wrap the target Image inside the appropriately named AspectRatio widget to remove the effects of its parent’s constraints while preserving the in-flight transition animation:

AspectRatio(
  aspectRatio: 1920 / 1280,
  child: Image.asset(
    'assets/image.jpg',
    fit: BoxFit.cover,
  ),
)

A perhaps simpler option is to determine if the image is portrait or landscape and apply either fitHeight or fitWidth, respectively:

Image.asset(
  'assets/image.jpg',
  fit: isLandscape ? BoxFit.fitWidth : BoxFit.fitHeight,
)

In either case this is what you should expect to see:

Final thoughts

I hope this article helps to shed some light on how hero animations work in Flutter, and how to get them to work perfectly.
You can see these transitions and a lot more in our StoryArk app.

If you have any questions let me know on Twitter, I go by @cachapa.