66
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Flutter #2Advent Calendar 2020

Day 18

【Flutter】NotificationListenerとScrollNotificationを用いてScrollableなWidgetのイベントを取得する

Last updated at Posted at 2020-06-23

はじめに

NotificationListenerとは、ラップした子Widgetのサイズ変化などウィジェットの様々なイベントを購読して取得することができるウィジェットです。その中でもScrollNotificationというイベントは、ListViewSingleChildScrollViewなどのScrollableなウィジェットの状態変化を取得することができます。今回はこのScrollableなウィジェットに対して、NotificationListenerでラップし、ScrollNotificationイベントを受け取った際に、いつ、どのようなイベントが取得可能であるかについて解説していきたいと思います。

NotificationListenerについて

NotificationListenerには子ウィジェットchildとコールバック関数となるonNotificationのプロパティがあり、イベントをSubscribeしたいウィジェットをchildとし、onNotificationプロパティにコールバック関数をセットすることで、イベントをSubscribeすることができます。
取得したいNotificationの種類は、Notificationクラスを継承している型をNotificationListenerのジェネリクスとして定義することによって、指定できます。

Flutter公式ドキュメント | Notification class

Notificationクラスを継承しているクラスは以下のようなものがあります。

  • LayoutChangedNotification
  • KeepAliveNotification
  • OverscrollIndicatorNotification
  • ScrollNotification <- 今回取り上げるもの
notification_listener_sample.dart

Widget build(BuildContext context) {
  return NotificationListener<HogeNotification>(
     child: SomethingWidget(...),
     onNotification: (notification) {
       // childのイベントに応じてNotificationが取得可能
       return false;
     }
  );
}

また、onNotificationはboolを返す必要があるのですが、これは先祖のウィジェットにこのイベントを伝搬させるか否かを表します。それぞれ以下に該当します。

  • true
    • 先祖のウィジェットにイベントを伝えない。
  • false
    • 先祖のウィジェットにイベントを伝える。

これについては、NotificationListenerを二重でラップして、親のウィジェットにイベントが伝搬するかどうかを確認するとわかります。正直ネストさせることはほとんどないと思うので、基本falseを返しても良いのかなと思いました。

notification_listener_sample.dart
Widget build(BuildContext context) {
  return NotificationListener<HogeNotification>(
     child: NotificationListener<HogeNotification>(
        child: SomethingWidget(...),
        onNotification: (notification) {
          // return true => 親のNotificationListenerにイベントを伝搬させない
          // return false => 親のNotificationListenerにイベントを伝搬させる
          return (true または false);
        },
     ),
     onNotification: (notification) {
        // 子ウィジェットのonNotificationでtrueが返された場合は、ここではイベントが取得できない
        // 子ウィジェットのonNotificationでfalseが返された場合は、ここでもイベントが取得できる 
        return (true または false);
     },
  );
}

ScrollNotification

上述したようにNotificationListenerNotificationクラスを継承したクラスの型をジェネリクスで指定することでイベントを取得します。ScrollNotificationクラスは、ViewportNotificationMixin というNotificationクラスを継承したmixinを継承(実装?)しているので、NotificationListenerのジェネリクスに指定可能です。このScrollNotificationクラスをNotificationListenerに指定すると、子ウィジェットの様々なスクロールイベントを取得することができるようになります。

notification_listener_sample.dart

Widget build(BuildContext context) {
  return NotificationListener<ScrollNotification>(
     child: SingleChildScrollView(...), // スクロール可能なウィジェット
     onNotification: (notification) {
       // スクロールイベントのハンドリング
       return false;
     }
  );
}

ScrollMetrics

ScrollNotificationクラスには、metricsプロパティとしてScrollMetricsというクラスのオブジェクトが取得できます。これは、現在のスクロール全体の状態を反映しているプロパティのイメージです。

