Hero Animation within the same route in Flutter

For a landing page of our FlutterDev.Pro course we got the marketing requirement — to allow page visitors to see and experience the benefits students get by generating strong visual associations.
Also, you may check it on Youtube.
Flutter package to add hero animation to widgets repositioning within the same Route.
Page built with HeroAnimation
So a student could actually see how a course topic on the left animatedly transforms into a skill in his LinkedIn profile.

The more covered topics — the reacher experience.
The idea is approved;)

Looks like a hero animation, but within the same route — nothing suitable on, so let's consider how to implement it.


The key observations from a gif demo:

  1. Text widget flies from one layout position to another.
  2. There are two types of flight: first — from topic to LinkedIn profile, and second from one position in the profile to another to maintain a skill list in the profile vertically centered.
  3. After the first type of flight, a text is changed from topic to skill, but after the 'adjustment' flight text remains the same.
  4. Layout position in Profile is defined by a Column.
  5. A flight is done above the rest page content.
Those observations are important but don't make things much clearer at the moment, moreover trying to think out some of them simultaneously causes more confusion than helps.

Looks like a problem, so how to tackle it?

Let's break things into small subproblems so we could focus on solving smh relatively simple, the first obvious subproblem would be hero flight from one position to another.

Usage of Flutter SDK Hero won't fit, as it animates a hero between different routes, while we have an animation within one route, but its key concepts do look promising:

  1. Flight is done between the positions of two Hero widgets with the same tag.
  2. Flying widget is implemented as an Overlay.
We may conclude that our HeroAnimation implementation should be based on the inflation of the same child widget into two widgets:

  • still widget, which position change is tracked
  • flying overlay widget, that flies between a still widget's previous and new positions.

So let's apply those observations to our HeroAnimation widget, in the example to which we show the cup game, by animating a moving cup from one position to another.

tag: 'tag_cup',
child: Image.asset('assets/im_cup.png' ),
The algorithm has 3 main stages:

Internally HeroAnimation's child is inflated to HeroStill and HeroFly widgets.

  1. Flight Start. Animation triggers when within the same frame HeroAnimation with the same tag is removed from one place in a tree and added to another.
  2. Flight in Progress. During flight HeroFy is visible, HeroStill is hidden on the next, flight destination position.
  3. Flight End. HeroStill becomes visible at the flight destination position, HeroFly is hidden.
Let's have a look in detail at what happens at each stage.

Stage 1:
Picture 1
On the left Cup, the child Image is inflated inside HeroAnimation into yellow HeroStill and green HeroFly.

At first, HeroStill and FlightWidget both are placed in the same position, but before the flight animation starts HeroStill is visible and HeroFly is not.

Cup flight animation is triggered when HeroStill is added to another position within the same frame, and the initial HeroStill is removed from the left.

Both must have the same tag.

    children: _cups
          (e) => HeroAnimation.child(
            key: ValueKey(e),
            tag: e,
            child: Image.asset(
Particularly for example with cups, to trigger animation we just need to reorder _cups list is setState ().

onPressed: () {
  setState(() {
Stage 2:
Picture 2
Once the flight starts yellow old HeroStill is removed from its start position on the left, HeroFly animatedly changes its position to a new position defined by already laid out, but currently hidden, HeroStill.

Stage 3:
Picture 3
Finally, once the fly animation completes, the visibility of HeroStill and HeroFly is changed and vice versa — HeroStill becomes visible and HeroFly — is hidden.

Implementation logic scheme

We may notice from the schemes above that a single HeroAnimation flight is ensured under the hood by 3 widgets: 1 — HeroFly and 2 — HeroStills, 2 HeroStills are needed to detect begin and end flight positions for HeroFly, which are tracked by HeroAnimationController, whose responsibility is to start animation between the difference in those HeroStill positions:

class HeroStillRenderObject extends RenderProxyBox {
  late HeroAnimationController _controller;

  void paint(PaintingContext context, Offset offset) {
    Rect rect = Rect.fromPoints(offset, size.bottomRight(offset));

Also, HeroAnimationController switches the visibility of HeroFly and HeroStill depending on if a flight animation is running:
Implementation scheme
Implementation details

From the scheme above we already saw that HeroAnimation creates HeroFly in initState() and HeroStill is returned by build() of its State<HeroAnimation>.
Specific of HeroAnimation[tag] is that it is inserted into different locations in a widget tree, any number of times, and each time it should fly from an old to a new position. That implies for we dealing with a special HeroAnimation lifecycle:

  • In the beginning, HeroFly is created and added to Overlay just once, for performance optimisation.
  • In the end, HeroFly is removed from Overlay, and HeroAnimationController is disposed of to prevent a memory leak.
To ensure such a lifecycle we need to come up with hero associated scope:

class HeroScope {
  /// starts animation between differences in HeroStill positions
  final HeroAnimationController controller;

  /// associated with [tag] HeroFly lives here
  final OverlayEntry flyOverlay;

  /// count of HeroStill in a tree
  int count;
HeroScore count helps us to understand when the lifecycle begins and ends, as it is changed each time HeroStill [tag] is entered/removed from a widget tree.

And that's why HeroAnimation must be StatefulWidget, as its HeroAnimationState with its initState() and dispose() acts for our HeroScore as a scope controller:

class HeroAnimationState extends State<HeroAnimation> {

/// HeroScope associated with a tag
static final Map<String, HeroScope> _map = <String, HeroScope>{};

void initState() {
  if(!_map.containsKey(widget.tag)) {
    // creates HeroAnimationController and HeroFly just once
    final controller = HeroAnimationController();
    final flyOverlay = HeroFly.insertOverlay(context, controller);

    map.putIfAbsent(widget.tag, () => HeroScope(controller, flyOverlay, 1));
  } else {
  final heroScope = _map[widget.tag];
  // increment count if another HeroAnimation with the same tag was added

void dispose() {
  final heroScope = _map[widget.tag];

 // decrement count if HeroAnimation with the same tag was added

  // dispose controller is no HeroAnimation widgets left 
  if(heroScope.count == 0) {
For encapsulation HeroScope is kept in a static map in HeroAnimationState, this helps scopes to outlive particular HeroAnimation as a static map is not an instance property, but a class.

HeroStill appearance and disposal are controlled by HeroAnimation, as its direct child:

class HeroAnimationState extends State<HeroAnimation> {


  Widget build(BuildContext context) {
    return HeroStill(
        controller: controller,
        child: widget.child
Going back to key observations from a gif demo — this article doesn't cover 3rd point, about flight state, so you can check how it's implemented in a source code on gitHub and a package on

If you have questions❓, will be glad to answer.

Thanks for reading!

Don't miss the latest news. Subscribe to our newsletter!
© 2023 All Right Reserved. KiRN Tech.
Quick and Smooth Mobile App Launch
For Your Startup.
Peremohy Ave 24, Kyiv, Ukraine, 04116