【Flutter】SnackBarをスクラッチで実装する

## はじめに
きっかけは→ [CupertinoAppではSnackBarは表示できない](https://qiita.com/sekitaka_1214/items/4486363db8da18bf7b54) の記事です。 CupertinoAppを使っているアプリでSnackBarを表示させたかったので、簡単なSnackBarを自分で実装してみました。
ライブラリを使った爆速開発もいいけど、たまには車輪の再発明もいいものさ。

出来上がりはこんなかんじ。
![my_snackbar.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18273/dac0c87c-46a9-f67d-5a63-d4ca6d9fe615.gif)


## ライブラリ使わなかった理由
以下の2つを考慮し今回はライブラリを使わない選択をしました。
* ちょっとした便利機能のためだけのライブラリってFlutter自体のバージョンアップについてくるか心配
* 今回のケースではライブラリを導入しても、必要なコードはその一部だけの見込みだった

## 仕様
主な仕様は以下の通りです。
* SnackBarの高さは固定
* OKボタン固定でOKボタンタップすると閉じる
* ライブラリにするつもりは無いので、カスタマイズ性は考慮しない

## コード
### SnackBarを表示する
呼び出し方は簡単で以下の1行を記述するだけ。
```dart
showMySnackBar(context: context, message: message);
```

### SnackBarの実装
SnackBarのコード全体は以下の通りです。 いくつか重要なポイントを箇条書きしておきます。

* SnackBarはOverlayEnryを使うことで、他のウィジェットより上のレイヤーに表示しています。 これによって既存の画面のウィジェット構造を変更すること無くSnackBarを表示することができます
* アニメーションや表示状態をSnackBarのウィジェットと切り離すため、`_MySnackBarController`というクラスを作成しています
* キーボードやセーフエリアの有無を考慮して位置を調整しています
* 画面移動時に削除されるようにMySnackBarNavigationObserverで表示中のSnackBarすべてを削除するようにしています

```dart:my_snackbar.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

// SnackBarを表示する
showMySnackBar(
    {required BuildContext context,
    required String message,
    VoidCallback? onOkTapped}) {
  final controller = _MySnackBarController(
      context: context, message: message, onOkTapped: onOkTapped)
    ..show();
  _snackBarControllers.add(controller);
}

// すべて表示中のSnackBarを削除する
_dismissAllMySnackBar() {
  _snackBarControllers.forEach((element) {
    if (!element.isDismissed) {
      element._dismiss();
    }
  });

  print(
      "snackBarControllers count:${_snackBarControllers.length} @before clean");
  _snackBarControllers.removeWhere((element) => element.isDismissed);
  print(
      "snackBarControllers a count:${_snackBarControllers.length} @after clean");
}

// 表示中のSnackBarController
final List<_MySnackBarController> _snackBarControllers = [];


// SnackBarのアニメーションや表示状態を管理する
class _MySnackBarController with WidgetsBindingObserver {
  late AnimationController controller;
  late OverlayState overlay;
  OverlayEntry? overlayEntry;
  String message;
  VoidCallback? onOkTapped;
  bool isDismissCalled = false;
  bool isDismissed = false;
  BuildContext context;

  // https://github.com/surfstudio/flutter-bottom-inset-observer/blob/main/lib/src/bottom_inset_observer.dart
  @override
  void didChangeMetrics() {
    final window = WidgetsBinding.instance.window;
    final inset = window.viewInsets.bottom / window.devicePixelRatio;
    print("didChangeMetrics:$inset");
    // キーボードの表示非表示を検知したら閉じる
    _dismiss();
  }

  _MySnackBarController(
      {required this.context,
      required this.message,
      VoidCallback? onOkTapped}) {
    WidgetsBinding.instance.addObserver(this);

    final rootOverlay = Overlay.of(context);
    overlay = rootOverlay!;
    controller = AnimationController(
        vsync: rootOverlay!, duration: const Duration(milliseconds: 200))
      ..addStatusListener((status) {
        print("animation status changed:${status}");
        if (status == AnimationStatus.dismissed) {
          _onDismissFinished();
        }
      });
    this.onOkTapped = () {
      _dismiss();
      if (onOkTapped != null) {
        onOkTapped();
      }
    };
  }

  _dismiss() {
    if (!isDismissCalled) {
      controller.reverse();
      WidgetsBinding.instance.removeObserver(this);
    }
    isDismissCalled = true;
  }

  _onDismissFinished() {
    print("Remove overlay");
    overlayEntry?.remove();
    overlayEntry = null;
    isDismissed = true;
  }

  show() {
    final width = MediaQuery.of(context).size.width;
    final bodyHeight = 60;
    final safeAreaHeight = MediaQuery.of(context).padding.bottom;
    final bottomViewInsets = MediaQuery.of(context).viewInsets.bottom;
    final bodyHeightWithSafeArea =
        bodyHeight + safeAreaHeight + bottomViewInsets;

    final entry = OverlayEntry(
        builder: (context) {
          return Container(
            alignment: Alignment.bottomCenter,
            child: SizedBox(
              height: bodyHeightWithSafeArea,
              width: width,
              child: Stack(
                clipBehavior: Clip.none,
                children: [
                  SlideTransition(
                      position: Tween<Offset>(
                        begin: Offset(0, 1),
                        end: Offset(0, 0),
                      ).animate(controller),
                      child: _MySnackBar(
                        message: message,
                        onOkTapped: onOkTapped,
                      ))
                ],
              ),
            ),
          );
        },
        maintainState: true)
      ..addListener(() async {
        controller.forward();
      });
    overlayEntry = entry;
    overlay.insert(entry);
  }
}

// SnackBar自体のウィジェット
class _MySnackBar extends StatefulWidget {
  String message;
  VoidCallback? onOkTapped;

  _MySnackBar({required this.message, this.onOkTapped});

  @override
  State createState() => _MySnackBarState();
}

class _MySnackBarState extends State<_MySnackBar>
    with TickerProviderStateMixin {
  @override
  void initState() {
    super.initState();
  }

  void _onOkTapped() {
    if (widget.onOkTapped != null) {
      widget.onOkTapped!();
    }
  }

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    final bodyHeight = 60;
    final safeAreaHeight = MediaQuery.of(context).padding.bottom;
    final bodyHeightWithSafeArea = bodyHeight + safeAreaHeight;
    final body = Container(
      width: width,
      height: bodyHeightWithSafeArea,
      color: Colors.amber,
      child: Padding(
        padding: EdgeInsets.only(
            bottom: safeAreaHeight, left: 16, top: 8, right: 16),
        child: Row(
          children: [
            Expanded(
                child: Text(
              widget.message,
              maxLines: 3,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(fontSize: 14, height: 1.2),
            )),
            const SizedBox(
              width: 8,
            ),
            TextButton(
                onPressed: () => _onOkTapped(),
                child: const Text(
                  "OK",
                )),
          ],
        ),
      ),
    );
    return body;
  }
}

// 画面移動時にSnackBarを削除するためのオブザーバー
// CupertinoAppやMaterialAppの生成時に指定する
class MySnackBarNavigationObserver extends NavigatorObserver {
  @override
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
    print("didPush");
    _dismissAllMySnackBar();
  }

  @override
  void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
    print("didPop");
    _dismissAllMySnackBar();
  }
}
```

## おわりに
200行くらいのコードですがSnackBarとして十分機能するものができたなぁという印象です。 複数のSnackBarを連続で表示する時に、いくつも重なって表示するところなど、本家のSnackBarより好みな部分でもあります。
今回開発した内容は以下のリポジトリで公開しています。
https://github.com/sekitaka/flutter_my_snackbar