はじめに
きっかけは→ CupertinoAppではSnackBarは表示できない の記事です。 CupertinoAppを使っているアプリでSnackBarを表示させたかったので、簡単なSnackBarを自分で実装してみました。
ライブラリを使った爆速開発もいいけど、たまには車輪の再発明もいいものさ。
ライブラリ使わなかった理由
以下の2つを考慮し今回はライブラリを使わない選択をしました。
- ちょっとした便利機能のためだけのライブラリってFlutter自体のバージョンアップについてくるか心配
- 今回のケースではライブラリを導入しても、必要なコードはその一部だけの見込みだった
仕様
主な仕様は以下の通りです。
- SnackBarの高さは固定
- OKボタン固定でOKボタンタップすると閉じる
- ライブラリにするつもりは無いので、カスタマイズ性は考慮しない
コード
SnackBarを表示する
呼び出し方は簡単で以下の1行を記述するだけ。
showMySnackBar(context: context, message: message);
SnackBarの実装
SnackBarのコード全体は以下の通りです。 いくつか重要なポイントを箇条書きしておきます。
- SnackBarはOverlayEnryを使うことで、他のウィジェットより上のレイヤーに表示しています。 これによって既存の画面のウィジェット構造を変更すること無くSnackBarを表示することができます
- アニメーションや表示状態をSnackBarのウィジェットと切り離すため、
_MySnackBarController
というクラスを作成しています - キーボードやセーフエリアの有無を考慮して位置を調整しています
- 画面移動時に削除されるようにMySnackBarNavigationObserverで表示中のSnackBarすべてを削除するようにしています
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