在Flutter中,ScrollController可以精确地控制和管理滚动行为。通过ScrollController,可以监听滚动的位置、速度,甚至可以在用户滚动时触发自定义的动作。此外,ScrollController还提供了对滚动位置的直接控制,可以编程地滚动到特定位置。
ScrollController({
double initialScrollOffset = 0.0,
this.keepScrollOffset = true,
this.debugLabel,
this.onAttach,
this.onDetach,
}) : _initialScrollOffset = initialScrollOffset {
if (kFlutterMemoryAllocationsEnabled) {
ChangeNotifier.maybeDispatchObjectCreation(this);
}
}
ScrollController
常用的属性和方法ScrollController间接继承自Listenable,可以根据ScrollController来监听滚动事件
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(() {
});
}
创建一个ListView
,当滚动位置发生变化时,记录滑动位置:
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(() {
print("location:${_scrollController.offset}");
setState(() {
offset = _scrollController.offset;
});
});
}
body: ListView(
scrollDirection: scrollDirection,
controller: _scrollController,
children: randomList.map<Widget>((data) {
return Padding(
padding: const EdgeInsets.all(8),
child: _getRow(data[0], math.max(data[1].toDouble(), 50.0)),
);
}).toList(),
)
TextButton(
onPressed: () {
},
child: Text("position: ${offset.floor()}"),
)
通过animateTo方法回到顶部。
TextButton(
onPressed: () {
_scrollController.animateTo(0,
duration: const Duration(seconds: 1),//动画时间是1秒,
curve: Curves.bounceInOut);//动画曲线
},
child: Text("回到顶部"),
)
PageStorage是一个用于保存页面(路由)相关数据的组件,它并不会影响子树的UI外观,其实,PageStorage是一个功能型组件,它拥有一个存储桶(bucket),子树中的Widget可以通过指定不同的PageStorageKey来存储各自的数据或状态。
每次滚动结束,可滚动组件都会将滚动位置offset存储到PageStorage中,当可滚动组件重新创建时再恢复。如果ScrollController.keepScrollOffset为false,则滚动位置将不会被存储,可滚动组件重新创建时会使用ScrollController.initialScrollOffset;ScrollController.keepScrollOffset为true时,可滚动组件在第一次创建时,会滚动到initialScrollOffset处,因为这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,而initialScrollOffset会被忽略。
当一个路由中包含多个可滚动组件时,如果你发现在进行一些跳转或切换操作后,滚动位置不能正确恢复,这时你可以通过显式指定PageStorageKey来分别跟踪不同的可滚动组件的位置,如:
ListView(key: PageStorageKey(1), ... );
...
ListView(key: PageStorageKey(2), ... );
不同的PageStorageKey,需要不同的值,这样才可以为不同可滚动组件保存其滚动位置。
注意:一个路由中包含多个可滚动组件时,如果要分别跟踪它们的滚动位置,并非一定就得给他们分别提供PageStorageKey。这是因为Scrollable本身是一个StatefulWidget,它的状态中也会保存当前滚动位置,所以,只要可滚动组件本身没有被从树上移除(detach),那么其State就不会销毁(dispose),滚动位置就不会丢失。只有当Widget发生结构变化,导致可滚动组件的State销毁或重新构建时才会丢失状态,这种情况就需要显式指定PageStorageKey,通过PageStorage来存储滚动位置,一个典型的场景是在使用TabBarView时,在Tab发生切换时,Tab页中的可滚动组件的State就会销毁,这时如果想恢复滚动位置就需要指定PageStorageKey。
ScrollPosition是用来保存可滚动组件的滚动位置的。一个ScrollController对象可以同时被多个可滚动组件使用,ScrollController会为每一个可滚动组件创建一个ScrollPosition对象,这些ScrollPosition保存在ScrollController的positions属性中(List<ScrollPosition>)。
final List<ScrollPosition> _positions = <ScrollPosition>[];
ScrollPosition是真正保存滑动位置信息的对象,offset只是一个便捷属性:
double get offset => position.pixels;
一个ScrollController虽然可以对应多个可滚动组件,但是有一些操作,如读取滚动位置offset,则需要一对一!但是我们仍然可以在一对多的情况下,通过其他方法读取滚动位置,举个例子,假设一个ScrollController同时被两个可滚动组件使用,那么我们可以通过如下方式分别读取他们的滚动位置:
...
controller.positions.elementAt(0).pixels
controller.positions.elementAt(1).pixels
...
我们可以通过controller.positions.length
来确定controller
被几个可滚动组件使用。
ScrollPosition有两个常用方法:animateTo() 和 jumpTo(),它们是真正来控制跳转滚动位置的方法,ScrollController的这两个同名方法,内部最终都会调用ScrollPosition的。
#ScrollPosition
@override
Future<void> animateTo(
double to, {
required Duration duration,
required Curve curve,
});
#ScrollPosition
@override
void jumpTo(double value);
#ScrollController
Future<void> animateTo(
double offset, {
required Duration duration,
required Curve curve,
}) async {
assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
await Future.wait<void>(<Future<void>>[
for (int i = 0; i < _positions.length; i += 1) _positions[i].animateTo(offset, duration: duration, curve: curve),
]);
}
#ScrollController
void jumpTo(double value) {
assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
for (final ScrollPosition position in List<ScrollPosition>.of(_positions)) {
position.jumpTo(value);
}
}
ScrollController的三个重要方法:
ScrollPosition createScrollPosition(
ScrollPhysics physics,
ScrollContext context,
ScrollPosition oldPosition);
void attach(ScrollPosition position) ;
void detach(ScrollPosition position) ;
当ScrollController和可滚动组件关联时,可滚动组件首先会调用ScrollController的createScrollPosition()方法来创建一个ScrollPosition来存储滚动位置信息,接着,可滚动组件会调用attach()方法,将创建的ScrollPosition添加到ScrollController的positions属性中,这一步称为“注册位置”,只有注册后animateTo() 和 jumpTo()才可以被调用。
当可滚动组件销毁时,会调用ScrollController的detach()方法,将其ScrollPosition对象从ScrollController的positions属性中移除,这一步称为“注销位置”,注销后animateTo() 和 jumpTo() 将不能再被调用。
需要注意的是,ScrollController的animateTo() 和 jumpTo()内部会调用所有ScrollPosition的animateTo() 和 jumpTo(),以实现所有和该ScrollController关联的可滚动组件都滚动到指定的位置。
String notify = "";
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("ScrollToIndexDemoPage"),
),
body: NotificationListener(
onNotification: (dynamic notification) {
String notify = "";
if (notification is ScrollEndNotification) {
notify = "ScrollEnd";
} else if (notification is ScrollStartNotification) {
notify = "ScrollStart";
} else if (notification is UserScrollNotification) {
notify = " UserScroll";
} else if (notification is ScrollUpdateNotification) {
notify = "ScrollUpdate";
}
setState(() {
this.notify = notify;
});
return false;
},
child: ListView(
scrollDirection: scrollDirection,
controller: _scrollController,
children: randomList.map<Widget>((data) {
return Padding(
padding: const EdgeInsets.all(8),
child: _getRow(data[0], math.max(data[1].toDouble(), 50.0)),
);
}).toList(),
)),
persistentFooterButtons: <Widget>[
const SizedBox(width: 0.3, height: 30.0),
TextButton(
onPressed: () {},
child: Text(notify),
)
],
);
}
ScrollNotification是滚动事件通知,ScrollEndNotification、ScrollStartNotification、UserScrollNotification、ScrollUpdateNotification都是它的子类。
abstract class ScrollNotification extends LayoutChangedNotification with ViewportNotificationMixin {
/// Initializes fields for subclasses.
ScrollNotification({
required this.metrics,
required this.context,
});
/// A description of a [Scrollable]'s contents, useful for modeling the state
/// of its viewport.
final ScrollMetrics metrics;
/// The build context of the widget that fired this notification.
///
/// This can be used to find the scrollable's render objects to determine the
/// size of the viewport, for instance.
final BuildContext? context;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$metrics');
}
}
mixin ScrollMetrics {
/// Creates a [ScrollMetrics] that has the same properties as this object.
///
/// This is useful if this object is mutable, but you want to get a snapshot
/// of the current state.
///
/// The named arguments allow the values to be adjusted in the process. This
/// is useful to examine hypothetical situations, for example "would applying
/// this delta unmodified take the position [outOfRange]?".
ScrollMetrics copyWith({
double? minScrollExtent,
double? maxScrollExtent,
double? pixels,
double? viewportDimension,
AxisDirection? axisDirection,
double? devicePixelRatio,
}) {
return FixedScrollMetrics(
minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null),
maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null),
pixels: pixels ?? (hasPixels ? this.pixels : null),
viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null),
axisDirection: axisDirection ?? this.axisDirection,
devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio,
);
}
pixels:当前滚动位置。
maxScrollExtent:最大可滚动长度。
extentBefore:滑出ViewPort顶部的长度;此示例中相当于顶部滑出屏幕上方的列表长度。
extentInside:ViewPort内部长度;此示例中屏幕显示的列表部分的长度。
extentAfter:列表中未滑入ViewPort部分的长度;此示例中列表底部未显示到屏幕范围部分的长度。
atEdge:是否滑到了可滚动组件的边界(此示例中相当于列表顶或底部)。
虽然 Flutter
官方提供了 ScrollController
,调用相关方法可以滚动到指定偏移处,但是官方没有提供滚动到指定下标位置的功能。
我们可以使用三方库实现动到指定下标位置的功能。
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
class ScrollToIndexDemoPage extends StatefulWidget {
const ScrollToIndexDemoPage({super.key});
@override
_ScrollToIndexDemoPageState createState() => _ScrollToIndexDemoPageState();
}
class _ScrollToIndexDemoPageState extends State<ScrollToIndexDemoPage> {
static const maxCount = 100;
/// pub scroll_to_index 项目的 controller
AutoScrollController? controller;
final ScrollController _scrollController = ScrollController();
final random = math.Random();
final scrollDirection = Axis.vertical;
late List<List<int>> randomList;
//双获取偏移=>位置.像素;
double offset = 0;
String notify = "";
@override
void initState() {
super.initState();
controller = AutoScrollController(
viewportBoundaryGetter: () =>
Rect.fromLTRB(0, 0, 0, MediaQuery.paddingOf(context).bottom),
axis: scrollDirection);
///一个 index 和 item 高度的数组
randomList = List.generate(maxCount,
(index) => <int>[index, (1000 * random.nextDouble()).toInt()]);
_scrollController.addListener(() {
print("location:${_scrollController.offset}");
setState(() {
offset = _scrollController.offset;
});
});
}
Widget _getRow(int index, double height) {
return _wrapScrollTag(
index: index,
child: Container(
padding: const EdgeInsets.all(8),
alignment: Alignment.topCenter,
height: height,
decoration: BoxDecoration(
border: Border.all(color: Colors.lightBlue, width: 4),
borderRadius: BorderRadius.circular(12)),
child: Text('index: $index, height: $height'),
));
}
Widget _wrapScrollTag({required int index, required Widget child}) =>
AutoScrollTag(
key: ValueKey(index),
controller: controller!,
index: index,
highlightColor: Colors.black.withOpacity(0.1),
child: child,
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("ScrollToIndexDemoPage"),
),
body: NotificationListener(
onNotification: (dynamic notification) {
String notify = "";
if (notification is ScrollEndNotification) {
notify = "ScrollEnd";
} else if (notification is ScrollStartNotification) {
notify = "ScrollStart";
} else if (notification is UserScrollNotification) {
notify = " UserScroll";
} else if (notification is ScrollUpdateNotification) {
notify = "ScrollUpdate";
}
setState(() {
this.notify = notify;
});
return false;
},
child: ListView(
scrollDirection: scrollDirection,
controller: controller,
children: randomList.map<Widget>((data) {
return Padding(
padding: const EdgeInsets.all(8),
child: _getRow(data[0], math.max(data[1].toDouble(), 50.0)),
);
}).toList(),
)),
persistentFooterButtons: <Widget>[
TextButton(
onPressed: () async {
///滑动到第13个的位置
await controller!
.scrollToIndex(13, preferPosition: AutoScrollPosition.begin);
controller!.highlight(13);
},
child: const Text("Scroll to 13"),
),
// const SizedBox(width: 0.3, height: 30.0),
// TextButton(
// onPressed: () {
// // _scrollController.animateTo(0,
// // duration: const Duration(seconds: 1),
// // curve: Curves.bounceInOut);
// // // setState(() {
// // // entries = entries;
// // // });
// },
// child: Text("position: ${offset.floor()}"),
// ),
// const SizedBox(width: 0.3, height: 30.0),
// TextButton(
// onPressed: () {
// _scrollController.animateTo(0,
// duration: const Duration(seconds: 1),
// curve: Curves.bounceInOut);
// },
// child: Text("回到顶部"),
// ),
// const SizedBox(width: 0.3, height: 30.0),
// TextButton(
// onPressed: () {},
// child: Text(notify),
// )
],
);
}
}
AutoScrollTag 是一个可以包裹在任意行级 widget 中的组件,它会接收控制器和索引值,并在需要时高亮显示。
AutoScrollController 则负责整个滚动操作,包括监听和触发滚动到指定索引的命令。
对于有固定行高的情况,可以设置 suggestedRowHeight 参数以提高滚动效率。
通过 viewportBoundaryGetter 自定义视口边界,以及选择垂直或水平滚动方向。