ScrollMetricsでどのような値が取れるか詳しくは、別記事にまとめたいと思いますが、ざっくりとこんな感じです。

  • axis
    • Vertical(縦)かHorizontal(横)
  • axisDirection
    • up, down, right, leftのどれか。現在スクロールしている方向ではなく、基準となる方向がどれか(座標の向きのイメージ)
  • extentBefore
    • ウィジェットの初めから現在スクロールしている始点までの大きさ(見えていない手前の部分)
  • extentInside
    • 現在スクロールしている始点から終点までの大きさ(見えている部分)
  • extentAfter
    • 現在スクロールしている終点からウィジェットの終わりまでの大きさ(見えていない後の部分)
  • atEdge
    • スクロール位置が一番端にいるかどうか(=extentBefore or extentAfterが0)
  • maxScrollExtent
    • スクロール可能な最大値
  • minScrollExtent
    • スクロール可能な最小値
  • viewportDimension
    • 画面の大きさ
  • pixels
    • スクロールの位置(画面の始点の端を0とする)

ScrollNotificationの種類

また、ScrollNotificationには複数のNotificationクラスが子クラスとして定義されています。それぞれ紹介します。

UserScrollNotification

ユーザーが自らスクロールした時に取得できます。以下のように定義されています。(一部抜粋)

user_scroll_notification.dart
class UserScrollNotification extends ScrollNotification {
  UserScrollNotification({
    @required ScrollMetrics metrics,
    @required BuildContext context,
    this.direction,
  }) : super(metrics: metrics, context: context);
  
  ...
}

direction

ScrollDirectionクラスのオブジェクトが取得可能。
種類は、forward, idle, reverseの3つ。

  • forward
    • スクロールの始点に向かってスクロールした時(ScrollMetricsのAxisDirectionがdownの場合は上方向にスクロールしている時)
  • reverse
    • スクロールの終点に向かってスクロールした時(ScrollMetricsのAxisDirectionがdownの場合は下方向にスクロールしている時)
  • idle
    • ユーザーのスクロールが完了した時

ScrollStartNotification

手動かどうかに関わらずスクロールが始まった際に取得できます。
以下のように定義されています。(一部抜粋)

scroll_start_notification.dart
class ScrollStartNotification extends ScrollNotification {
  ScrollStartNotification({
    @required ScrollMetrics metrics,
    @required BuildContext context,
    this.dragDetails,
  }) : super(metrics: metrics, context: context);

  ...
}

dragDetails

DragStartDetailsというクラスのオブジェクトが取得可能。
ユーザーが手動でスクロール(ドラッグ)した時にデータが入る。自動の場合は、nullになる。

globalPositionlocalPosition

dragDetailsからは2つのオブジェクトがOffsetとして取得できる。基本的にはdx,dy,distanceのプロパティがよく使いそうです。

  • globalPosition
    • dx, dy: グローバルな座標系でのスクロール位置(指の位置)
    • distance: スクロールの始点からの距離
  • localPosition
    • dx, dy: ポインタが当てているレシーバーの中でのローカルな座標系でのスクロール位置(指の位置)
    • distance: スクロールの始点からの距離

ScrollUpdateNotification

手動かどうかに関わらずスクロールが継続されている際に取得できます。基本的にはScrollStartNotificationScrollEndNotificationの間に取得されます。(一部抜粋)

scroll_update_notification.dart
class ScrollUpdateNotification extends ScrollNotification {
  ScrollUpdateNotification({
    @required ScrollMetrics metrics,
    @required BuildContext context,
    this.dragDetails,
    this.scrollDelta,
  }) : super(metrics: metrics, context: context);
  
  ...
}

上記のScrollStartNotificationと同様にdragDetailsが取得できますが、こちらはDragUpdateDetailsのオブジェクトなので、少しプロパティが違います。

dragDetails

基本的には、ScrollStartNotificationのものと同じですが、deltaprimaryDeltaというプロパティが追加されています。primaryDeltaは変化量が負の数のdoubleで細かい値まで取れますが、deltaOffsetオブジェクトなので、(x,y)の形で少数第一位までで、変化量が取れます。

