Flutter 滚动监听

ScrollPosition和ScrollController

我们知道,在使用ListView等可滚动组件时,可以通过给它传进去一个ScrollController来控制滚动行为,那么ScrollController是怎么做到的呢?实际上ScrollController内部会在每一次ScrollController和一个可滚动组件绑定时为其创建一个ScrollPosition对象,并放在一个数组中进行统一管理,真正对滚动起控制作用的其实是ScrollPosition,例如,当我们调用ScrollControlleranimateTo方法的时候,实际上是这样的:

1
2
3
4
5
6
// ScrollController的animateTo方法(伪实现)
Future<void> animateTo(double offset, Duration duration, Curve curve){
for(var _sp in this._positions){
_sp.animateTo(offset, duration: duration, curve: curve)
}
}

没错,当有多个可滚动组件被绑定到同一个ScrollController时,animateTojumpTo方法同理)会遍历当前所有的ScrollPosition对象,并依次调用它们的animateTo方法

ScrollController处理多个绑定时的逻辑

在绑定了多个可滚动组件时,ScrollController的某些属性直接使用会直接报异常,假设_controller绑定了多个可滚动组件:

1
2
debugPrint("${_controller.offset}"); //报异常
debugPrint("${_controller.position.pixels}"); //报异常

为什么报异常其实很好理解,当我们绑定了多个可滚动组件时,每个可滚动组件的offsetposition都不相同,那么你直接访问这两个属性谁知道是代表的哪个可滚动组件的信息呢,所以此处报异常很合理,可以看看报的具体内容:

image-20241118205853196

可以看到,在ScrollController的内部有一个断言: _positions.length == 1,当我们绑定了多个可滚动组件时,这个断言是不成立的,自然就会报异常了

对于多个可滚动组件,我们只能使用ScrollControllerpositions属性(注意比前面多了一个s),这是一个数组,里面存放的就是所有可滚动组件对应的各自的ScrollPosition对象,我们可以通过遍历等方式来获取信息或者批量处理多个可滚动组件。

监听滚动事件

监听滚动事件的办法有很多,我们依次介绍

方法一: 通过ScrollController来监听

我们查看ScrollController的实现就可以知道,它是间接继承自Listenable的,所以我们可以直接使用addListener来进行滚动事件的监听:

1
2
3
_controller.addListener((){
// Your code
})

这种监听方式的特点是,从滚动开始一直到滚动结束这个过程中,你通过addListener添加的回调函数不间断地多次调用(比如你若在里面使用了打印函数,控制台在一瞬间会打印出很多行内容),能让你实现精细化的滚动控制,但如果你在里面执行开销较大的操作时,会影响性能,例如setState导致多个组件被重绘。

方法二: 通过NotificationListener组件来监听

我们先来了解一下什么是NotificationListener。在Flutter中,子组件可以向父组件发送通知,而父组件可以选择监听自己关心的通知,这里的父组件范围是很广的,在子组件到根组件之间的所有组件其实都可以对该子组件发出的通知进行监听。通知的机制有点像Web开发中的事件冒泡,我们在NotificationListener的回调函数中可以通过返回值来决定当前通知是否继续向更上一级的组件进行传播,这让我们获得了很强大的事件管理能力。而我们在此处要监听的通知就是: ScrollNotification

示例如下:

1
2
3
4
5
6
7
8
9
10
NotificationListener<ScrollNotification>(
onNotification: (notification){
// Your code
return false;
},
child: ListView(
controller: _controller
children: []
)
)

我们在onNotification中即可进行事件的处理,注意该函数返回false表示事件应当继续冒泡,返回true则表示事件已经处理完毕,无需再通知更上一级的组件。其实在上述代码中,我们无需让ListView作为NotificationListener的直接子组件,只需要NotificationListenerListView的上层即可。我们可以在onNotification这个回调函数中,通过notification变量获取到滚动信息,包括Viewport的信息:

1
2
3
4
5
6
7
8
9
10
11
NotificationListener<ScrollNotification>(
onNotification: (notification){
debugPrint(notification.metrics.pixels); //当前滚动的像素
debugPrint(notification.metrics.maxScrollExtent); //可滚动的最大高度
return false;
},
child: ListView(
controller: _controller
children: []
)
)

这种监听方法也会在滚动时不间断地调用你提供的回调函数

这种监听方法的好处是,我们可以在ScrollController无法访问的情况下获取到滚动的详细信息,例如我们想要写一个单独的组件来控制子组件的滚动行为的时候,下面要介绍的ScrollBar就是通过这种方式来实现的。

ScrollBar这个组件可以作为可滚动组件的子组件以显示一个滚动条,它内部是通过监听子组件的滚动通知来实现的,基础用法如下:

1
2
3
4
5
6
7
8
ScrollBar(
child: ListView(
itemCount: 10,
itemBuild: (context, index){
return ListTile(title: Text("$index"));
}
),
)

请思考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ScrollBar(
child: NotificationListener(
onNotification: (notification){
// Your code
return true;
},
child: ListView(
itemCount: 10,
itemBuild: (context, index){
return ListTile(title: Text("$index"));
}
),
)
)

在这个例子中,ScrollBar会直接失效,这是因为我们在onNotification中返回了true,这表示滚动事件不再向上冒泡,那么ScrollBar也就接收不到滚动事件,自然就失效了

方法三: 通过ScrollPosition的isScrollingNotifier来监听

isScrollingNotifier也是一个Listenable,所以我们也可以给他添加监听器:

1
2
3
4
5
6
7
8
9
10
11
12
_controller = ScrollController(
onAttach: (position){
_controller.position.isScrollingNotifier.addListener(_yourHandler);
// 也可直接使用position参数: position.isScrollingNotifier.addListener(_yourHandler);
},
onDetach: (position){
_controller.position.isScrollingNotifier.removeListener(_yourHandler);
// 也可直接使用position参数: position.isScrollingNotifier.addListener(_yourHandler);
}
)


这种方法和前面两种不同的是,它不会在滚动期间持续调用回调函数,而是只在滚动开始和滚动结束时调用(可滚动组件在静止和滚动这两种状态之间切换时调用)

注意一定要在onAttach中进行监听器的绑定,因为这种监听方法依赖的是ScrollPosition对象,这个对象在ScrollController被关联到一个可滚动组件时才会创建,在绑定之前position实际上是空的!而onAttach方法就是在position对象创建后被调用的,所以很适合在其中进行监听器的绑定。同时别忘了在onDetach中移除监听处理方法,防止内存泄漏

滚动事件中的常用信息

这些信息的获取方法有两种,一种是通过ScrollNotification来获取: notification.metrics.xxx; 还可以直接通过ScrollController来获取: _controller.position.xxx,这两种方式中的xxx属性名都是一样的

  • pixels: 当前滚动的位置
  • maxScrollExtent: 最大滚动高度
  • extentBefore: 滑出Viewport顶部的长度
  • extentAfter: 滑出Viewport底部的长度
  • extentInside: Viewport的长度,即显示在屏幕上的长度
  • extentTotal: extentBeforeextentAfterextentInside这三者的和
  • atEdge: 是否滑到了可滚动组件的边界(顶部or底部)