Flutter - 详情页初始锚点与优化

欢迎关注微信公众号:FSA全栈行动 👋

一、前言

这是最近一个需求中需要实现的小功能,产品希望在点击通知后打开详情页,并直接定位到指定模块的位置。

基于上一篇《Flutter - 详情页 TabBar 与模块联动?秒了!》已经实现的联动效果,我们现在只需要完成初始锚点功能,以及解决初始锚点模块被异步加载显示的模块往下顶的问题即可。

相应的 Demo 可科学上网后在线体验: fluttercandies.github.io/flutter_scr...

如图,进入详情页后定位至 模块 7,接着 模块 3模块 6 在异步请求到数据后再显示,此时 模块 7 依旧保持当前位置不被顶下去。

OK,接下来我们就来看看如何通过我的开源库实现该功能

github.com/fluttercand...

二、实现

初始锚点

创建两个必要的控制器

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,
);

调整 ListViewphysics

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 会在 ListViewObserverinitState 的下一帧去执行 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 技术,还有 AndroidFlutterPython 等文章, 可能有你想要了解的技能知识点哦~

相关推荐
胡gh1 小时前
依旧性能优化,如何在浅比较上做文章,memo 满天飞,谁在裸奔?
前端·react.js·面试
大怪v2 小时前
超赞👍!优秀前端佬的电子布洛芬技术网站!
前端·javascript·vue.js
胡gh2 小时前
你一般用哪些状态管理库?别担心,Zustand和Redux就能说个10分钟
前端·面试·node.js
roamingcode3 小时前
Claude Code NPM 包发布命令
前端·npm·node.js·claude·自定义指令·claude code
码哥DFS3 小时前
NPM模块化总结
前端·javascript
灵感__idea4 小时前
JavaScript高级程序设计(第5版):代码整洁之道
前端·javascript·程序员
唐璜Taro4 小时前
electron进程间通信-IPC通信注册机制
前端·javascript·electron
分布式存储与RustFS5 小时前
RustFS的边缘计算优化方案在5G MEC场景下的实测数据如何?
人工智能·5g·开源·边缘计算·rustfs
陪我一起学编程5 小时前
创建Vue项目的不同方式及项目规范化配置
前端·javascript·vue.js·git·elementui·axios·企业规范