🔖

【Flutter】FloatingActionButtonには常にheroTagを指定せよ - FAB消失バグ格闘記

に公開

タイトル通り。以上。

‐‐では味気ないのでもう少し解説する。

筆者について

Flutter歴1ヵ月程度。アプリ開発知識ゼロ。完全な独学手探り。結果バグの沼に沈んだため、せめてもの成果としてここに詳細を記すことにする。

結論

  • heroTagを指定しないと、FloatingActionButton(FAB)が正しく動作しないことがある。
  • 他の記事等では、同一ページ内で複数のFABを使う場合heroTagを指定すると書かれていることが多いが、単一のFABでも問題となる場合がある。
  • 特に、戻るボタンを押したときに、showDialogを使用して「ダイアログで確認してから戻る」動作を実装するときに、heroTagを指定しないと、戻った先でFABが表示されなくなる(今回嵌った問題)。

そもそもheroTagとは

私もよく知らなかったので、Geminiに解説を書かせてみた。最近はなんでもGeminiに教えを乞うている。

Geminiによる`heroTag`の解説

Flutterにおいて、画面遷移時に要素が滑らかに移動するようなアニメーション(Heroアニメーション)を実現するために使用されるのがHeroウィジェットです。そして、このHeroウィジェットが必須とするプロパティの一つがheroTagです。

heroTagの主な役割は、異なる画面(ルート)間において、対応するHeroウィジェットを一意に識別することです。

より具体的には:

対応付けのキー: 画面Aにある特定のHeroウィジェットと、画面Bにある特定のHeroウィジェットを紐付けるための「鍵」となります。
アニメーションの起点・終点: Flutterはこのタグを頼りに、画面AのHeroウィジェット(起点)から画面BのHeroウィジェット(終点)へ、要素が移動するアニメーションを実行します。
最も重要な点は、heroTagの値は、現在のウィジェットツリー内に存在する(つまり、遷移元と遷移先の両方の画面に存在する可能性がある)すべてのHeroウィジェット間で一意である必要があるということです。

もし複数のHeroウィジェットが同じheroTagを持っている場合、Flutterはどの要素をどこへ移動させれば良いかを判断できず、エラーが発生します。

heroTagには、String型やint型など、一意な識別子として機能する任意のオブジェクトを指定できます。例えば、リスト表示されたアイテムの詳細画面への遷移で写真を表示する場合などに、写真のURLやIDなどをタグとして使用することが一般的です。

まとめると、heroTagはHeroアニメーションを正しく機能させるために、対応するHeroウィジェットを一意に識別するための必須のプロパティです。Heroアニメーションを実装する際には、この一意性を厳密に管理することが重要となります。

要するに、Heroアニメーションにおいて、要素を識別するための「アニメーション版のKey」のようなものらしい

ついでなので、なぜ「Hero」という名前なのかについても聞いてみた。

Geminiによる「Hero」の由来解説

はい、なぜ「Hero」という名前なのか、についてですね。

これはFlutter開発者やデザイナーの間でよく話題になる、少し詩的なネーミングです。技術的な機能そのものから直接連想される名前というよりは、その視覚的な効果や役割に基づいています。

Heroウィジェットが実現するアニメーションは、ある要素(例えばリストのサムネイル画像など)が、画面遷移時にまるで主役のように画面を横断し、詳細画面のより大きな画像に変形・移動する、というものです。

この「画面間を主役として滑らかに、そしてダイナミックに移動する要素」の様子が、まるで物語の「ヒーロー」が悪役を倒すために飛び回ったり、重要な場面で颯爽と登場したりする姿になぞらえられたと考えられます。

つまり、Heroアニメーションにおいて最も注目を集め、遷移のストーリーを牽引するその要素こそが、アニメーション自体の「Hero(主役)」である、という発想からこの名前がつけられた可能性が高いです。

技術的な命名というよりは、アニメーションの印象や役割を表現した、少しロマンチックなネーミングと言えるでしょう。

「画面間を主役として滑らかに、そしてダイナミックに移動する要素」だから「Hero(主役)」とのこと。ChatGPTやClaudeにも聞いてみたが、おおむね同じような回答だった。

