iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔖

[Flutter] Always Specify a heroTag for FloatingActionButton: My Struggle with the Disappearing FAB Bug

に公開

Just as the title says. That's all.

...Actually, that would be a bit too dry, so I'll explain a bit more.

About the author

I have about one month of experience with Flutter. Zero app development knowledge. Completely self-taught and feeling my way through. As a result, I sank into a swamp of bugs, so I decided to record the details here as at least some form of achievement.

Conclusion

  • If you don't specify a heroTag, the FloatingActionButton (FAB) may not work correctly.
  • Other articles often state that you should specify a heroTag when using multiple FABs on the same page, but issues can occur even with a single FAB.
  • In particular, when implementing a "confirm via dialog before returning" behavior using showDialog to "confirm with a dialog and then return," the FAB might disappear on the page you returned to if the heroTag is not specified (this is the problem I encountered this time).

What is heroTag anyway?

I didn't know much about it either, so I had Gemini write an explanation. Lately, I've been asking Gemini for help with everything.

Gemini's explanation of `heroTag`

In Flutter, the Hero widget is used to achieve animations where elements move smoothly during screen transitions (Hero animations). One of the mandatory properties for this Hero widget is the heroTag.

The primary role of heroTag is to uniquely identify corresponding Hero widgets across different screens (routes).

More specifically:

A key for mapping: It serves as a "key" to link a specific Hero widget on Screen A with a specific Hero widget on Screen B.
Start and end points of the animation: Flutter relies on this tag to execute the animation where the element moves from the Hero widget on Screen A (start) to the Hero widget on Screen B (end).
The most important point is that the value of heroTag must be unique among all Hero widgets present in the current widget tree (meaning they might exist on both the source and destination screens).

If multiple Hero widgets have the same heroTag, Flutter cannot determine which element should be moved where, resulting in an error.

Any object that functions as a unique identifier, such as a String or int, can be specified for the heroTag. For example, when displaying a photo during a transition to a detail screen for a listed item, it is common to use the photo's URL or ID as the tag.

In summary, heroTag is a mandatory property for uniquely identifying corresponding Hero widgets to make Hero animations function correctly. When implementing Hero animations, it is crucial to manage this uniqueness strictly.

In short, it seems like an "animation version of a Key" used to identify elements in Hero animations.

Since I was at it, I also asked why it is named "Hero."

Gemini's explanation of the origin of "Hero"

Yes, about why it's named "Hero."

This is a somewhat poetic naming choice that often becomes a topic of conversation among Flutter developers and designers. It's based on its visual effect and role rather than a name directly associated with its technical function.

The animation achieved by the Hero widget is one where an element (such as a thumbnail image in a list) crosses the screen like a protagonist during a transition, transforming and moving into a larger image on the detail screen.

This appearance of an "element moving smoothly and dynamically across screens as the protagonist" is thought to be likened to a "hero" in a story flying around to defeat villains or making a grand entrance in a crucial scene.

In other words, it's highly likely this name was given based on the idea that the element which attracts the most attention and drives the story of the transition is the "Hero (protagonist)" of the animation itself.

Rather than a technical naming, it can be called a slightly romantic naming that expresses the impression and role of the animation.

It's named "Hero (protagonist)" because it's the "element that moves smoothly and dynamically across screens as the main character." I asked ChatGPT and Claude as well, and they gave roughly the same answers.

ChatGPT's explanation was a bit easier to visualize. Normally, things are contained within a page, but a Hero is what crosses pages. The Hero (protagonist) continues to appear even when crossing screens. Therefore, an animation that crosses screens is a Hero.

Gemini's explanation of the origin of "Hero" (Excerpt)

Specifically, in a normal screen transition (e.g., from a list to a detail page), an element typically disappears once and is displayed anew on the next screen. However, using Hero animations makes it look as if the same element is continuously "jumping" across the screens. Because this movement is dynamic and stands out, as if a "hero (Hero) continues to appear consistently across scenes," it is named Hero.

I've quoted long AI explanations, but in short, Hero is an animation that can span across screens. And for its management, a unique heroTag is required.

