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

# はじめに

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

# NotificationListenerについて

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

[Flutter公式ドキュメント | Notification class](https://api.flutter.dev/flutter/widgets/Notification-class.html)

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

上述したように`NotificationListener`は`Notification`クラスを継承したクラスの型をジェネリクスで指定することでイベントを取得します。`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になる。

#### `globalPosition`と`localPosition`

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

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

## ScrollUpdateNotification

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

```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`のものと同じですが、`delta`と`primaryDelta`というプロパティが追加されています。`primaryDelta`は変化量が負の数のdoubleで細かい値まで取れますが、`delta`は`Offset`オブジェクトなので、(x,y)の形で少数第一位までで、変化量が取れます。

### `scrollDelta`

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

## 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`

こちらの`dragDetails`は`DragUpdateDetails`オブジェクトとして取得できます。
基本的には`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`

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

#### `primaryVelocity`

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

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

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

（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: 手動でスクロールし始めて手動で止める

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147934/a388e7bb-cf2e-8ab3-0ba6-ede531544179.gif" width=25%>

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147934/7c563226-864f-e8f7-faf1-4b17d4a67013.gif" width=25%>

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

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147934/4d1bc94c-0dbd-31b1-08bd-b0bd520844a1.gif" width=25%>

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147934/865d470c-2033-a61f-f8cf-0ef5fb7e0d42.gif" width=25%>

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147934/059f4b21-f39b-8c18-25dd-5d5575dc14d0.gif" width=25%>

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147934/aa2c37c1-3fd2-f72f-7fa8-3ab2ef3090e6.gif" width=25%>

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

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

# まとめ

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