ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Flutter Scrollable 核心原理:状态机制、滚动手势与物理规则全解

Flutter Scrollable 核心原理:状态机制、滚动手势与物理规则全解 “面试官问我ListView 底层的 Scrollable 是什么我当时只能说出‘能滚动的容器’然后就被追问死了。”这是前阵子一位同事跟我复述的场景。说实话这类问题在 Flutter 社区里问得不少原因很现实我们每天都在用 ListView、SingleChildScrollView、GridView对它们的方法和参数熟得不行但 Scrollable 藏在它们背后很少被直接引用真要解释起来反而讲不清。我自己刚开始啃 Scrollable 源码时也花了不少时间才把它和 Viewport、Sliver、ScrollPosition 的关系理顺。后来在项目里做横向分页、吸顶、滚动状态监听以及排查各种列表卡顿问题几乎所有结论都要落回到 Scrollable 这一层。这篇文章我打算把它彻底拆开先看它在整个滚动体系里扮演什么角色再顺着“手指按下 → 滚动偏移变化”这条链路把内部状态机讲明白然后分别说清 ScrollController、ScrollPosition、ScrollPhysics 的职责最后给出一个绕过 PageView、直接基于 Scrollable 做分页组件的完整示例并附上性能优化和面试考点。无论你是刚入门想搞懂滚动原理还是准备面试、或者要实现复杂滚动交互这篇都可以直接参考。1. Scrollable在Flutter滚动体系中的位置它凭什么被叫做“可滚动”1.1 先破除一个误解Scrollable本身不渲染任何可见内容很多人看到“Scrollable”这个名字第一反应是它应该是一个类似 SingleChildScrollView 的容器里面放个 child 就能滚。其实这是最大的误读。Scrollable 是一个 StatefulWidget它的职责是“把滚动相关的状态、手势、物理规则管理起来”但它自己不写任何可见的列表项也不直接持有一个 child 来滚动。我建议你把 Scrollable 理解成一辆车的底盘。底盘负责悬挂、转向、动力但你坐在车里看到的方向盘、座椅、仪表盘都不是底盘本身。ListView 之所以能显示列表是因为它在自己的 build 里通过 Scrollable 的 viewportBuilder 参数把一个 Viewport 塞了进去Viewport 再配合 Sliver 去创建和排列每一行内容。Scrollable 只是那个“让这一切可以被滚动起来的底盘”。这个误解带来的实际影响是当你需要自定义一个完全不同的滚动容器时往往不知道要重写 Scrollable反而去改 ListView 的参数最后越改越别扭。理解了“Scrollable 不渲染内容”这件事很多问题就有了解法。1.2 三层分工Scrollable / Viewport / Sliver 各自管什么Flutter 的滚动体系可以粗暴地分成三层我在给别人做技术分享时经常用“窗户和窗帘”来类比Scrollable 是“窗户框架”它感知手指或滚轮输入维护当前滚动位置ScrollPosition并决定滚动到边界时的手感。它不关心窗外有多少内容。Viewport 是“窗玻璃”它定义了你当前能看到多大区域并根据 Scrollable 提供的偏移量决定哪些内容应该被展示在玻璃范围内。Sliver 是“窗帘褶皱”它负责具体内容的布局。它可以根据 Viewport 的请求只构建当前需要展示的那一段滑走的褶皱可以收起来这就是懒加载的底层逻辑。所以当你看到 SingleChildScrollView 和 ListView 用起来差别很大时不要误以为它们底层的滚动机制不同。实际上它们都用 Scrollable差别在于 viewportBuilder 里装的东西SingleChildScrollView 是一个只接受单个 child 的专用 viewport布局时会把 child 的完整尺寸都算一遍ListView 则是标准的 Viewport SliverListSliver 可以按需构建子项滑出屏幕很远的项会被销毁。这也是长列表一定要用 ListView 而不是 SingleChildScrollView 包 Column 的根本原因。1.3 看构造参数理解Scrollable的职责边界Scrollable 的构造参数不多但每一个都恰好对应它的一项职责我把常用的列一下axisDirection滚动的轴向和方向值是 AxisDirection.down / up / left / right。注意它不是 Axis.horizontal / vertical而是带方向的枚举。如果不传会由 Directionality 推断。controllerScrollController负责创建和管理 ScrollPosition。不传时Scrollable 内部会创建一个默认的 ScrollPosition并尝试挂到 PrimaryScrollController 上。physicsScrollPhysics决定到达边界时的行为是“卡住”还是“回弹”。不传时由 ScrollConfiguration 根据平台决定。viewportBuilder这是 Scrollable 和.Viewport 之间唯一的通道。它接收一个 ViewportOffset也就是滚动位置要求你返回一个 Viewport。你在这里返回值就决定了这个滚动容器的“内容形态”。gestureRecognizers默认的手势识别器集合可以覆盖系统默认的拖动/滚轮手势用来解决手势冲突。换句话说Scrollable 把自己的渲染能力完全交了出去只留下“状态管理 手势协调 物理规则”这三件事。理解这个边界后面看源码时就不会迷路。2. 手指到底怎么变成滚动偏移Scrollable内部状态机拆解2.1 ScrollableState比widget活得更久的“滚动大脑”Scrollable 是 StatefulWidget这意味着真正的滚动核心逻辑在它的 State 里这个 State 就是 ScrollableState。为什么强调 State 比 Widget 活得久因为父组件 rebuild 时Scrollable widget 本身可能会被重新创建但 State 只要没有被移除或换 key就会一直保留。滚动位置这种东西如果每次 build 都重置一次那列表一刷新就回到顶部肯定没法用。ScrollableState 里保存了 ScrollPosition、手势识别器集合、当前正在进行的 drag / hold 状态它是全局唯一的状态持有者。ScrollableState 还实现了 ScrollContext 接口。这个接口是给 ScrollPosition 用的相当于给滚动位置提供了一个“联系外界的电话线”。ScrollPosition 可以通过 ScrollContext 拿到 axisDirection、通知 ScrollableState 当前是否可以拖动、保存和恢复滚动位置等。所以你看 ScrollPosition 的构造通常会发现它需要一个 ScrollContext 参数。2.2 从手势识别到像素移动的完整链路我把一次最常见的手指拖动列表操作拆成下面这条链路。理解这条链路比背十遍 API 都有用用户手指按下 ListViewPointerDownEvent 被 Scrollable build 出来的 RawGestureDetector 捕获。注意Scrollable 用的不是普通的 GestureDetector而是更底层的 RawGestureDetector因为它需要精确控制手势识别器的创建和销毁。ScrollableState 在合适的时机调用 updateGestureRecognizer把一组手势识别器注册进去。竖向列表默认识别 vertical drag 和滚轮事件。手势竞技场GestureArena判定拖拽手势胜出触发 drag 的 onStart 回调。ScrollableState 创建 ScrollDragController并让 position 进入“拖动活动”状态。手指移动时DragUpdateDetails 携带的 delta 被传给 ScrollDragController它会先问 physics.applyPhysicsToUserOffset 这个偏移是否需要被修正然后再调用 position.applyUserOffset 更新像素值。position.pixels 一变等于通知了 Viewport 的 offset 变了。Viewport 会在下一帧重新布局把新的内容画到屏幕上。这一步也是滚动性能的关键pixels 变化会触发 layout所以滚动的成本基本取决于这一帧里需要布局多少 Sliver。手指松开_handleDragEnd 被触发。position.endDrag 会调用 physics.createBallisticSimulation 询问“接下来该按什么曲线继续滑”。如果返回一个 Simulation就进入惯性滑动活动直到模拟结束如果返回 null就原地停下。这条链路最值得记住的一点是ScrollableState 全程不直接操作 RenderObject它只负责更新 ScrollPosition 这个“数据源”。到底是什么可视内容跟着滚动那完全是 Viewport 和 Sliver 的事。2.3 自定义手势识别器什么时候必须用gestureRecognizers大多数业务代码都不需要碰 gestureRecognizers但有一个场景非常典型一个横向滚动的 ListView 外面包了一个负责“左右滑动切页”的 GestureDetector你常常会发现横滑列表的手势被抢走列表卡住滑不动。原因就是手势竞技场里外层 GestureDetector 的横向拖拽识别器和 ListView 默认的横向拖拽识别器冲突了。默认规则下外层往往胜出列表就只能“原地看戏”。解决办法是给内部 ListView 的 Scrollable 传一个自定义的 gestureRecognizers用我们自己的 HorizontalDragGestureRecognizer 覆盖默认识别器并在识别器里决定如何处理手势竞争。ListView( scrollDirection: Axis.horizontal, gestureRecognizers: FactoryOneSequenceGestureRecognizer{ FactoryOneSequenceGestureRecognizer( HorizontalDragGestureRecognizer.new, ), }, children: ..., )这里有个细节必须用 Factory 包一层不能直接放一个实例。因为 Flutter 每次进入手势竞技场时都需要一个全新的识别器实例避免上一次手势的状态残留影响下一次。我第一次写的时候直接把实例放进去结果报错提示“recognizer already in arena”排查了半天才反应过来。3. ScrollPosition与ScrollController程序化滑动背后的协作机制3.1 ScrollPosition一个带着大量上下文的位置对象ScrollPosition 是 Scrollable 的核心数据类它继承自 ViewportOffset本质上是一个 ChangeNotifier。也就是说它不止保存 pixels当前滚动像素值还保存了一整套滚动上下文viewportDimension视口在滚动主轴上的尺寸对于竖向列表就是高度横向列表就是宽度。minScrollExtent / maxScrollExtent可滚动范围的最小值和最大值也就是“到底能滚多远”。axisDirection滚动方向。physics当前生效的物理规则。context前面提到的 ScrollContext用来和 ScrollableState 保持通信。这些信息在滚动监听里非常重要。比如判断是否触底很多人会去拿 context 算实际上直接读 notification.metrics.maxScrollExtent 和 pixels 就够了。3.2 controller和position的attach/detach协作ScrollController 并不直接持有像素值它只是一个“管理容器”。一个 ScrollController 内部维护着一个 positions 列表它可以同时 attach 多个 ScrollPosition。这意味着什么两个 ListView 可以共用一个 ScrollController。你调用 controller.animateTo 时它会遍历 positions 列表让每一个已 attach 的 position 都执行滚动。这种机制在 NestedScrollView 这类需要“多个可滚动区域同步滚动”的场景里非常有用。但这也带来一个经典坑controller.offset 这个 getter一旦没有任何 position attach会直接抛异常。所以代码里只要有可能在列表尚未挂载、或者页面已经销毁后访问 offset就必须先判断 hasClients。if (_controller.hasClients) { final double current _controller.offset; }不要用 try-catch 包这种逻辑效率低且掩盖问题。另外ScrollController.dispose 会把所有 position 都 detach 掉如果你在某个异步网络请求回来后再去读 position.pixels就很可能撞上一个已经无效的对象。我早期在一个加载更多组件里就因此崩过后来养成了习惯任何异步回调里访问滚动位置前先判断 mounted 和 hasClients。3.3 animateTo与jumpTo动画滚动和瞬移滚动有本质区别animateTo 和 jumpTo 都能让列表滚到指定位置但底层逻辑完全不同。jumpTo 是“瞬间改变像素值”它直接调用 position.jumpTo实际上就是 forcePixels没有中间过程也不会触发插值动画。它会把当前正在进行的各种 Activity比如惯性滑动、用户拖动取消掉然后立即把 pixels 设为目标值。animateTo 则复杂很多。它会要求 position 进入一个 DrivenScrollActivity这个 Activity 内部用 AnimationController 按你给的 curve 逐帧把目标值算出来再映射到 pixels 上。所以 animateTo 天然是异步的duration 和 curve 直接决定用户观感。一个实践建议回到顶部这种操作UI 上最好用 animateTo给用户一个明确的“位置在移动”的反馈但如果页面切换后需要恢复滚动位置那更适合在视图首次创建时用 jumpTo避免动画闪烁。4. ScrollPhysics掌控滚动手感与回弹规则的关键链条4.1 ScrollPhysics到底控制哪几件事ScrollPhysics 是个抽象类它控制的不只是“能不能回弹”还有以下这几点shouldAcceptUserOffset是否允许用户通过手势改变滚动位置。NeverScrollableScrollPhysics 能禁用滚动核心就是这里返回 false。applyPhysicsToUserOffset用户手指拖动时是否要对本次位移做修正。大部分默认物理效果这里不处理但自定义物理可以在这里做阻尼、变速等效果。createBallisticSimulation松手后的惯性运动模拟器。这是决定“滑出去多远、多久停下来、会不会回弹”的核心方法。它返回一个 SimulationFlutter 会用这个模拟器驱动后续的每一帧。getMinScrollExtent / getMaxScrollExtent允许调整可滚动范围的边界。少数自定义场景会靠它实现“允许某一小段越界”的效果。4.2 Clamping与BouncingAndroid与iOS手感的本质差异我在做跨平台项目时经常被产品经理问“为什么同一个列表Android 和 iOS 手感不一样”。答案就在 ScrollPhysics 上对比项ClampingScrollPhysicsBouncingScrollPhysics默认平台Android、Windows 等iOS、macOS 触控板到达边界表现立刻卡住边缘有光晕效果可以继续拖出一段松手后弹性回弹惯性结束后超界直接截断回弹动画典型感受硬、停止感强软、有弹性常见实现ClampingScrollSimulationBouncingScrollSimulation这套默认选择是在 ScrollBehavior 里完成的。MaterialScrollBehavior 会根据当前平台 TargetPlatform 决定返回哪一种 physics。所以你只需要在 MaterialApp 里跑一遍同一段代码就能在 Android 上感受到“咔”的停止在 iOS 上感受到“弹一下”的效果。4.3 physics的链式传递与AlwaysScrollableScrollPhysics的用法ScrollPhysics 不是单例工作它是通过 parent 字段形成一条责任链的。看这段const AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics())当你查询某个行为时当前 physics 不处理就会把请求透传给 parent直到链尾。所以 AlwaysScrollableScrollPhysics 包在 ClampingScrollPhysics 外面意味着“无论内容是否足够长都允许用户拖动”而边界的手感仍然交给 Clamping 处理。反过来包就会破坏默认物理行为这个顺序不能折腾错。理解这一点对自定义 physics 也很关键。如果你直接继承 ScrollPhysics 并重写某个方法最好把不处理的逻辑交给 parent否则整条链会断。前面那个自定义“惯性变短”的 physics 示例就保留了 parent 传递。4.4 自己写一个“惯性变短”的physics我在运营活动页里做过一个横向卡片列表产品要求滑动后“停得快一点”不要像默认那样飞出去很远。于是我在 ScrollPhysics 上动了一点手脚核心是重写 createBallisticSimulation把默认模拟器的时间轴压缩一半class ShortInertiaScrollPhysics extends ScrollPhysics { const ShortInertiaScrollPhysics({super.parent}); override ShortInertiaScrollPhysics applyTo(ScrollPhysics? ancestor) { return ShortInertiaScrollPhysics(parent: buildParent(ancestor)); } override Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { final Simulation? source super.createBallisticSimulation(position, velocity); if (source null) return null; return _TimeScaledSimulation(source, 0.5); } } class _TimeScaledSimulation extends Simulation { final Simulation _inner; final double _factor; _TimeScaledSimulation(this._inner, this._factor); override bool get isDone _inner.isDone; override double x(double timeInSeconds) _inner.x(timeInSeconds * _factor); override double dx(double timeInSeconds) _inner.dx(timeInSeconds * _factor) * _factor; }这个例子只是为了展示“可以在哪一层切入”实际项目里如果要对惯性做定制更合理的做法是按运动学公式重新写一个 Simulation比如把速度衰减系数调大。但核心思路是一样的ScrollPhysics 给了你修改滚动手感的机会而且这个修改不会污染业务代码。5. 滚动监听实战用Notification与Controller优雅响应列表状态5.1 五类ScrollNotification别只会听Update很多人监听滚动只知道 ScrollUpdateNotification其实 Flutter 在 scroll_notification.dart 里定义了一整套通知类型ScrollStartNotification一次滚动开始scrollDelta 为 0可以在这里做“开始滑动”的状态切换。ScrollUpdateNotification滚动位置每次更新都会发scrollDelta 是本次的增量。ScrollEndNotification滚动结束包括用户松手后的惯性停止、动画滚动完成等。OverscrollNotification滚动超出边界时触发常用来实现“下拉刷新触发区”的视觉反馈。UserScrollNotification用户主动滚动或停止时触发用 direction 标记是 idle、forward 还是 reverse。用 NotificationListener 包裹列表就可以统一收到这些消息。5.2 只监听、不卡顿局部重建替代整页setState最常见、也最伤性能的写法是在滚动回调里直接 setState 整个页面。屏幕每滚一帧整个页面的 build 都跑一遍列表很容易掉帧。正确做法是让滚动位置的数据变化只驱动“需要变化的那一小块区域”。我的惯用方案是class _HomePageState extends StateHomePage { final ScrollController _controller ScrollController(); final ValueNotifierdouble _offsetNotifier ValueNotifier(0); override void initState() { super.initState(); _controller.addListener(() { _offsetNotifier.value _controller.offset; }); } override void dispose() { _controller.dispose(); _offsetNotifier.dispose(); super.dispose(); } override Widget build(BuildContext context) { return Stack( children: [ ListView.builder( controller: _controller, itemCount: 50, itemBuilder: (ctx, i) ListTile(title: Text(Item $i)), ), ValueListenableBuilderdouble( valueListenable: _offsetNotifier, builder: (ctx, offset, _) { return AnimatedOpacity( opacity: offset 400 ? 1 : 0, duration: const Duration(milliseconds: 120), child: FloatingActionButton( onPressed: () _controller.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ), child: const Icon(Icons.arrow_upward), ), ); }, ), ], ); } }这样滚动过程中只有 ValueListenableBuilder 里的小区域会重建列表本身不受影响。返回顶部按钮的显示和隐藏都是局部更新性能开销很小。5.3 回到顶部、吸顶导航这类常见需求的写法回到顶部用 controller.animateTo 即可前面已经演示。吸顶导航则是另一种典型需求随着列表向上滑动某个 header 从“普通位置”变成“固定吸顶”。这种场景我建议用 NotificationListener 读取滚动位置再配合一个 ValueNotifier 做状态切换千万不要在滚动回调里去 get 某个子 widget 的 renderObject 位置。NotificationListenerScrollUpdateNotification( onNotification: (notification) { if (notification.metrics.pixels headerHeight) { if (!_isSticky.value) _isSticky.value true; } else { if (_isSticky.value) _isSticky.value false; } return false; }, child: ..., )这里的 _isSticky 可以用 AnimatedContainer 等组件消费同样只重建 header 区域。判断阈值用的 headerHeight建议提前算好不要在每帧里去测量布局。6. 自己动手实现一个分页滑动组件从Scrollable到底层定制6.1 什么场景需要绕过PageView直接操作ScrollablePageView 功能很完整但它有个特点自带了一套比较强的分页物理行为定制时需要一层层覆盖。当你遇到下面这些情况时我会建议直接基于 Scrollable 自己搭你对翻页手势有特殊要求比如只允许“一屏一屏地滑”或者松手后必须吸附到最近的页面。你需要在滚动过程中给每个页面做缩放、透明度等联动动画PageView 还需要额外监听而 Scrollable 的 Viewport Sliver 方案更直接。你不想引入 PageView 自带的那套控制器和页面缓存策略希望用普通的 ScrollController 管理。6.2 最小可运行示例Scrollable Viewport SliverFillViewport下面这个组件是一个横向分页列表每一页占满整个视口宽度整体效果类似一个“自定义 PageView”。import package:flutter/material.dart; import package:flutter/physics.dart; class SnapPageScrollable extends StatefulWidget { const SnapPageScrollable({ super.key, required this.itemCount, required this.itemBuilder, }); final int itemCount; final IndexedWidgetBuilder itemBuilder; override StateSnapPageScrollable createState() _SnapPageScrollableState(); } class _SnapPageScrollableState extends StateSnapPageScrollable { final ScrollController _controller ScrollController(); override void dispose() { _controller.dispose(); super.dispose(); } override Widget build(BuildContext context) { return Scrollable( axisDirection: AxisDirection.right, controller: _controller, physics: const SnapPageScrollPhysics(), viewportBuilder: (BuildContext context, ViewportOffset position) { return Viewport( axisDirection: AxisDirection.right, offset: position, slivers: Widget[ SliverFillViewport( viewportFraction: 1.0, delegate: SliverChildBuilderDelegate( (context, index) widget.itemBuilder(context, index), childCount: widget.itemCount, ), ), ], ); }, ); } }这里有个很容易忽略的细节Viewport 必须手动传 axisDirection它的默认值是 AxisDirection.down。如果你忘了传就会出现一个横向 Scrollable 却按竖向排版的 Viewport效果完全对不上。这个坑我在第一次写的时候踩过。6.3 给自定义列表加上“翻页吸附”的物理效果接下来是吸附物理。核心思路是重写 createBallisticSimulation松手后根据当前位置计算离哪个页面整数最近把目标位置作为弹簧模拟的终点让列表像被吸过去一样停在正确页码。class SnapPageScrollPhysics extends ScrollPhysics { const SnapPageScrollPhysics({super.parent}); override SnapPageScrollPhysics applyTo(ScrollPhysics? ancestor) { return SnapPageScrollPhysics(parent: buildParent(ancestor)); } override Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { final double page (position.pixels / position.viewportDimension).roundToDouble(); final double target page * position.viewportDimension; if ((target - position.pixels).abs() 0.5) { return null; } return SpringSimulation( const SpringDescription(mass: 0.5, stiffness: 100, damping: 20), position.pixels, target, velocity, ); } }SpringSimulation 是 Flutter 在 physics.dart 里提供的弹簧模型模拟器比较适合这种“需要带一点弹性地吸附到目标点”的效果。如果希望更干脆可以换成 ClampingScrollSimulation。6.4 验证方式与调试技巧写完自定义组件建议直接在一个页面里快速验证Scaffold( body: SnapPageScrollable( itemCount: 5, itemBuilder: (ctx, i) ColoredBox( color: Colors.primaries[i % Colors.primaries.length], child: Center(child: Text(Page $i, style: const TextStyle(fontSize: 32))), ), ), )跑起来之后重点观察三件事第一滑动每一页是否都能稳定停在整数页码第二松手速度很快时是否会出现“跨页”或“停在两页之间”第三边界处是否有不合理的抖动。如果是做单测可以用 WidgetTester.startGesture 模拟拖动再用 pumpAndSettle 等滚动结束最后断言 position.pixels 是页面宽度的整数倍。7. 从Scrollable出发看滚动性能优化与高频面试题7.1 影响滚动流畅度的几个隐藏参数滚动性能问题大多不在 Scrollable 本身而在于它和 Sliver、渲染层之间的配合。以下几个参数是我排查卡顿问题时一定会检查的itemExtent / itemExtentBuilder如果列表项高度固定务必传。Sliver 布局时就可以跳过测量过程直接按固定尺寸排布能省掉大量 layout。cacheExtent控制视口外预构建多少内容。调大能让快速回滑时“内容出现得更及时”但代价是内存占用上升。不要盲目改到几百默认值对大部分列表已经够用。RepaintBoundary不要在列表外层随便包一层 RepaintBoundary因为列表本身每帧都在重绘隔离没用。真正该隔离的是列表之外的 header、悬浮按钮这类相对静止的组件。图片滚动列表里的网络图片一定要用缓存方案并且设置合理的内存缓存上限。图片解码本身非常耗时快速滑动时如果每张图都现场解必然掉帧。7.2 排查滚动卡顿多线程、渲染引擎与图片解码我在日常开发中用 VSCode 写 Flutter配合 FVM 管理多版本 SDK切不同项目很省事。排查滚动卡顿时主要看 DevTools Performance 里的 Build 和 Rasterize 时长。如果 Build 高通常是列表项构造太重优先查 itemExtent 和 Sliver如果 Rasterize 高通常和图片、着色器有关。这里要提到两个经常被问到的点一个是 Isolate滚动回调里如果出现复杂计算比如 JSON 解析、大数组处理应该丢到 compute 或 Isolate.run 里去避免占用 UI 线程另一个是 Impeller 渲染引擎它出来之后滚动时因为着色器编译导致的卡顿明显减少因为 Impeller 在运行时会预编译好所需的 shader不像旧的 Skia 路径那样边滚边编译。遇到滚动画面表现异常时切换到旧引擎对比一下能快速判断问题是不是出在渲染层。7.3 面试里关于Scrollable的常见问题快答问题回答要点ListView 和 SingleChildScrollView 本质区别在哪两者都基于 Scrollable区别在 viewportSingleChildScrollView 会对 child 完整布局ListView 用 Sliver 按需懒加载。Scrollable 有没有 StateState 在哪里有ScrollableState 负责滚动状态、ScrollPosition 生命周期和手势协调。两个 ListView 能共用一个 ScrollController 吗可以。Controller 内部维护多个 positionanimateTo/jumpTo 会作用于所有已 attach 的 position。怎么判断列表是否触底通过 ScrollUpdateNotification 的 metricspixels 达到 maxScrollExtent - epsilon 即认为触底。为什么 iOS 和 Android 列表手感不同ScrollBehavior 按平台返回不同 physicsiOS 用 BouncingScrollPhysicsAndroid 用 ClampingScrollPhysics。怎么禁用列表滚动physics 设为 NeverScrollableScrollPhysics。滚动时如何避免整页重建用 ValueListenableBuilder/RepaintBoundary 隔离需要更新的区域避免在滚动回调里 setState 全页面。7.4 我踩过的几个Scrollable相关坑第一个坑是访问 controller.offset 时没有判断 hasClients导致页面还在异步加载时就崩了。后来我封装了一个工具方法统一处理“无 position”的情况才踏实下来。第二个坑是横向 ListView 和外层手势冲突卡了好久才想到用 gestureRecognizers 覆盖默认识别器。第三个坑是自定义 Scrollable 时忘了给 Viewport 传 axisDirection结果内容方向完全反了查了半天才发现是方向参数没对齐。现在我在项目里遇到滚动异常第一反应就是先判断是 Scrollable / ScrollPosition 那一层出了问题还是 Viewport / Sliver 那一层出了问题或者是 physics 手感和预期不符。这样定位问题的速度比之前快很多。Scrollable 并不复杂它只是把“滚动”这件事拆得很干净只要你愿意顺着它拆开的边界走一遍后面再遇到什么奇怪的滚动问题都能很快找到出口。
返回列表