Flutter

Flutter Hero Animation Unmasked - Part 1/2

 
You all know Flutter's famous Hero animation  where a "Hero" widget "flies" from one page to the next during a navigation transition:

This animation is pretty cool and is surely a great way to triggers that "Wow" effect with only a few lines of code.
It's so simple that it strangely looks like some kind of wizardry, doesn't it?
Have you ever wonder what was actually going on behind the scenes? What were the components at stake and how they all fit in together to produce such magic?
No? Well, let me take you on an adventure into the depth of the Flutter framework to Unmasked this Hero Animation by re-coding this lovely feature. Along the way, we shall learn a few things about Flutter's navigation, widget & elements trees, widgets lifecycle, geometry with Dart, overlays? You're going to love it!

Because this is a fairly long journey, I will split it into 2 parts:

  • Hero Animation Unmasked - Part 1: Finding the Heroes
  • Hero Animation Unmasked - Part 2: Making the Hero Fly

Hero Animation's general mechanism

Before diving into the wicked part, let's have look at the general mechanism of this feature:

1. Given 2 pages (source & destination), which both contain ++code>Hero++/code> widgets with the a ++code>tag++/code> property holding the same value.

Hero Animation - Initial State

2. Copy the widget's content into an Overlay

Hero Animation - Initial State with overlay

3. Animate the overlayed widget from the source to the destination position on the screen

Hero Animation - Overlay Animated

This 1 part "Flutter Hero Animation Unmasked - Part 1" will focus on steps 1 and 2. Step 3. is described in the second part of this article: "Flutter Hero Animation Unmasked - Part 2".

If you want to learn more about the actual Flutter implementation of the Hero Animation, well you know how it goes: "Head on to flutter.dev": https://flutter.dev/docs/development/ui/animations/hero-animations

Example source code

The entire source code of what we are going to do can be found here: hero-animation-unmasked with a step-by-step commits breakdown.

This sample application contains 2 pages:

  • a source page: Hero List rendering the list of ++code>HeroTile++/code> widgets to display heroes retrieved from the ++code>heroes_data.dart++/code> file
  • a destination page: Hero Details displayed upon click on one of the listed heroes and rendering their avatar.

If you wish to code along, you can begin at this commit: d124af0471 as a starting point, where we use the actual ++code>Hero++/code> widget to produce the targeted result.

In the next sections, I will be mentioning each time the commit corresponding to the current step, if you wish to check out the actual source code.

Let's Code!

1. Meet the UnmaskedHero

In both ++code>hero_tile.dart++/code> and ++code>hero_details.dart++/code> we remove the magic Hero widgets (Commit: 803a203) and replace them with our own. Since we are definitely going to see the "true identity" of our Hero widget by implementing it ourselves, let's call it: ++code>UnmaskedHero++/code> and save it at ++code>lib/packages/unmasked_hero/unmasked_hero.dart++/code>: custom ++code>UnmaskedHero++/code> ++code>StatefulWidget++/code> (Commit: 065bd9d):

++pre>++code>/// lib/packages/unmasked_hero/unmasked_hero.dart

import 'package:flutter/material.dart';

class UnmaskedHero extends StatefulWidget {
 final String tag;
 final Widget child;

 UnmaskedHero({required this.tag, required this.child});

 @override
 UnmaskedHeroState createState() => UnmaskedHeroState();
}

class UnmaskedHeroState extends State<UnmaskedHero> {
 @override
 Widget build(BuildContext context) {
   return widget.child;
 }
}++/pre>++/code>

 

++pre>++code>/// hero_tile.dart & hero_details.dart

child: Hero(
  tag: hero.id,
  child: Image.network(
     hero.avatar,
  ),
),++/pre>++/code>

replaced by:

++pre>++code>/// hero_tile.dart & hero_details.dart

child: UnmaskedHero(
  tag: hero.id,
  child: Image.network(
     hero.avatar,
  ),
),++/pre>++/code>

And we now have a regular transition without any widget flying from one page to the other

BooooOOOORING!

2. Listen to the Navigation

2.1. Extends NavigatorObserver to listen to navigation behaviors

Commit: b18b384

In ++code>lib/packagesunmasked_hero++/code> create a ++code>UnmaskedHeroController++/code> extending the ++code>NavigatorObserver++/code> class:

++pre>++code>/// lib/packages/unmasked_hero/unmasked_hero_controller.dart

import 'package:flutter/widgets.dart';

class UnmaskedHeroController extends NavigatorObserver {
  @override
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
     print('Navigating from $previousRoute to $route');
     super.didPush(route, previousRoute);
  }
}++/pre>++/code>

and pass it to the ++code>navigatorObservers++/code> property of the ++code>MaterialApp++/code> in your ++code>main.dart++/code>

++pre>++code>/// main.dart

