Flutter ウィジェットを自由に重ねるOverlayを活用しよう

ウィジェットを自由な位置に表示したい場合、Overlayを使うと便利です。

* `PopupMenuButton`では文字列リストの表示は簡単ですが、デザインに制約があります。
* `Dialog`を使用すればデザインに柔軟性を持たせることができますが、画面の中央に表示されてしまい、任意の位置に配置することはできません。

そんな場合には、Overlay機能を活用しましょう！
Overlayを使うと、ウィジェットを自由な位置に重ねて表示することができます。


↓ Googleのアカウント画面を模したサンプル
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670405/03c8adc1-c26e-17b3-2cf5-947b99fe2bd8.png" width=40%> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670405/4c421c74-d98a-79e2-a677-4bfbc6f30bd5.gif" width=40%>


以下では、具体的なコードの説明を行います。

## Overlayの実装コード
```home.dart
class _MyHomePageState extends State<MyHomePage> {
  final GlobalKey _actionKey = GlobalKey();
  OverlayEntry? _menuOverlayEntry;

  void _openMenu() {
    // _actionKeyが関連付けられたウィジェット(この場合はAppBar)のサイズや座標情報を持ったRenderBoxを取得
    final renderBox = _actionKey.currentContext?.findRenderObject() as RenderBox;
    const menuWidth = 200.0;
    // メニューを表示するためのOverlayEntryを作成
    _menuOverlayEntry = OverlayEntry(builder: (context) {
      return Stack(
        children: [
          // メニュー外をタップした時にもメニューを閉じれるように透明な背景を画面全体に表示
          Positioned.fill(
            child: GestureDetector(
              onTap: _closeMenu,
              child: Container(color: Colors.transparent),
            ),
          ),
          // メニューを表示するWidget
          Positioned(
            // デバイスの幅からメニューの幅と余白を引いた位置に表示
            left: MediaQuery.of(context).size.width - menuWidth - 24,
            // AppBarの高さの半分の位置に表示
            top: renderBox.size.height / 2,
            width: menuWidth,
            child: AccountMenu(
              onClose: _closeMenu,
            ),
          ),
        ],
      );
    });
    // メニューを表示
    Overlay.of(context).insert(_menuOverlayEntry!);
  }

  void _closeMenu() {
    // メニューを閉じる
    _menuOverlayEntry?.remove();
    _menuOverlayEntry = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        key: _actionKey, // _openMenu()内でWidgetサイズを取得するためにkeyを設定
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Sample'),
        actions: [
          IconButton(
            icon: const Icon(Icons.account_circle),
            onPressed: () => _openMenu(),
          ),
        ],
      ),
      body: const SizedBox(),
    );
  }
}
```

* Dialogと同じように、Overlay表示したメニュー以外の部分をタップしたときにも閉じれるように
Stackで透明なContainerを設置している点がポイントです。
* 表示位置は仕様に合わせて調整してください。下記のようにrenderBoxから座標も取得できるのでこれを活用してもいいかもです。
```dart
// グローバル座標を取得。この場合はAppBarの左上の座標。
final appBarTopLeftPosition = renderBox.localToGlobal(Offset.zero);

// グローバル座標を取得。この場合はAppBarの右下の座標。
final appBarBottomRightPosition = renderBox
        .localToGlobal(renderBox.size.bottomRight(Offset.zero));
```


## 全文コード 呼び出し側
```home.dart
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final GlobalKey _actionKey = GlobalKey();
  OverlayEntry? _menuOverlayEntry;

  void _openMenu() {
    // _actionKeyが関連付けられたウィジェット(この場合はAppBar)のサイズや座標情報を持ったRenderBoxを取得
    final renderBox = _actionKey.currentContext?.findRenderObject() as RenderBox;
    const menuWidth = 200.0;
    // メニューを表示するためのOverlayEntryを作成
    _menuOverlayEntry = OverlayEntry(builder: (context) {
      return Stack(
        children: [
          // メニュー外をタップした時にもメニューを閉じれるように透明な背景を画面全体に表示
          Positioned.fill(
            child: GestureDetector(
              onTap: _closeMenu,
              child: Container(color: Colors.transparent),
            ),
          ),
          // メニューを表示するWidget
          Positioned(
            left: MediaQuery.of(context).size.width - menuWidth - 24, // デバイスの幅からメニューの幅と余白を引いた位置に表示
            top: renderBox.size.height / 2, // AppBarの高さの半分の位置に表示
            width: menuWidth,
            child: AccountMenu(
              onClose: _closeMenu,
            ),
          ),
        ],
      );
    });
    // メニューを表示
    Overlay.of(context).insert(_menuOverlayEntry!);
  }

  void _closeMenu() {
    // メニューを閉じる
    _menuOverlayEntry?.remove();
    _menuOverlayEntry = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        key: _actionKey, // _openMenu()内でWidgetサイズを取得するためにkeyを設定
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Sample'),
        actions: [
          IconButton(
            icon: const Icon(Icons.account_circle),
            onPressed: () => _openMenu(),
          ),
        ],
      ),
      body: const SizedBox(),
    );
  }
}
```

## 全文コード Menuクラス

```account_menu.dart
class AccountMenu extends StatelessWidget {
  const AccountMenu({
    Key? key,
    required this.onClose,
  }) : super(key: key);

  final VoidCallback onClose;

  @override
  Widget build(BuildContext context) {
    return Material(
      elevation: 3,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(10),
      ),
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                const CircleAvatar(
                  backgroundColor: Colors.blue,
                  radius: 40,
                  child: Text(
                    'T',
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: 30,
                    ),
                  ),
                ),
                const SizedBox(height: 6),
                const Text(
                  '田中 太郎',
                  style: TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                const SizedBox(height: 2),
                Text(
                  'tanaka@gmail.co.jp',
                  style: TextStyle(
                    fontSize: 14,
                    color: Colors.grey[600],
                  ),
                ),
                const SizedBox(height: 8),
                ElevatedButton(
                  onPressed: () {
                    onClose();
                  },
                  child: const Text('アカウント設定'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

```

## Overlay取り扱い時の注意点
Overlayで表示したウィジェット（A）の上に更に新たなウィジェット（B）を重ねるときは注意が必要です。
（A）が常に最上位のウィジェットであると認識されているので、（B）を表示しても（A）の下に隠れてしまいます。
そのため、(A)を閉じてから（B）を表示するか、（A）の上から更に（B）をOverlay表示するなどの対策が必要になってきます。
他にもっといい方法があればぜひ教えてください！

## 参考

https://api.flutter.dev/flutter/widgets/Overlay-class.html