scrollDelta

dragDetailsdeltaと基本的に同じですが、こちらは正の数で取れます。

OverscrollNotification

スクロールの端まで来た際に、それ以上スクロールしようとした時に取得できます。(一部抜粋)

overscroll_notification.dart
class OverscrollNotification extends ScrollNotification {
  OverscrollNotification({
    @required ScrollMetrics metrics,
    @required BuildContext context,
    this.dragDetails,
    @required this.overscroll,
    this.velocity = 0.0,
  }) : assert(overscroll != null),
       assert(overscroll.isFinite),
       assert(overscroll != 0.0),
       assert(velocity != null),
       super(metrics: metrics, context: context);
  ...
}

dragDetails

こちらのdragDetailsDragUpdateDetailsオブジェクトとして取得できます。
基本的にはScrollUpdateNotificationと同様なので、省略します。

overscroll

overscrollプロパティは、どれくらいオーバースクロールしたかを表すdouble値です。
deltaとは違い、現在どれくらいオーバースクロールしているかのスナップショットが取得できるイメージです。

velocity

こちらはoverscrollイベントに達する前にどれくらいのスピードで到達したかを表すdouble値です。
ただ、ずっと指をつけた状態で取得すると0になります。

ScrollEndNotification

手動かどうかに関わらずスクロールが完了した際に取得できます。(一部抜粋)

scroll_end_notification.dart
class ScrollEndNotification extends ScrollNotification {
  ScrollEndNotification({
    @required ScrollMetrics metrics,
    @required BuildContext context,
    this.dragDetails,
  }) : super(metrics: metrics, context: context);
  
  ...
}

dragDetails

こちらのdragDetailsはDragEndDetailsオブジェクトを取得できます。

velocity

overscrollvelocityと同様で、スクロールが止まるまでのスピードがVelocityというオブジェクトで取得できます。
VelocitypixelPerSecondというOffsetオブジェクトのプロパティがあるのでそこからスピードが取得できます。
overscrollと同様、ずっと指をつけた状態で取得すると0になります。
普通に端まで到達しないようにスクロールした際は、取得ができず、overscrollした場合のみ取得できました。

primaryVelocity

overscrollvelocityと同様で、スクロールが止まるまでのスピードがdouble値で取得できます。
overscrollと同様、ずっと指をつけた状態で取得すると0になります。
普通に端まで到達しないようにスクロールした際は、取得ができず、overscrollした場合のみ取得できました。

各シチュエーションごとに呼ばれるScrollNotificationの種類

サンプルアプリを作ってどの挙動の時に何が呼ばれるのかをまとめてみました。
コードはこんな感じで、SingleChildScrollViewCardウィジェットを10枚並べたColumnを定義しています。真ん中に現在のNotificationの状態が表示されています。(ScrollStartNotificationScrollEndNotificationはほんの一瞬なので、目には見えませんが、ちゃんとログで確認済みです)

(06/24追記)
なお、SingleChildScrollViewのScrollPhysicsはAndroidのデフォルト時と同様のClampingScrollPhysicsの際の挙動となっております。iOSのデフォルトのBouncingScrollPhysicsなどでは、少し挙動が違うようなので、また別記事にてまとめようと思います。

リポジトリはこちらです
https://github.com/youmitsu/notification_listener_demo_app

demo.dart
ScrollNotification _notification = null;
final ScrollController _scrollController = ScrollController();

