🦁

FlutterのOverlayって難しい

に公開

FlutterのOverlayをご存知ですか?名前の通り、画面の上に重ねて表示することができる機能です。
Overlay.of(context).insert(OverlayEntry(builder: (context) => (ウィジェット)))で使えます。

import "package:flutter/material.dart";

void main() {
  runApp(MaterialApp(home: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: ElevatedButton(
        child: Text("Show notification"),
        onPressed: () => Overlay.of(context).insert(
          OverlayEntry(
            builder: (context) => Align(
              alignment: Alignment.topLeft,
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: Container(
                  width: double.infinity,
                  decoration: BoxDecoration(
                    color: Theme.of(context).colorScheme.surface,
                    borderRadius: BorderRadius.all(Radius.circular(5)),
                    boxShadow: [
                      BoxShadow(
                        offset: Offset.fromDirection(0),
                        blurRadius: 2,
                        spreadRadius: 0,
                        blurStyle: BlurStyle.normal,
                      ),
                    ],
                  ),
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Column(
                      mainAxisSize: MainAxisSize.min,
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          "The Earth exploded",
                          style: Theme.of(context).textTheme.titleLarge,
                        ),
                        Text(
                          "Please move to Mars ASAP.",
                          style: Theme.of(context).textTheme.bodyMedium,
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

https://dartpad.dev/?null_safety=true&id=45fe880d6207fdddb05457f20cff5b59
通知のように表示されましたね。では、非常に似た以下のコードを実行してみましょう。

import "package:flutter/material.dart";

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: ElevatedButton(
          child: Text("Show notification"),
          onPressed: () => Overlay.of(context).insert(
            OverlayEntry(
              builder: (context) => Align(
                alignment: Alignment.topLeft,
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Container(
                    width: double.infinity,
                    decoration: BoxDecoration(
                      color: Theme.of(context).colorScheme.surface,
                      borderRadius: BorderRadius.all(Radius.circular(5)),
                      boxShadow: [
                        BoxShadow(
                          offset: Offset.fromDirection(0),
                          blurRadius: 2,
                          spreadRadius: 0,
                          blurStyle: BlurStyle.normal,
                        ),
                      ],
                    ),
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(
                            "The Earth exploded",
                            style: Theme.of(context).textTheme.titleLarge,
                          ),
                          Text(
                            "Please move to Mars ASAP.",
                            style: Theme.of(context).textTheme.bodyMedium,
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

https://dartpad.dev/?null_safety=true&id=1f578233f207aacbd5b58f25befcad9d

══╡ EXCEPTION CAUGHT BY GESTURE ╞════════════════════════════════
The following assertion was thrown while handling a gesture:
No Overlay widget found.
Some widgets require an Overlay widget ancestor for correct
operation.
The most common way to add an Overlay to an application is to
include a MaterialApp, CupertinoApp or Navigator widget in the
runApp() call.
The context from which that widget was searching for an overlay
was:
  MyApp

When the exception was thrown, this was the stack

Handler: "onTap"
Recognizer:
  TapGestureRecognizer#b6745
═════════════════════════════════════════════════════════════════

ボタンを押すと、例外が発生してしまいます。これは、(おそらく)Scaffoldcontextが必要なのだと思います。これが少し扱いにくいことがあり、例えばこんなエラーにもなりうります(間違ってたらすみません)。

DartError: Looking up a deactivated widget's ancestor is unsafe.
At this point the state of the widget's element tree is no longer stable.
To safely refer to a widget's ancestor in its dispose() method, save a reference to the ancestor by calling dependOnInheritedWidgetOfExactType() in the widget's didChangeDependencies() method.

うーん、disposeした後のcontextを使っていたからこうなるのだと思います。また、複数のページがあるアプリで使うとそのページ内で完結する場合は良いのですが、例えばフォアグラウンドの通知を受信したらオーバーレイを表示するようにするときには、少し面倒ですね。また、ページを跨いだオーバーレイの場合は大変そうです。少しレアなケースな気がしますが、中規模でこのような使い方をするときに困ってしまいます。
私は、この問題に6時間ぐらい悩みました。その結果思ったことは、Overlay以外を使えるなら、その方が使いやすいことがある、ということです。例えば、今回の場合はSnackBarの方が使いやすいことに気づきました。カスタマイズ性に欠けますが、Scaffoldの子でないcontextを使えます。

import "package:flutter/material.dart";

void main() {
    runApp(MaterialApp(home: MyApp()));
}

class MyApp extends StatelessWidget {
    const MyApp({super.key});

    
    Widget build(BuildContext context) {
        return Scaffold(
            body: Center(
                child: ElevatedButton(
                    child: Text("Show SnackBar"),
                    onPressed: () {
                        ScaffoldMessenger.of(context).showSnackBar(
                            SnackBar(
                                content: Column(
                                    mainAxisSize: MainAxisSize.min,
                                    crossAxisAlignment: CrossAxisAlignment.start,
                                    children: [
                                        Text(
                                            "The Earth exploded",
                                            style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Colors.black),
                                        ),
                                        Text(
                                            "Please move to Mars ASAP.",
                                            style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.black),
                                        ),
                                    ],
                                ),
                                backgroundColor: Theme.of(context).colorScheme.surface,
                                behavior: SnackBarBehavior.floating,
                                shape: RoundedRectangleBorder(
                                    borderRadius: BorderRadius.circular(5),
                                ),
                            ),
                        );
                    },
                ),
            ),
        );
    }
}

結論

SnackBarは最強。

Discussion