FSA全栈行动 FSA全栈行动
首页
  • 移动端文章

    • Android
    • iOS
    • Flutter
  • 学习笔记

    • 《Kotlin快速入门进阶》笔记
    • 《Flutter从入门到实战》笔记
    • 《Flutter复习》笔记
前端
后端
  • 学习笔记

    • 《深入浅出设计模式Java版》笔记
  • 逆向
  • 分类
  • 标签
  • 归档
  • LinXunFeng
  • GitLqr

公众号:FSA全栈行动

记录学习过程中的知识
首页
  • 移动端文章

    • Android
    • iOS
    • Flutter
  • 学习笔记

    • 《Kotlin快速入门进阶》笔记
    • 《Flutter从入门到实战》笔记
    • 《Flutter复习》笔记
前端
后端
  • 学习笔记

    • 《深入浅出设计模式Java版》笔记
  • 逆向
  • 分类
  • 标签
  • 归档
  • LinXunFeng
  • GitLqr
  • AndroidUI

  • Android第三方SDK

  • Android混淆

  • Android仓库

  • Android新闻

  • Android系统开发

  • Android源码

  • Android注解AOP

  • Android脚本

  • AndroidTv开发

  • AndroidNDK

  • Android音视频

  • Android热修复

  • Android性能优化

  • Android云游戏

  • Android插件化

  • iOSUI

  • iOS工具

  • iOS底层原理与应用

  • iOS组件化

  • iOS音视频

  • iOS疑难杂症

  • iOS之Swift

  • iOS之RxSwift

  • iOS开源项目

  • iOS逆向

  • Flutter开发

    • Dart - 抽象类的实例化
    • Flutter - 打印好用的Debug日志
    • Flutter - 混合开发
    • Flutter - 解决混合开发iOS脚本打包遇到的问题
    • Flutter - 低版本在iOS14上遇到的问题与解决方案
    • Flutter - 解决原生弹窗的触摸事件被Flutter响应的问题
    • Flutter - 实现列表上下拉切换header
    • Flutter - 获取ListView当前正在显示的Widget信息
    • Flutter - 列表滚动定位超强辅助库,墙裂推荐!🔥
    • Flutter - 快速实现聊天会话列表的效果,完美💯
    • Flutter - 聊天输入框更新文本时的必备优化点🔖
    • Flutter - 我给官方提PR,解决run命令卡住问题 😃
    • Flutter - 探索run命令到底做了什么 🤔
    • Flutter - 引擎调试(iOS篇)🛠
    • Flutter - 引擎调试bug到提交PR实战 🐞
    • Flutter - 船新升级😱支持观察第三方构建的滚动视图💪
    • Flutter - 瀑布流交替播放视频 🎞
    • Flutter - IM保持消息位置大升级(支持ChatGPT生成式消息) 🤖
    • Flutter - 滚动视图中的表单防遮挡 🗒
    • Flutter - 秒杀1/2曝光统计 📊
    • 一天内加入 Flutter 和 FlutterCandies 两大组织是什么体验 🧐
    • Flutter - 如何快速搓一个微信通讯录列表(azlist) 📓
    • Flutter - 混编项目集成Shorebird热更新🐦(安卓篇)
    • Flutter - 混编项目集成Shorebird热更新🐦(iOS篇)
    • Flutter - 解决返回原生页面时dispose方法未被触发的问题 🐞
    • Flutter - 升级3.19之后页面多次rebuild?🤨
    • Flutter - 热更新 Shorebird 1.0 正式版来了 🐦
    • Flutter - 使用Pigeon实现视频缓存插件 🐌
    • Flutter - 轻松搞定屏幕旋转功能 😎
    • Flutter - 解决Connection closed before full header was received
    • Flutter - 实现聊天键盘与功能面板的丝滑切换 🍻
    • Flutter - 支持观察NestedScrollView,兼容性更强 😈
    • Flutter - 聊天键盘与面板丝滑切换的强势升级 🍻
    • Flutter - 升级到3.24后页面还会多次rebuild吗?🧐
    • Flutter - 轻松实现PageView卡片偏移效果
    • Flutter - 轻松搞定炫酷视差(Parallax)效果
    • Flutter - 危!3.24版本苹果审核被拒!
    • Flutter - 子部件任意位置观察滚动数据
    • Flutter - iOS编译加速
    • Flutter - Xcode16 还原编译速度
    • Flutter - GetX Helper 助你规范应用 tag
    • Flutter - GetX Helper 如何应用于旧页面
    • Flutter - 聊天面板库动画生硬?这次让你丝滑个够
    • Flutter - 使用本地 DevTools 验证 SVG 加载优化
    • Flutter - 详情页 TabBar 与模块联动?秒了!
    • Flutter - 详情页初始锚点与优化
      • 一、前言
      • 二、实现
        • 初始锚点
        • 异步加载
        • 保持位置
      • 三、注意项
        • 初始锚点失效
        • 视图闪动
        • 保持位置的限制
      • 四、最后
  • 移动端
  • Flutter开发
