【Flutter】通知メッセージ表示としてOverlayクラスを導入する

この記事は約15分で読めます。

はじめに

前回、こちらの記事で、RenderFlex overflowedエラーの対応法について記載した。

今回は恒久対応として、「Overlay クラスを使用して、他のウィジェットの上に新しいウィジェットレイヤーを表示する」をやりたいと思う。

ちなみに、現状はアプリのホーム画面に表示する何らかのメッセージを、画面トップにTextウィジェットとして表示している。

overlay_supportパッケージの導入

overlay_supportという、トーストやアプリ内通知を簡単に作成できるパッケージがあったので、こちらを使用することにする。

overlay_support | Flutter package
provider support for overlay, easy to build toast and internal notification
ShellScript
$ flutter pub add overlay_support
$ flutter pub get

AppWidgetをOverlaySupportでWrapする

アプリのメインで OverlaySupport.global() を使うために、main.dart に次のように記述。

Dart
return OverlaySupport.global(child: MaterialApp());

メッセージ表示クラスを修正

元々、画面トップにTextウィジェットを表示するクラスとして作成していたのがこちら。

メッセージを右の閉じるアイコンで閉じることができ、また、メッセージバーをクリックするとshow dialogが開くというもの。これを、今回のOverlay_supportに置き換える。

Dart
// lib/widgets/top_message_bar.dart
import 'package:flutter/material.dart';
import 'package:overlay_support/overlay_support.dart';

void showCustomMessageBar({
  required BuildContext context,
  required String message,
  required VoidCallback onClose,
  required VoidCallback onTapped,
  Duration? duration,
}) {
  showOverlayNotification(
    (context) {
      return GestureDetector(
        onTap: () {
          onTapped();
          OverlaySupportEntry.of(context)?.dismiss();
        },
        child: Container(
          padding: const EdgeInsets.symmetric(
            vertical: 10,
            horizontal: 16,
          ),
          margin: const EdgeInsets.fromLTRB(16, 60, 16, 0),
          decoration: BoxDecoration(
            color: Colors.blue.shade100,
            borderRadius: BorderRadius.circular(10),
            boxShadow: const [
              BoxShadow(
                color: Colors.black26,
                blurRadius: 10,
                spreadRadius: 1,
                offset: Offset(0, 5),
              ),
            ],
          ),
          child: Row(
            mainAxisSize: MainAxisSize.max,
            children: [
              Expanded(
                child: Text(
                  message,
                  style: const TextStyle(
                    fontSize: 14,
                    color: Colors.black,
                    decoration: TextDecoration.none,
                  ),
                ),
              ),
              IconButton(
                icon: const Icon(Icons.close),
                onPressed: () {
                  OverlaySupportEntry.of(context)?.dismiss();
                  onClose();
                },
              ),
            ],
          ),
        ),
      );
    },
    duration: duration,
  );
}

変更後のコードがこちら。

duration パラメーターをオプショナルとして、呼び出す側で通知ごとに表示期間を設定できるようにする。

OverlaySupportEntry.of(context)?.dismiss();は、表示されているオーバーレイウィジェット(この場合はメッセージバー)を削除するメソッド。これを使用することで、ユーザーが閉じるボタンを押した時やメッセージバー自体をタップした時にオーバーレイをプログラム的に閉じることができる。

Dart
// lib/widgets/top_message_bar.dart
import 'package:flutter/material.dart';
import 'package:overlay_support/overlay_support.dart';

void showCustomMessageBar({
  required BuildContext context,
  required String message,
  required VoidCallback onClose,
  required VoidCallback onTapped,
  Duration? duration, // オプショナルにして、デフォルトはnull(無期限表示)
}) {
  showOverlayNotification((context) {
    return GestureDetector(
      onTap: () {
        onTapped();
        OverlaySupportEntry.of(context)?.dismiss();
      },
      child: Container(
        padding: const EdgeInsets.all(10),
        margin: const EdgeInsets.symmetric(horizontal: 10),
        decoration: BoxDecoration(
          color: Colors.blue.shade100,
          borderRadius: BorderRadius.circular(10),
          boxShadow: [
            BoxShadow(
              color: Colors.black26,
              blurRadius: 10,
              spreadRadius: 1,
              offset: Offset(0, 5),
            ),
          ],
        ),
        child: Row(
          mainAxisSize: MainAxisSize.max,
          children: [
            Expanded(
              child: Text(
                message,
                style: const TextStyle(fontSize: 14),
              ),
            ),
            IconButton(
              icon: const Icon(Icons.close),
              onPressed: () {
                OverlaySupportEntry.of(context)?.dismiss();
                onClose();
              },
            ),
          ],
        ),
      ),
    );
  }, duration: duration); // デフォルトではdurationはnull
}

showCustomMessageBarの呼び出し

_HomeTabState にファームウェアのチェックとメッセージバー表示のロジックを追加する。

durationパラメーターに、Duration.zeroを指定するとメッセージは、ずっと固定表示される。閉じるアイコンをクリックすることで閉じることは可能。

