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:

Hero Animation

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>

 

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

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

replaced by:

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

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

 

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

Transition without Hero animation

BooooOOOORING! ?

2. Listen to the Navigation

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>

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>

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>

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.

ModalRoute.subtreeContext

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>

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 t

Développeur Mobile ?

Rejoins nos équipes