LinXunFeng
2025-08-24
目录

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

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

# 一、前言

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

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

相应的 Demo 可科学上网后在线体验: https://fluttercandies.github.io/flutter_scrollview_observer/#/detail (opens new window)

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

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

https://github.com/fluttercandies/flutter_scrollview_observer (opens new window)

# 二、实现

# 初始锚点

创建两个必要的控制器

ScrollController scrollController = ScrollController();

late ListObserverController observerController = ListObserverController(
  controller: scrollController,
)

如果你的 ListView 从一开始就拿到了所有数据,那直接按如下设置 initialIndex 即可

late ListObserverController observerController = ListObserverController(
  controller: scrollController,
)
  // 设置初始锚点下标
  ..initialIndex = 3;

如果需要设置偏移量,则可以使用 initialIndexModel 的方式。

late ListObserverController observerController = ListObserverController(
  controller: scrollController,
)
  ..initialIndexModel = ObserverIndexPositionModel(
    index: 3,
    offset: (_) => navBarHeight,
  );

但我们今天的例子稍微复杂一点,ListView 的部分模块是动态加载的。

为了更快的显示页面,在请求到基础数据后,会先显示出来,再单独去请求加载一些数据量较大的接口再刷新对应的模块视图。

在得到页面所需的基础数据后,调用如下 initIndexPositionForListView 方法初始化锚点。

// 请求到基础数据
// 初始锚点
initIndexPositionForListView();

// 刷新页面
update();
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 方法中跳过了异步数据模块的处理,所以传递给详情页的初始锚点为异步加载的模块时,需要在成功请求到数据后自行检测并滚动到相应的位置。

/// 检测初始锚点
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 为例

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 会被往下推,接下来我们就来优化掉这个问题!

# 保持位置

创建必要的控制器

late ChatScrollObserver keepPositionObserver = ChatScrollObserver(
  observerController,
);

调整 ListView 的 physics

// 使用 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,
);

调整模块的刷新方法

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 为例

void loadAsyncData() {
  // 模拟请求数据
  await Future.delayed(const Duration(seconds: 2));
  ...

  // 注释掉原刷新逻辑
  // update([DetailUpdateType.module3]);
  // 改为调用 updateAndKeepPositionForListView
  updateAndKeepPositionForListView([DetailUpdateType.module3]);

  // 检测是否为初始锚点
  checkAnchorForListView(DetailModuleType.module3);
}

好了,大功告成~

# 三、注意项

# 初始锚点失效

注意:initialIndexModel 会在 ListViewObserver 的 initState 的下一帧去执行 jumpTo 时使用,所以还未请求到数据时,请先不要构建它,避免初始锚点功能失效。

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。

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(),
        ),
      );
    },
  );
}
// 请求到数据了
update();

// 延迟大法去掉 loading
await Future.delayed(const Duration(milliseconds: 100));
state.showLoading = false;
update([DetailUpdateType.loading]);

# 保持位置的限制

主要实现逻辑是在刷新模块视图之前,找到当前正在显示的第一个 item,将其做为参照对象,在视图刷新之后,再次找到被参照对象,对比之前的偏移量进行还原。

所以,在刷新的前后,被参数的 item 必须有被渲染出来!渲染出来并不等同于显示出来,如在缓冲区。

因此,你需要自行给 ListView 设置适当的 cacheExtent。

# 四、最后

通过上述示例的讲解,相信你对 scrollview_observer 的使用又更加清楚,开源不易,如果你也觉得这个库好用,请不吝给个 Star 👍 ,并多多支持!

GitHub: https://github.com/fluttercandies/flutter_scrollview_observer (opens new window)

本篇到此结束,感谢大家的支持,我们下次再见! 👋

#Dart#Flutter
上次更新: 2025/08/24, 08:22:48
Flutter - 详情页 TabBar 与模块联动?秒了!

← Flutter - 详情页 TabBar 与模块联动?秒了!

最近更新
01
Flutter - 详情页 TabBar 与模块联动?秒了!
08-17
02
Flutter - 使用本地 DevTools 验证 SVG 加载优化
08-07
03
AI - Gemini CLI 摆脱终端限制
07-27
更多文章>
Theme by Vdoing | Copyright © 2020-2025 FSA全栈行动
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式
×