Dart
// lib/screens/home/home_tab.dart
@override
void initState() {
  super.initState();
  _checkFirmwareUpdate();
}

void _checkFirmwareUpdate() async {
  final firmwareUpdate = ref.watch(firmwareUpdateProvider);
  firmwareUpdate.whenData((response) {
    if (response.message == "NEW_FIRMWARE_AVAILABLE") {
      showCustomMessageBar(
        context: context,
        message: "新しいファームウェアバージョン(${response.newFirmwareVersion})がダウンロード可能です。",
        onClose: () {},
        onTapped: () {
          showDialog(
            context: context,
            builder: (BuildContext context) {
              return FirmwareUpdateDialog(
                newVersion: response.newFirmwareVersion ?? "",
                onConfirm: () {
                  final userNotifier = ref.read(userProvider.notifier);
                  userNotifier.updateUser(User(firmwareVersion: response.newFirmwareVersion));
                },
                onCancel: () {},
              );
            },
          );
        },
        duration: Duration.zero;
      );
    }
  });
}

ただし、これをビルドすると、下記のエラーが出る。

ビルド中にはウィジェットツリーの子要素リストがまだ更新中であるため、visitChildElements() メソッドを呼ぶことはできないという意味のエラー

Overlayは、独立したWidgetツリーの上に新しいWidget(例えばダイアログやトースト通知)を動的に追加する機能。ビルドメソッド内でオーバーレイを直接操作すると、ビルドプロセスが完了する前に新しいWidgetをWidgetツリーに挿入しようとするため、フレームワークは「子要素への操作がビルド中に行われている」と判断し、エラーを発生させる。

この問題を解決するためには、オーバーレイを操作するコードをビルドプロセスが完了した後に実行する必要があり、そのためには、WidgetsBinding.instance.addPostFrameCallback で、オーバーレイを操作するコードをwrapする必要がある。

Dart
WidgetsBinding.instance.addPostFrameCallback((_) {
  showOverlay(...);  // Overlayを表示する関数
});

WidgetsBinding.instance.addPostFrameCallback とは?

WidgetsBinding.instance.addPostFrameCallback は、Flutterのフレームワーク内で使用されるメソッドで、画面描画のフレームが完了した後に実行されるコールバック(後続処理)をスケジュールするために使う。このコールバックは、フレームの描画が完了した後に実行されるため、安全にオーバーレイを表示することができる。

https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPostFrameCallback.html

ただし、WidgetsBinding.instance.addPostFrameCallback 内で showCustomMessageBar 関数を呼び出すと、ウィジェットがリビルドされるたびに通知が重複して表示されてしまう。これは addPostFrameCallback がフレームの描画後に毎回実行されるため、画面が更新される度に通知が再度トリガーされてしまうから。

通知を1度だけ表示するようにStateProviderで状態管理

通知が重複表示されないようにするには、通知を表示する条件を管理するフラグを使用し、通知が一度表示された後に再表示されないように制御する必要がある。

また、ユーザーが通知を「×」アイコンで明示的に閉じた後は、画面間を遷移しても通知メッセージを表示しないようにする。

Riverpodの StateProvider を使って、アプリ全体の状態として通知が閉じられたかどうかを管理する。

Dart
// 通知が表示されたかどうかを追跡するためのプロバイダー
final notificationShownProvider = StateProvider<bool>((ref) => false);

class HomeTab extends ConsumerStatefulWidget {
  const HomeTab({super.key});
  @override
  _HomeTabState createState() => _HomeTabState();
}

class _HomeTabState extends ConsumerState<HomeTab> {
  @override
  Widget build(BuildContext context) {
    final firmwareUpdate = ref.watch(firmwareUpdateProvider);
    final notificationShown = ref.watch(notificationShownProvider);

    // ファームウェア更新があり、まだ通知が閉じられておらず、通知もまだ表示されていない場合に通知を表示
    if (firmwareUpdate.asData?.value.message == "NEW_FIRMWARE_AVAILABLE" &&
        !notificationShown) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (!notificationShown) {
          showCustomMessageBar(
            context: context,
            message: "新しいファームウェアバージョン(${firmwareUpdate.asData?.value.newFirmwareVersion})がダウンロード可能です。",
            onClose: () {},
            onTapped: () {
              // ダイアログ表示のロジック
            },
          );
          ref.read(notificationShownProvider.notifier).state = true; // 通知が表示されたことを記録
        }
      });
    }

    return Scaffold(
      // UIコンポーネント
    );
  }
}

動作確認

Widgetのリビルド時にWidgetsBinding.instance.addPostFrameCallbackが再度呼び出されても、通知がすでに表示されている場合やユーザーが通知を閉じた場合には、再表示されなくなった。

また、今回の改修でメッセージ表示をTextウィジェットからOverlayクラスへと移行できたので、元々Overflow errorの原因となっていた、Columnウィジェットのpaddingを元の32pxに戻しても、エラーが表示されなくなった。

Dart
return DebugContainer(
  child: Center(
    child: Padding(
      padding: const EdgeInsets.all(32.0),
      child: Column(

めでたし、めでたし♪

コメント

タイトルとURLをコピーしました