Flutter中常用的可滚动组件,可以用 ScrollController 来控制可滚动组件的滚动位置。

ScrollController

ScrollController构造函数如下:

  1. ScrollController({
  2. double initialScrollOffset = 0.0, //初始滚动位置
  3. this.keepScrollOffset = true, //是否保存滚动位置
  4. this.debugLabel,
  5. })

我们介绍一下ScrollController常用的属性和方法:

  • offset:可滚动组件当前的滚动位置。
  • jumpTo(double offset)animateTo(double offset,...):这两个方法用于跳转到指定的位置,它们不同之处在于,后者在跳转时会执行一个动画,而前者不会。

ScrollController还有一些属性和方法,我们将在后面原理部分解释。

滚动监听

ScrollController间接继承自Listenable,我们可以根据ScrollController来监听滚动事件,如:

  1. controller.addListener(()=>print(controller.offset))

示例1:

fdtdttyddasd.gif
我们创建一个ListView,当滚动位置发生变化时,我们先打印出当前滚动位置,然后判断当前位置是否超过1000像素,如果超过则在屏幕右下角显示一个“返回顶部”的按钮,该按钮点击后可以使ListView恢复到初始位置;如果没有超过1000像素,则隐藏“返回顶部”按钮。代码如下:

  1. import 'package:app1/utils/Base.dart';
  2. import 'package:flutter/material.dart';
  3. class ScrollDemo1 extends StatefulWidget {
  4. @override
  5. _ScrollDemo1State createState() => _ScrollDemo1State();
  6. }
  7. class _ScrollDemo1State extends State<ScrollDemo1> {
  8. ScrollController _controller = ScrollController();
  9. bool showToTopBtn = false; //是否显示“返回到顶部”按钮
  10. @override
  11. void initState() {
  12. super.initState();
  13. // 监听滚动
  14. _controller.addListener(() {
  15. var scrollTop = _controller.offset;
  16. print('滚动位置 => ${scrollTop}');
  17. if (scrollTop < 1000 && showToTopBtn) {
  18. setState(() {
  19. showToTopBtn = false;
  20. });
  21. } else if (scrollTop >= 1000 && showToTopBtn == false) {
  22. setState(() {
  23. showToTopBtn = true;
  24. });
  25. }
  26. });
  27. }
  28. @override
  29. void dispose() {
  30. //为了避免内存泄露,需要调用_controller.dispose
  31. _controller.dispose();
  32. super.dispose();
  33. }
  34. @override
  35. Widget build(BuildContext context) {
  36. return Scaffold(
  37. body: Scrollbar(
  38. child: ListView.builder(
  39. controller: _controller,
  40. itemCount: 20,
  41. itemBuilder: (content, index) {
  42. return Container(
  43. height: 100,
  44. color: Color(Base.getRandomColor()),
  45. child: Text('text ${index + 1}'),
  46. );
  47. },
  48. ),
  49. ),
  50. floatingActionButton: !showToTopBtn
  51. ? null
  52. : FloatingActionButton(
  53. child: Icon(Icons.arrow_upward),
  54. onPressed: () {
  55. // 返回顶部
  56. // _controller.jumpTo(0); //无动画,直接跳到目标位置
  57. _controller.animateTo(
  58. 0,
  59. duration: Duration(milliseconds: 500), //动画时间
  60. curve: Curves.ease, //动画缓动方式
  61. );
  62. },
  63. ),
  64. );
  65. }
  66. }

滚动位置恢复

PageStorage是一个用于保存页面(路由)相关数据的组件,它并不会影响子树的UI外观,其实,PageStorage是一个功能型组件,它拥有一个存储桶(bucket),子树中的Widget可以通过指定不同的PageStorageKey来存储各自的数据或状态。

每次滚动结束,可滚动组件都会将滚动位置offset存储到PageStorage中,当可滚动组件重新创建时再恢复。如果ScrollController.keepScrollOffsetfalse,则滚动位置将不会被存储,可滚动组件重新创建时会使用ScrollController.initialScrollOffsetScrollController.keepScrollOffsettrue时,可滚动组件在第一次创建时,会滚动到initialScrollOffset处,因为这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,而initialScrollOffset会被忽略。

当一个路由中包含多个可滚动组件时,如果你发现在进行一些跳转或切换操作后,滚动位置不能正确恢复,这时你可以通过显式指定PageStorageKey来分别跟踪不同的可滚动组件的位置,如:

  1. ListView(key: PageStorageKey(1), ... );
  2. ...
  3. ListView(key: PageStorageKey(2), ... );

不同的PageStorageKey,需要不同的值,这样才可以为不同可滚动组件保存其滚动位置。

注意:一个路由中包含多个可滚动组件时,如果要分别跟踪它们的滚动位置,并非一定就得给他们分别提供PageStorageKey。这是因为Scrollable本身是一个StatefulWidget,它的状态中也会保存当前滚动位置,所以,只要可滚动组件本身没有被从树上detach掉,那么其State就不会销毁(dispose),滚动位置就不会丢失。只有当Widget发生结构变化,导致可滚动组件的State销毁或重新构建时才会丢失状态,这种情况就需要显式指定PageStorageKey,通过PageStorage来存储滚动位置,一个典型的场景是在使用TabBarView时,在Tab发生切换时,Tab页中的可滚动组件的State就会销毁,这时如果想恢复滚动位置就需要指定PageStorageKey

ScrollPosition

ScrollPosition是用来保存可滚动组件的滚动位置的。一个ScrollController对象可以同时被多个可滚动组件使用,ScrollController会为每一个可滚动组件创建一个ScrollPosition对象,这些ScrollPosition保存在ScrollControllerpositions属性中(List<ScrollPosition>)。ScrollPosition是真正保存滑动位置信息的对象,offset只是一个便捷属性:

  1. double get offset => position.pixels;