...
return MaterialApp(
  initialRoute: 'hero_list_page',
  navigatorObservers: [UnmaskedHeroController()], /// <--- Add this line
  routes: {
     'hero_list_page': (context) => HeroListPage(),
     'hero_details_page': (context) => HeroDetailsPage(),
  },
);++/pre>++/code>

++code>++/code>

This allows us to listen to navigation events like ++code>didPush++/code>.

2.2 Check flight validity & ignore if does not have a valid origin & destination

Check Flight validity

Commit: 5426287

++pre>++code>/// lib/packages/unmasked_hero/unmasked_hero_controller.dart

  ...

  /// Checks whether the hero's flight has a valid origin & destination routes
  bool _isFlightValid(PageRoute? fromRoute, PageRoute toRoute) {
     if (fromRoute == null) {
        return false;
     }
     BuildContext? fromRouteContext = fromRoute.subtreeContext;
     BuildContext? toRouteContext = toRoute.subtreeContext;
     if (fromRouteContext == null || toRouteContext == null) {
        return false;
     }
     return true;
  }

  @override
  void didPush(Route<dynamic> toRoute, Route<dynamic>? fromRoute) {
     WidgetsBinding.instance?.addPostFrameCallback((Duration value) {
        /// If the flight is not valid, let's just ignore the case
        if (!_isFlightValid(fromRoute as PageRoute?, toRoute as PageRoute)) {
           return;
        }
     });
     super.didPush(toRoute, fromRoute);
  }++/pre> ++/code>

Here we check for the "validity" of the flight by making sure that the source and destination routes have a non-null ++code>subtreeContext++/code>.
You might wonder what is that ++code>subtreeContext++/code> then?
The Flutter Documentation defines it as "The build context for the subtree containing the primary content of this route". So, it is the context of the widget tree that originated from this route.

In concrete terms, it's the very same instance of ++code>BuildContext++/code> than the one you would access by running ++code>ModalRoute.of(context).subtreeContext++/code> from the ++code>build++/code> method of ++code>HeroDetailsPage++/code> which is the so-called: "primary content" mentioned in the definition.

3. "Heroes, Assemble!"

Heroes Assemble!

3.1 Visit & Invite source & dest. heroes

Commit: ccfba16311c555d6b3947621371158da625bb90e

++pre>++code>/// lib/packages/unmasked_hero/unmasked_hero_controller.dart

  ...
  /// Visit & Invite all heroes of given context to the party
  Map<String, UnmaskedHeroState> _inviteHeroes(BuildContext context) {
     Map<String, UnmaskedHeroState> heroes = {};
     void _visitHero(Element element) {
        if (element.widget is UnmaskedHero) {
           final StatefulElement hero = element as StatefulElement;
           final UnmaskedHero heroWidget = hero.widget as UnmaskedHero;
           final dynamic tag = heroWidget.tag;
           heroes[tag] = hero.state as UnmaskedHeroState;
        } else {
           element.visitChildren(_visitHero);
        }
     }
     context.visitChildElements(_visitHero);
     return heroes;
  }

  ...

  @override
  void didPush(Route<dynamic> toRoute, Route<dynamic>? fromRoute) {
     WidgetsBinding.instance?.addPostFrameCallback((Duration value) {
        /// If the flight is not valid, let's just ignore the case
        if (!_isFlightValid(fromRoute as PageRoute?, toRoute as PageRoute)) {
           return;
        }
        final BuildContext fromContext = fromRoute!.subtreeContext!;
        final BuildContext toContext = toRoute.subtreeContext!;

        Map<String, UnmaskedHeroState> sourceHeroes = _inviteHeroes(fromContext);
        for (UnmaskedHeroState hero in sourceHeroes.values) {
           print("Source Hero invited: tag = ${hero.widget.tag}, type = ${hero.widget.child.runtimeType}");
         }
         Map<String, UnmaskedHeroState> destinationHeroes =
_inviteHeroes(toContext);
         for (UnmaskedHeroState hero in destinationHeroes.values) {
            print("Destination Hero invited: tag = ${hero.widget.tag}, type = ${hero.widget.child.runtimeType}");
          }
     });
     super.didPush(toRoute, fromRoute);
  } ++/pre>++/code>

Let's break down and see what's going on here:

1. with the ++code>_inviteHeroes++/code> method, we recursively browse a given context to "visit" and find all instances of the ++code>UnmaskedHero++/code> widget.
2. in the ++code>didPush++/code> overriden method, we call the ++code>_inviteHeroes++/code> method on both "from" and "to" contexts so that we gather lists of source & destination heroes.
3. finally, we wrap this whole logic inside a callback passed to ++code>WidgetsBinding.instance?.addPostFrameCallback++/code>

There are 2 interesting things, I would like to highlight:

1. the ++code>_visitHero++/code> method shows how to navigate recursively through the Element tree to find some elements which are instances of widgets. If you want to learn more about Flutter's rendering behavior and the difference between Widget, Element & RenderObject, check out this talk on the subject


