欢迎关注微信公众号:FSA全栈行动 👋
一、前言
这是最近一个需求中需要实现的小功能,产品希望在点击通知后打开详情页,并直接定位到指定模块的位置。
基于上一篇《Flutter - 详情页 TabBar 与模块联动?秒了!》已经实现的联动效果,我们现在只需要完成初始锚点功能,以及解决初始锚点模块被异步加载显示的模块往下顶的问题即可。
相应的 Demo
可科学上网后在线体验: fluttercandies.github.io/flutter_scr...

如图,进入详情页后定位至 模块 7
,接着 模块 3
与 模块 6
在异步请求到数据后再显示,此时 模块 7
依旧保持当前位置不被顶下去。
OK,接下来我们就来看看如何通过我的开源库实现该功能
二、实现
初始锚点
创建两个必要的控制器
dart
ScrollController scrollController = ScrollController();
late ListObserverController observerController = ListObserverController(
controller: scrollController,
)
如果你的 ListView
从一开始就拿到了所有数据,那直接按如下设置 initialIndex
即可
dart
late ListObserverController observerController = ListObserverController(
controller: scrollController,
)
// 设置初始锚点下标
..initialIndex = 3;
如果需要设置偏移量,则可以使用 initialIndexModel
的方式。
dart
late ListObserverController observerController = ListObserverController(
controller: scrollController,
)
..initialIndexModel = ObserverIndexPositionModel(
index: 3,
offset: (_) => navBarHeight,
);
但我们今天的例子稍微复杂一点,ListView
的部分模块是动态加载的。
为了更快的显示页面,在请求到基础数据后,会先显示出来,再单独去请求加载一些数据量较大的接口再刷新对应的模块视图。
在得到页面所需的基础数据后,调用如下 initIndexPositionForListView
方法初始化锚点。
dart
// 请求到基础数据
// 初始锚点
initIndexPositionForListView();
// 刷新页面
update();
dart
void initIndexPositionForListView() {
// 默认定位模块 1
final defaultIndexModel = ObserverIndexPositionModel(
index: 0,
);
ObserverIndexPositionModel indexModel = defaultIndexModel;
() {
final moduleAnchor = state.defaultModuleAnchor;
if (moduleAnchor == null) return;
// 异步加载的模块不做处理
if (state.asyncLoadModuleTypes.contains(moduleAnchor)) return;
indexModel = ObserverIndexPositionModel(
index: state.moduleTypes.indexOf(moduleAnchor),
offset: (_) => state.navBarHeight,
);
}();
state.observerController.initialIndexModel = indexModel;
}
至此,非异步加载模块的初始锚点功能就搞定了。
异步加载
上述 initIndexPositionForListView
方法中跳过了异步数据模块的处理,所以传递给详情页的初始锚点为异步加载的模块时,需要在成功请求到数据后自行检测并滚动到相应的位置。
dart
/// 检测初始锚点
void checkAnchorForListView(DetailModuleType moduleType) async {
final moduleAnchor = state.defaultModuleAnchor;
// 没有初始锚点
if (moduleAnchor == null) return;
// 初始锚点与当前模块不一致
if (moduleType != moduleAnchor) return;
if (!state.moduleTypes.contains(moduleType)) return;
// 找到并滚动到对应模块位置
final index = state.moduleTypes.indexOf(moduleType);
await WidgetsBinding.instance.endOfFrame;
state.observerController.animateTo(
index: index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
offset: (_) => state.navBarHeight,
);
}
以异步加载数据的 模块 3
为例
dart
void loadAsyncData() {
// 模拟请求数据
await Future.delayed(const Duration(seconds: 2));
// 请求到数据
if (state.isDisposed) return;
state.haveDataForModule3 = true;
// 刷新模块 3 视图
update([DetailUpdateType.module3]);
// 检测是否为初始锚点
checkAnchorForListView(DetailModuleType.module3);
}
我们来看看目前实现的效果