FloatingActionButton and heroTag

Once you understand this far, it's easy to see why you need to specify a heroTag when using multiple FABs. The FloatingActionButton component uses a default heroTag, but if you use multiple FABs, that same default value will be used for all of them.

Record of My Battle with a Bug

However, in my app's code, an error occurred if I didn't specify the heroTag, even though I was using only a single FAB. It wasn't until much later, though, that I realized the cause was the missing heroTag.

Verifying the Bug

Transition from Page A to Page B using push. The AppBar of Page B has a "Back" button; clicking it displays a confirmation dialog, and if "Discard" is selected, it pops to return to Page A.

Both Page A and Page B have FABs.

In this state, when you approve the dialog from Page B and return to Page A, the FAB disappears.

 +-----+    +-----+                +-----+
 |  A  |    |← B  |    +------+    |  A  |
 |‾‾‾‾‾|    |‾‾‾‾‾| -> |dialog| -> |‾‾‾‾‾|
 |     | -> |     |    +------+    |     |
 |    ■|    |    ■|                |    _<-- Disappeared
 +-----+    +-----+                +-----+

Why???

The code for the part where it displays the dialog and returns is as follows. Nothing particularly unusual.

form_app_bar.dart
@override
Widget build(BuildContext context, WidgetRef ref) {
  Future<bool?> confirmDiscard() async => showDialog<bool>(
    context: context,
    builder:
      (context) => AlertDialog(
        title: const Text('Discard changes?'),
        content: const Text(
          'You have unsaved changes. Do you want to discard them?',
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: const Text('Cancel'),
          ),
          TextButton(
            onPressed: () => Navigator.of(context).pop(true),
            child: const Text('Discard'),
          ),
        ],
      ),
  );

  return AppBar(
    title: title,
    leading: IconButton(
      onPressed:() async {
        if(await confirmDiscard() ??  false) {
          router.pop()
        }
      },
      icon: const Icon(Icons.arrow_back),
    ),
  );
}

Preliminary Investigation

I had no idea what was going on, but I could see that the FAB was missing. It was too critical to ignore.

After some investigation, I found the following:

  • The FAB widget exists. It's just not being displayed (confirmed with the Inspector).
  • If I insert await Future(Duration(milliseconds: 1000)) after await showDialog(...) but before the pop, the FAB appears.
    • It appears with 1000ms, but the bug returns with 100ms.
  • If I pop immediately without showing the dialog, the FAB is displayed correctly.

The hypothesis I formed at this point was that "the FAB won't show up unless the dialog's closing animation is completely finished." But even so, why?

Testing with something other than FloatingActionButton

To investigate whether this issue was caused by the floatingActionButton property of the Scaffold or by the FloatingActionButton component itself, I tried replacing the floatingActionButton with an IconButton.

As it turned out, the IconButton was displayed without any issues.

Therefore, I concluded that the problem lies within the FloatingActionButton component.

Resolution

At this point, I thought it was a problem with how I was using the FloatingActionButton component, but everything else was unknown.

Since the problem disappeared when I added a delay or stopped showing the dialog, I suspected it was related to animations.

While still having no clear idea, I was looking through the documentation and code for FloatingActionButton when I discovered something called heroTag. After I specified it, the problem was resolved.

Cause

When a dialog is involved, a state occurs where FABs from two pages exist simultaneously. Because the dialog's animation hasn't completely finished, some information from the previous page remains.

By displaying the dialog, a situation arises where the page being returned to and the page where the dialog is shown temporarily coexist.

At this time, since FABs without a specified heroTag use the default value, multiple FABs with the same heroTag end up existing. This is what caused the FAB to stop displaying correctly.

Others

Of course, during debugging, I also asked Gemini by passing the code directly and used Deep Research, but they didn't contribute to the resolution at all. I received rather off-target answers, such as suggesting that asynchronous processing like await in showDialog might be the potential cause.

Summary

Even if AI is smart, what's ultimately needed for debugging is grit and a heart that doesn't give up.

Discussion