2. We call the ++code>WidgetsBinding.instance?.addPostFrameCallback++/code>. Because the ++code>didPush++/code> method of an observer is called right after the page is actually pushed into the navigation stack, the context of the destination route has not been mounted yet. Without ++code>addPostFrameCallback++/code>, the context of the ++code>toRoute++/code> page would be null and we would fallback into the case: ++code>isFlightValid(...) == false++/code>. With this callback, we wait for the newly pushed page to be built and ensure that the ++code>toRoute.subtreeContext++/code> is well defined so that we can look for the instances of our Hero elements. If you want to understand more about this method, take a look at this French article: addPostFrameCallback as well as the documentation.

This prints the following statements:

++pre>++code> flutter: Source Hero invited: tag = spiderman, type = Imageflutter: Source Hero invited: tag = ironman, type = Imageflutter: Source Hero invited: tag = starlord, type = Imageflutter: Source Hero invited: tag = captain_america, type = Imageflutter: Source Hero invited: tag = thor, type = Imageflutter: Destination Hero invited: tag = spiderman, type = Image ++/code>++/pre>

The _inviteHeroes finds all UnmaskedHeroes from the source page and 1 single UnmaskedHero from the destination page corresponding to the one we selected.

3.2 Check source/dest. match before flying

Commit: 12be82ad115eaa0571aae43d7b88997e34a32f03

Now that we can invite all Heroes from both the source and the destination pages, we want to focus only on the ones existing on both.
  ...
  ++pre> ++code> Map<String, UnmaskedHero> sourceHeroes = _inviteHeroes(fromContext);
  Map<String, UnmaskedHero> destinationHeroes =
_inviteHeroes(toContext);
  for (UnmaskedHero hero in destinationHeroes.values) { /// browse throught the heroes of the destination page
     if (sourceHeroes[hero.widget.tag] == null) { /// if no hero with a matching tag has been found on the source page, ignore it.
        continue;
     }
     /// Here we can be sure that the current hero has both
     /// a source to fly from
     /// and a destination to fly to
     _visitHero("Start flying with Hero with tag = ${hero.widget.tag}, type = ${hero.widget.child.runtimeType}");
  } ++/pre> ++/code>

4. Display the flying Hero on overlay

Commit: fba28365be2633b48bfca642769ada8f419d786d

Now that we took a hold on our flying Hero, let’s create and call a _displayFlyingHero method to make them appear on screen using Flutter’s Overlay class 

  /// Display Hero on Overlay
  void _displayFlyingHero(UnmaskedHeroState hero) {
     if (navigator == null) {
        print('Cannot fly without my navigator...');
        return;
     }
     final BuildContext navigatorContext = navigator!.context;
     print("Start flying with Hero with tag = ${hero.widget.tag}, type = ${hero.widget.child.runtimeType}");

     OverlayEntry overlayEntry;
     overlayEntry = OverlayEntry(
        builder: (BuildContext context) => Container(
           child: hero.widget.child,
        ),
     );
     Navigator.of(navigatorContext).overlay?.insert(overlayEntry);
  }

  ...

  @override
  void didPush(Route<dynamic> toRoute, Route<dynamic>? fromRoute) {
     WidgetsBinding.instance?.addPostFrameCallback((Duration value) {
        ...

        for (UnmaskedHeroState hero in destinationHeroes.values) {
           if (sourceHeroes[hero.widget.tag] == null) {
              print('No source Hero could be found for destination Hero with tag: ${hero.widget.tag}');
              continue;
           }
           _displayFlyingHero(hero);
        }
     }
  }

Here, we create an OverlayEntry, wrap our hero widget’s child inside and pass it to navigator.overlay.insert to display a widget over the whole content of the app. This is a great feature of Flutter that you can leverage to craft your own modals, floating action sheets, or overlaying “Help” features.

With this, our UnmaskedHero's child is displayed majestically over the rest of the app 🎉 … but it does not fly yet …

To be continued …

That is already a lot to process, so I suggest that we take a break so that you can digest it all.
Let’s take a look at what we’ve done so far:

1. We understood the general mechanism of the Hero Animation

2. We created our own UnmaskedHero widget and its UnmaskedHeroController

3. We hooked ourselves to the didPush Navigation method to react to navigation
4. We browsed the Elements Tree to look for interesting Widget instances
5. We displayed our UnmaskedHero into the screen overlay

I really hope that you enjoyed this 1st Part of Hero Animation Unmasked. Hopefully, this Hero widget should start to appear less magical.
In the next part Hero Animation Unmasked - Part 2, we’ll make our Hero fly from the source page to the destination by playing a little more with elements’ positions and adding the animation.

If you have any questions, if you find any mistakes, inaccuracies, or if you just want to have a chat about Flutter, I’d be very happy to. You can find me on Twitter @Guitoof or on the Flutter Community Slack where I also go by the name Guitoof.

Acknowledgment

I’d like to give a big and sincere shoutout to:

Développeur mobile ?

Rejoins nos équipes