ChatGPTの説明は少しイメージしやすかった。普通はページ内で完結するが、ページをまたぐのがHeroである。Hero(主役)は画面をまたいでも登場し続ける。だから、画面をまたぐアニメーションはHero。

Geminiによる「Hero」の由来解説(抜粋)

具体的には、通常の画面遷移(例えばリストから詳細ページへ)では、要素は一度消えて、次の画面で新しく表示されるのが普通です。しかしHeroアニメーションを使うと、画面をまたいで同じ要素が連続して"ジャンプ"していくように見えるんです。この動きが、まるで「ヒーロー(Hero)がシーンを超えて一貫して登場し続ける」ような、ダイナミックで目立つものだから、Heroという名前がつけられています。

長々とAIの解説を引用したが、要するにHeroとは画面間をまたぐことのあるアニメーション。その管理に一意なheroTagが必要だということ。

FlotingActionButtonheroTag

ここまで理解すると、複数のFABを使う場合にheroTagを指定する必要があるのはわかりやすい。FloatingActionButtonコンポーネントにはデフォルトのheroTagが使われるが、複数のFABを使うと同じデフォルト値が使われてしまう。

バグ格闘記

しかし、私のアプリのコードでは、単一のFABを使っているにもかかわらず、heroTagを指定しないとエラーが発生した。ただし原因がheroTagの未指定であることがわかるのはかなり後のことになる。

バグの確認

ページAからページBにpushで遷移する。ページBのAppBarに「戻る」ボタンがあり、これを押すと確認ダイアログを表示して、OKならpopしてページB戻る。

ページAとページBは双方FABを持つ。

この状態で、ページBからダイアログを承認してページAに戻ると、FABが消失する。

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

どうして???

ダイアログを表示して戻る部分のコードは下記の通り。特に特殊なことはしていない。

form_app_bar.dart

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),
    ),
  );
}

初期調査

状況は一切わからないが、FABが表示されないことはわかる。致命的すぎて見なかったことにもできない。

色々と調べた結果、以下のことが分かった

  • FABのWidgetは存在している。表示されないだけ(Inspectorで確認)。
  • await showDialog(...)のあと、popの前にawait Future(Duration(milliseconds: 1000))を入れるとFABが表示される。
    • 1000msなら表示されるが、100msではバグが復活する。
  • showDialogを挟まずにすぐにpopするとFABが表示される。
    この時点で立てた仮説は、「ダイアログが閉じるアニメーションが完全に終了しないとFABが表示されない」というもの。とはいえなんで???

FloatingActionButton以外で検証

この問題が、ScaffoldfloatingActionButtonに起因するのか、FloatingActionButtonコンポーネントに起因するのかを調べるため、floatingActionButtonIconButtonに置き換えてみた。

すると、IconButtonは問題なく表示される。

よって問題はFloatingActionButtonコンポーネントにあると考えられる。

解決

この時点ではFloatingActionButtonコンポーネントの使い方の問題であると考えられるが、その他一切不明。

delayを入れたり、ダイアログ表示をやめると問題が発生しなくなることから、アニメーション起因だろうと考えていた。

一切わからないまま、FloatingActionButtonのドキュメントやコードを眺めてheroTagをというものがあるのを見つけ、指定してみたところ、問題が解決した。

原因

ダイアログを挟んでいる場合、二つのページのFABが同時に存在する状態が発生する。ダイアログのアニメーションが完全に完了していないので、前のページの何かしらの情報が残ってしまっている。

ダイアログが表示されることで、戻る先のページと、ダイアログが表示されているページが、一時的に共存する状態が発生している。

この時、heroTagが指定されていないFABはデフォルト値を使用するため、同じheroTagを持つFABが複数存在することになる。これが原因でFABが正しく表示されなくなってしまった。

その他

もちろん、デバッグ中にGeminiにコードをそのまま渡して質問したり、DeepReserachも使用したが、全く解決には寄与しなかった。showDialogawait等の非同期処理が潜在的な原因かもしれないなど、かなり的外れな回答が返ってきた。

まとめ

AIが賢くても、最終的にデバッグに必要なのは根性と諦めない心。

Discussion