💬

任意のWidgetを通知みたいに出せるin_app_notificationがv1.1.0になりました

2022/03/17に公開

これは何?

これは自分が作成したFlutter用packageの紹介もとい宣伝記事です。公開自体は結構前にしていたのですが、そういえば日本語媒体でちゃんと宣伝をしてなかった(Twitterくらい)のと、先日大規模なリファクタリングを伴うアップデートを行ったので、ノリで記事を書こうかなと思った次第です。

どんなパッケージ?

https://pub.dev/packages/in_app_notification

タイトルにも書いてありますが、これは任意のWidgetをまるでプッシュ通知のように画面に登場させることができるpackageです。

READMEにも貼ってあるgifをここにも貼っておきます。

in_app_notification

そしてこれまたREADMEにも書いてありますが、使い方は非常にシンプル。

InAppNotification Widgetをアプリ内に配置して、

 return InAppNotification(
   child: MaterialApp(
     title: 'In-App Notification Demo',
     home: const HomePage(),
   ),
 );

InAppNotification.show() メソッドを呼ぶだけです。

InAppNotification.show(
  child: SomeCoolWidget(),
  context: context,
  onTap: () => print('Notification tapped!'),
);

Widgetの大きさも自由なので[1]、上のgif画像でいう Count:N のテキストの大きさを通知を出すたびに自由に変更しても問題ありません[2]

InAppNotification.show() メソッドでは表示するWidget、必須のBuildContext以外に以下の引数を指定できます(v1.1.0時点)。

  • 通知をタップしたときのコールバック関数 onTap
  • 通知を表示し続ける時間 duration
  • 通知を表示するときのアニメーションカーブ curve
  • 通知を非表示にするときのアニメーションカーブ dismissCurve

https://pub.dev/documentation/in_app_notification/latest/in_app_notification/InAppNotification/show.html

表示した通知は、以下の方法で非表示にすることができます。

  • 通知をタップする(onTapコールバックを指定している場合のみ)
  • 通知を上にスワイプする
  • 通知を左右にスワイプする

v1.1.0までの道のり

packageの機能紹介自体はREADMEで大体できてるので、最初のリリースからここまで何があったかを掘り起こしながら書いていきます。

初回リリース v0.1.0

当初このpackageは弊社CBcloudで開発しているプロダクト(これもまた宣伝)で実装した機能を、可能な限り一般化してpackage化したものになります。

一番最初のバージョンのコードでは、Navigatorで遷移する画面一つ一つを InAppNotification で囲む必要がある、というなんとも言えない微妙なインターフェースでした🤔
https://github.com/cb-cloud/flutter_in_app_notification/blob/v0.1.0/lib/src/in_app_notification.dart#L26

とりあえず動くものができているので、フィードバックを求めるのを兼ねて公開、そしてRedditへの投稿も行いました。

https://www.reddit.com/r/FlutterDev/comments/nuucwl/in_app_notification_show_any_widget_as_a/

嬉しいことにこの投稿は30以上のupvoteを頂き、コメントでアドバイスも頂きました。

Very excited to see this library!

I think it's a missed opportunity to display using overlay entries. > That way there's no requirement to wrap the entire app.

(意訳:overlay entries(OverlayEntryのこと)を使ったほうがいいと思うぜ!)

恥ずかしいことに、当初OverlayEntryの存在をすっかり忘れていた(知ってはいた)ため、見た瞬間に「アッッッ」ってなりました。そういうことってありますよね。

このとき通知を表示する仕組みとしては、配置したInAppNotificationの中にStackが入っており、渡したWidgetをそのStackに乗せて、あとはアニメーション付きで表示/非表示するといった感じでした。
通常のアプリのWidgetの中、Navigatorよりも下位でWidgetの重ね合わせを実現するのがStackなのに対して、OverlayはNavigatorよりも上位でWidgetの重ね合わせを実現する仕組みになります。OverlayEntryもWidgetツリーの中には入りますし内部的にはStackを使っているので大した差はないですが、Overlayの場合はNavigatorの画面スタックに関係なく上からWidgetを表示できるので今回のような用途にはうってつけです(他にはWidgetを長押しして出すツールチップなどがOverlayの作例としてあります)。

普段の業務の合間合間で作っていたpackageではありますが、すぐに直したくなってしまったのでちょっと業務時間の振り方を変えたりして、v0.1.0公開から10日後にv0.3.0を公開しました。

v0.3.0

https://github.com/cb-cloud/flutter_in_app_notification/releases/tag/v0.3.0

