はじめに
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
- スクロール位置が一番端にいるかどうか(=
extentBefore
orextentAfter
が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
を使ってハンドリングしていたりするので、とても勉強になりました。誤りあればご指摘いただければと思います。
最後までご覧いただきありがとうございました。