はじめに
NotificationListenerとは、ラップした子Widgetのサイズ変化などウィジェットの様々なイベントを購読して取得することができるウィジェットです。その中でもScrollNotificationというイベントは、ListViewやSingleChildScrollViewなどのScrollableなウィジェットの状態変化を取得することができます。今回はこのScrollableなウィジェットに対して、NotificationListenerでラップし、ScrollNotificationイベントを受け取った際に、いつ、どのようなイベントが取得可能であるかについて解説していきたいと思います。
NotificationListenerについて
NotificationListenerには子ウィジェットchildとコールバック関数となるonNotificationのプロパティがあり、イベントをSubscribeしたいウィジェットをchildとし、onNotificationプロパティにコールバック関数をセットすることで、イベントをSubscribeすることができます。
取得したいNotificationの種類は、Notificationクラスを継承している型をNotificationListenerのジェネリクスとして定義することによって、指定できます。
Flutter公式ドキュメント | Notification class
Notificationクラスを継承しているクラスは以下のようなものがあります。
- LayoutChangedNotification
- KeepAliveNotification
- OverscrollIndicatorNotification
- ScrollNotification <- 今回取り上げるもの
Widget build(BuildContext context) {
return NotificationListener<HogeNotification>(
child: SomethingWidget(...),
onNotification: (notification) {
// childのイベントに応じてNotificationが取得可能
return false;
}
);
}
また、onNotificationはboolを返す必要があるのですが、これは先祖のウィジェットにこのイベントを伝搬させるか否かを表します。それぞれ以下に該当します。
- true
- 先祖のウィジェットにイベントを伝えない。
- false
- 先祖のウィジェットにイベントを伝える。
これについては、NotificationListenerを二重でラップして、親のウィジェットにイベントが伝搬するかどうかを確認するとわかります。正直ネストさせることはほとんどないと思うので、基本falseを返しても良いのかなと思いました。
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に指定すると、子ウィジェットの様々なスクロールイベントを取得することができるようになります。
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
- スクロール位置が一番端にいるかどうか(=
extentBeforeorextentAfterが0)
- スクロール位置が一番端にいるかどうか(=
- maxScrollExtent
- スクロール可能な最大値
- minScrollExtent
- スクロール可能な最小値
- viewportDimension
- 画面の大きさ
- pixels
- スクロールの位置(画面の始点の端を0とする)
ScrollNotificationの種類
また、ScrollNotificationには複数のNotificationクラスが子クラスとして定義されています。それぞれ紹介します。
UserScrollNotification
ユーザーが自らスクロールした時に取得できます。以下のように定義されています。(一部抜粋)
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
手動かどうかに関わらずスクロールが始まった際に取得できます。
以下のように定義されています。(一部抜粋)
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の間に取得されます。(一部抜粋)
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
スクロールの端まで来た際に、それ以上スクロールしようとした時に取得できます。(一部抜粋)
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
手動かどうかに関わらずスクロールが完了した際に取得できます。(一部抜粋)
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
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の実装などを見てみると、この辺りのNotificationListenerとScrollNotificationを使ってハンドリングしていたりするので、とても勉強になりました。誤りあればご指摘いただければと思います。
最後までご覧いただきありがとうございました。