进入详情页会自动定位至 模块 7
,但随着 模块 3
和 模块 6
相继请求到数据并显示对应视图后,模块 7
会被往下推,接下来我们就来优化掉这个问题!
保持位置
创建必要的控制器
dart
late ChatScrollObserver keepPositionObserver = ChatScrollObserver(
observerController,
);
调整 ListView
的 physics
dart
// 使用 ChatScrollObserver 提供的 physics
ScrollPhysics _physics = ChatObserverClampingScrollPhysics(
observer: state.keepPositionObserver,
);
// 如果原本有设置 physics,比如刷新组件的,可以通过 applyTo 进行组合
if (physics != null) {
_physics = physics.applyTo(_physics);
}
Widget resultWidget = ListView.separated(
controller: state.scrollController,
// 设置 physics
physics: _physics,
...
);
resultWidget = ListViewObserver(
controller: state.observerController,
...
child: resultWidget,
);
调整模块的刷新方法
dart
void updateAndKeepPositionForListView([
List<Object>? ids,
]) {
() async {
// 强制观察 ListView
final result = await state.observerController.dispatchOnceObserve(
isForce: true,
isDependObserveCallback: false,
);
final observeResult = result.observeResult;
if (observeResult == null) return;
final firstChild = observeResult.firstChild;
if (firstChild == null) return;
// 拿到当前正在显示的第一个 item 的下标
// 注意:详情页的模块都是一开始就固定的,没有数据时则视图显示 SizedBox
// 所以模块的下标都是不变的!!!
final refItemIndex = firstChild.index;
// 调用 standby 保持位置
state.keepPositionObserver.standby(
mode: ChatScrollObserverHandleMode.specified,
refItemIndex: refItemIndex,
refItemIndexAfterUpdate: refItemIndex,
);
}();
// 原刷新视图逻辑
update(ids);
}
这里以异步加载数据的 模块 3
为例
dart
void loadAsyncData() {
// 模拟请求数据
await Future.delayed(const Duration(seconds: 2));
...
// 注释掉原刷新逻辑
// update([DetailUpdateType.module3]);
// 改为调用 updateAndKeepPositionForListView
updateAndKeepPositionForListView([DetailUpdateType.module3]);
// 检测是否为初始锚点
checkAnchorForListView(DetailModuleType.module3);
}
好了,大功告成~

三、注意项
初始锚点失效
注意:initialIndexModel
会在 ListViewObserver
的 initState
的下一帧去执行 jumpTo
时使用,所以还未请求到数据时,请先不要构建它,避免初始锚点功能失效。
dart
Widget _buildBody() {
// 还没请求到数据
if (state.isRequesting) {
// 返回 loading 视图
return _buildPageLoading();
}
// 已请求到数据
return Stack(
children: [
// 被 ListViewObserver 包裹的 ListView
const DetailListView(),
// 顶部导航栏
const Positioned(
top: 0,
left: 0,
right: 0,
child: DetailNavBar(),
),
// 为延迟显示页面而盖住的 loading
_buildLoading(),
],
);
}
视图闪动
关于 Stack
中的 _buildLoading
,由于是在下一帧 jumpTo
,所以会看到先显示再滚动的现象,因此可以在请求到数据后,先刷新页面,接着使用延迟大法去掉 loading
。
dart
bool showLoading = true;
Widget _buildLoading() {
return GetBuilder<DetailLogic>(
tag: logicTag,
id: DetailUpdateType.loading,
builder: (_) {
if (!state.showLoading) return const SizedBox.shrink();
return Positioned.fill(
child: Container(
color: Colors.white,
child: const CupertinoActivityIndicator(),
),
);
},
);
}
dart
// 请求到数据了
update();
// 延迟大法去掉 loading
await Future.delayed(const Duration(milliseconds: 100));
state.showLoading = false;
update([DetailUpdateType.loading]);
保持位置的限制
主要实现逻辑是在刷新模块视图之前,找到当前正在显示的第一个 item
,将其做为参照对象,在视图刷新之后,再次找到被参照对象,对比之前的偏移量进行还原。
所以,在刷新的前后,被参数的 item
必须有被渲染出来!渲染出来并不等同于显示出来,如在缓冲区。
因此,你需要自行给 ListView
设置适当的 cacheExtent
。
四、最后
通过上述示例的讲解,相信你对 scrollview_observer
的使用又更加清楚,开源不易,如果你也觉得这个库好用,请不吝给个 Star
👍 ,并多多支持!
GitHub: github.com/fluttercand...
本篇到此结束,感谢大家的支持,我们下次再见! 👋
如果文章对您有所帮助, 请不吝点击关注一下我的微信公众号:FSA全栈行动, 这将是对我最大的激励. 公众号不仅有
iOS
技术,还有Android
,Flutter
,Python
等文章, 可能有你想要了解的技能知识点哦~