v0.3.0では、v0.1.0で指摘されていたようにOverlayEntryを使うようにした他、アプリコードから渡すようにしていた minAlertHeightsafeAreaPadding といったプロパティを内部で取得できるように変更しました。これによってInAppNotificationをより手軽に使えるようになり、ついでに内部的な作りもいい感じになりました。
ここらでv1.0.0にしても良かったんですが、ロードマップ的にはここから横スワイプでの非表示も追加したかったので、それができるまでメジャーバージョンはお預けしてました。

v1.0.0

https://github.com/cb-cloud/flutter_in_app_notification/releases/tag/v1.0.0

予定していた横スワイプでの非表示を実装し、ついにメジャーバージョンを迎えました🎉
横スワイプの実装は「これでいいのか?」という感じでしたが、なんとかなったのでそのまま通した、という感じです。
大まかには、GestureDetectorでスワイプを検知しておき、スワイプが終わったときの指の速度と位置(速度がしきい値を超えているか、あるいは位置がしきい値を超えているか)で通知を非表示にするアニメーションを再生する/通知が元の位置に戻るアニメーションを再生するといった処理をしています。コードで言うとこのあたりになります。

また、地味に重要な改善も同時に行いました。InAppNotificationStatefulWidget を使っており、その状態(OverlayEntryを操作するためのインスタンス保持や、通知自体の位置やAnimationControllerの保持)はそのStateに保持しています。
InAppNotification.show()メソッドを呼んだとき、StatefulWidgetからStateを呼び出す方法が必要ですが、その方法としてBuildContextにあるfindAncestorStateOfTypeメソッドを使っています。
これはInheritedWidgetのようにインスタンスをWidgetツリーから探索するメソッドですが、InheritedWidgetで使うgetElementForInheritedWidgetOfExactTypeO(1)なのに対して findAncestorStateOfTypeO(N)の計算量が必要です。
すなわち、このメソッドはWidgetツリーが深くなればなるほど(今回だと深い階層から呼ぶと)パフォーマンスが悪くなります。
そこで、findAncestorStateOfTypeを使って取得するStateをキャッシュするようにしました。
これにより、毎回O(N)の探索をしていたところを2回目以降はO(1)で済ませることができます。

…とはいえそこまで深いWidgetツリーを持ったアプリにまだ触れたことがないので、観測範囲ではそう困ることもなかったですが…

v1.1.0

https://github.com/cb-cloud/flutter_in_app_notification/releases/tag/v1.1.0

そしてなんやかんやあり、v1.1.0までやってきました。
v1.0.0からの変更点としては、細かいバグ修正のほか以下の変更を行っています。

  • 大きくリファクタリング
    • ジェスチャー起点のアニメーションをInteractAnimationControllerインターフェースで操作できるように定義、VerticalInteractAnimationControllerHorizontalInteractAnimationを用意してロジックをまとめた
    • InAppNotificationStatefulWidgetからStatelessWidgetに変更
      • 状態はInheritedWidgetとして持つように
  • InAppNotification.dismiss()メソッドを追加
    • コードから通知を非表示にする方法がなかったので追加した
  • dismissCurve引数を追加
    • タップ等で非表示にするときのアニメーションカーブを指定できるように

リファクタリングにより通知を操作するときのロジック(GestureDetectorに渡している諸メソッド)の可読性が上がりました。また、StatefulWidgetからの脱却をしたため
、先述したfindAncestorStateOfTypeではなくgetElementForInheritedWidgetOfExactType を使うようしました。
その結果通知の状態を探索するときの計算量がすべてO(1)になり、安定したパフォーマンスを保証できるようになりました。

これから

無事いい感じに使えるようになったInAppNotificationですが、まだいくつかの課題があります。

  • リファクタリングしたとはいえ、状態をmutableに扱っている
    • その点で可読性がイマイチな部分がある
    • 無理にimmutableにする必要もない気はしているが…
  • 通知を表示するとき、初回のみ少しガタつく?
    • StatefulWidgetからの脱却により治ったかと思いきやそうでもなさそう

というわけで、まだまだ改良の余地はありそうなので今後もメンテナンスを続けていきたいと思っています。
個人的には結構便利なpackageになっているんじゃないかなと思うので、よければぜひ使ってみてください👐 なにか気づいた点や改良点があったら、お気軽にissueやPull Request投げていただければと思います。

脚注
  1. 縦横ともに画面の大きさの範囲内という制限はあります。 ↩︎

  2. v1.1.0のexampleには通知の大きさを変更するUIが実装されてます。 ↩︎

Discussion