return Scaffold(
      appBar: ...,
      body: Stack(
        children: <Widget>[
          NotificationListener<ScrollNotification>(
            child: SingleChildScrollView(
              controller: _scrollController,
              child: Column(
                children: _buildCards(),
              ),
            ),
            onNotification: (notification) {
              Logger()..d(notification);
              setState(() {
                _notification = notification;
              });
              return false;
            },
          ),
          Center(
            child: DefaultTextStyle(
              style: TextStyle(
                fontSize: 20,
                color: Colors.black,
              ),
              child: Text(
                _notification.runtimeType.toString(),
              ),
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        child: IconButton(
          icon: Icon(Icons.arrow_downward),
          onPressed: () {
            _scrollController.animateTo(200,
                duration: Duration(milliseconds: 300), curve: Curves.easeInOut);
          },
        ),
      ),
    );

動作例1: 手動でスクロールし始めて手動で止める

呼ばれるScrollNotificationの順番とDragDetailsの有無

ScrollStartNotification
(DragDetails: あり)
↓
UserScrollNotification
↓
ScrollUpdateNotification(移動中何度も呼ばれる)
(DragDetails: あり)
↓
ScrollEndNotification
(DragDetails: あり)
↓
UserScrollNotification

動作例2: 手動でスクロールし始めて自動で止める

呼ばれるScrollNotificationの順番とDragDetailsの有無

ここでポイントなのは、ScrollUpdateNotificationは、スクロールがされている途中ですが、のDragDetailは、指が画面についているときは存在し、画面から離れたあとは、nullになる点です。

ScrollStartNotification
(DragDetails: あり)
↓
UserScrollNotification
↓
ScrollUpdateNotification(移動中何度も呼ばれる)
(DragDetails: あり)
↓
ScrollUpdateNotification(移動中何度も呼ばれる)
(DragDetails: null)
↓
ScrollEndNotification
(DragDetails: null)
↓
UserScrollNotification

動作例3: ScrollControllerを用いて、自動でスクロール

呼ばれるScrollNotificationの順番とDragDetailsの有無

ScrollStartNotification
(DragDetails: null)
↓
ScrollUpdateNotification(移動中何度も呼ばれる)
(DragDetails: null)
↓
ScrollEndNotification
(DragDetails: null)

動作例4: 手動でスクロールし始めて端まで行ったあと、オーバースクロールして止める

呼ばれるScrollNotificationの順番とDragDetailsの有無

ScrollStartNotification
(DragDetails: あり)
↓
UserScrollNotification
↓
ScrollUpdateNotification(移動中何度も呼ばれる)
(DragDetails: あり)
↓
OverscrollNotification(移動中何度も呼ばれる)
(DragDetails: あり)
↓
ScrollEndNotification
(DragDetails: あり)
↓
UserScrollNotification

動作例5: 手動でスクロールし始めて端まで行ったあと、オーバースクロールして離さずにスクロールし戻す

呼ばれるScrollNotificationの順番とDragDetailsの有無

ScrollStartNotification
(DragDetails: あり)
↓
UserScrollNotification
↓
ScrollUpdateNotification(移動中何度も呼ばれる)
(DragDetails: あり)
↓
OverscrollNotification(移動中何度も呼ばれる)
(DragDetails: あり)
↓
ScrollUpdateNotification(移動中何度も呼ばれる)
(DragDetails: あり)
↓
ScrollEndNotification
(DragDetails: あり)
↓
UserScrollNotification

動作例6: 手動で思いっきり端までスクロールして手を離し、端まで達したら自動でスクロールが止まる(Overscroll)

呼ばれるScrollNotificationの順番とDragDetailsの有無

ScrollStartNotification
(DragDetails: あり)
↓
UserScrollNotification
↓
ScrollUpdateNotification(移動中何度も呼ばれる)
(DragDetails: あり)
↓
ScrollUpdateNotification
(DragDetails: null)
↓
OverscrollNotification
(DragDetails: null)
↓
ScrollEndNotification
(DragDetails: null)
↓
UserScrollNotification

まとめ

スクロール状況に応じて細かく制御したりするときはこのあたりの理解がかなり大事になってくると思いました。
公式のSmartRefresherの実装などを見てみると、この辺りのNotificationListenerScrollNotificationを使ってハンドリングしていたりするので、とても勉強になりました。誤りあればご指摘いただければと思います。
最後までご覧いただきありがとうございました。

66
27
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
66
27

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?