一个ScrollController虽然可以对应多个可滚动组件,但是有一些操作,如读取滚动位置offset,则需要一对一!但是我们仍然可以在一对多的情况下,通过其它方法读取滚动位置,举个例子,假设一个ScrollController同时被两个可滚动组件使用,那么我们可以通过如下方式分别读取他们的滚动位置:

  1. ...
  2. controller.positions.elementAt(0).pixels
  3. controller.positions.elementAt(1).pixels
  4. ...

我们可以通过controller.positions.length来确定controller被几个可滚动组件使用。

ScrollPosition的方法

ScrollPosition有两个常用方法:animateTo()jumpTo(),它们是真正来控制跳转滚动位置的方法,ScrollController的这两个同名方法,内部最终都会调用ScrollPosition的。

ScrollController控制原理

我们来介绍一下ScrollController的另外三个方法:

  1. ScrollPosition createScrollPosition(
  2. ScrollPhysics physics,
  3. ScrollContext context,
  4. ScrollPosition oldPosition);
  5. void attach(ScrollPosition position) ;
  6. void detach(ScrollPosition position) ;

ScrollController和可滚动组件关联时,可滚动组件首先会调用ScrollControllercreateScrollPosition()方法来创建一个ScrollPosition来存储滚动位置信息,接着,可滚动组件会调用attach()方法,将创建的ScrollPosition添加到ScrollControllerpositions属性中,这一步称为“注册位置”,只有注册后animateTo()jumpTo()才可以被调用。

当可滚动组件销毁时,会调用ScrollControllerdetach()方法,将其ScrollPosition对象从ScrollControllerpositions属性中移除,这一步称为“注销位置”,注销后animateTo()jumpTo() 将不能再被调用。

需要注意的是,ScrollControlleranimateTo()jumpTo()内部会调用所有ScrollPositionanimateTo()jumpTo(),以实现所有和该ScrollController关联的可滚动组件都滚动到指定的位置。

滚动监听

Flutter Widget树中子Widget可以通过发送通知(Notification)与父(包括祖先)Widget通信。父级组件可以通过NotificationListener组件来监听自己关注的通知,这种通信方式类似于Web开发中浏览器的事件冒泡,我们在Flutter中沿用“冒泡”这个术语,关于通知冒泡我们将在后面“事件处理与通知”一章中详细介绍。

可滚动组件在滚动时会发送ScrollNotification类型的通知,ScrollBar正是通过监听滚动通知来实现的。通过NotificationListener监听滚动事件和通过ScrollController有两个主要的不同:

  1. 通过NotificationListener可以在从可滚动组件到widget树根之间任意位置都能监听。而ScrollController只能和具体的可滚动组件关联后才可以。
  2. 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置。

示例

下面,我们监听ListView的滚动通知,然后显示当前滚动进度百分比:
fdtdttyddasd.gif

  1. import 'package:app1/utils/Base.dart';
  2. import 'package:flutter/material.dart';
  3. class ScrollDemo2 extends StatefulWidget {
  4. @override
  5. _ScrollDemo2State createState() => _ScrollDemo2State();
  6. }
  7. class _ScrollDemo2State extends State<ScrollDemo2> {
  8. String _progress = '0%';
  9. @override
  10. Widget build(BuildContext context) {
  11. return Scrollbar(
  12. child: NotificationListener<ScrollNotification>(
  13. //监听滚动事件
  14. onNotification: (ScrollNotification notification) {
  15. var metrics = notification.metrics;
  16. double progress = metrics.pixels / metrics.maxScrollExtent;
  17. setState(() {
  18. _progress = "${(progress * 100).toInt()}%";
  19. });
  20. print('开始');
  21. print('pixels => ${metrics.pixels}'); //当前滚动位置
  22. print('minScrollExtent => ${metrics.minScrollExtent}'); //最大可滚动长度
  23. print('maxScrollExtent => ${metrics.maxScrollExtent}'); //最小可滚动长度
  24. print("BottomEdge: ${metrics.extentAfter == 0}"); //是否已滚动到底部
  25. print('extentAfter => ${metrics.extentAfter}'); //底部 尚未滚动至可见区域的 高度
  26. print('extentBefore => ${metrics.extentBefore}'); //顶部 尚未滚动至可见区域的 高度
  27. print('extentInside => ${metrics.extentInside}'); //ViewPort内部长度;此示例中屏幕显示的列表部分的长度
  28. print('axis => ${metrics.axis}'); //滚动轴向 Axis.vertical 垂直
  29. print('axisDirection => ${metrics.axisDirection}'); //滚动方向 AxisDirection.down向下
  30. print('atEdge => ${metrics.atEdge}'); //是否滑到了可滚动组件的边界
  31. print('outOfRange => ${metrics.outOfRange}'); //
  32. print('viewportDimension => ${metrics.viewportDimension}'); //窗口可视区域,需减去状态栏高度
  33. //return true; //放开此行注释后,进度条将失效
  34. },
  35. child: Stack(
  36. children: [
  37. Container(
  38. width: double.infinity,
  39. height: double.infinity,
  40. child: ListView.builder(
  41. itemCount: 10,
  42. itemBuilder: (content, index) {
  43. return Container(
  44. height: 100,
  45. color: Color(Base.getRandomColor()),
  46. child: Text('text ${index + 1}'),
  47. );
  48. },
  49. ),
  50. ),
  51. Align(
  52. child: CircleAvatar(
  53. //显示进度百分比
  54. radius: 30.0,
  55. child: Text(_progress),
  56. backgroundColor: Colors.black54,
  57. ),
  58. ),
  59. ],
  60. ),
  61. ),
  62. );
  63. }
  64. }