HarmonyOS 5.0下拉刷新实战:状态机、动画与请求竞态

发布时间:2026/11/2 2:51:41
HarmonyOS 5.0下拉刷新实战:状态机、动画与请求竞态 列表页下拉刷新松手之后箭头停在半空转圈图标一直转列表内容却纹丝不动——这个场景我在 HarmonyOS 5.0.0 的项目里遇到过不止一次。下拉刷新在纸上是一个组件的事落到工程里却是状态机、动画曲线、分页游标、请求竞态、嵌套滚动几件事缠在一起。这篇内容就围绕 Refresh 组件展开讲清楚在 5.0.0对应 API 12上怎么做出手感顺畅的下拉刷新动画以及怎么把重新加载内容这条链路做扎实翻页游标怎么重置、过期响应怎么丢弃、失败空态怎么给用户留一个重新加载的入口。适合已经能写 ArkTS 页面、但在下拉刷新上反复返工的开发者也适合刚开始接触声明式 UI 状态管理、想把这套思路迁移到别处的同学。下面所有代码都是按常见工程实践补全的具体到你的 SDK 版本上个别参数可能要在 IDE 里确认一下签名。1. 从箭头卡在中间的现场说起Refresh 组件在 5.0.0 上的能力边界先把最容易踩的坑摆出来。那天同事发来一张截图下拉到一半松手刷新头停在半空不动既没有回弹也没有转圈结束。我第一反应是动画曲线写错了查了半天发现是refreshing这个值根本没有回写到组件的内部控制逻辑里——他在onRefreshing回调里异步拿了数据赋值给了自己的State变量但构造Refresh的时候只传了refreshing: this.isRefreshing没有做双向绑定。组件内部认为用户还在刷新中自然就不会收起头部而外层变量在finally里被置成了false看起来逻辑没问题实际两边状态是不通的。这件事说明一个问题很多人把Refresh当成一个传个布尔值就完事的傻瓜组件实际上它托管的是手势识别 位移计算 阈值判定 状态广播这几件事至于刷新要做什么什么时候算结束结束之后头部怎么回去它只负责在你给对信号的前提下播放默认动效并不会替你做业务判断。1.1 Refresh 到底托管了哪些事情哪些必须你自己做我把Refresh的职责拆成两半一半是它管的一半是你必须管的分清楚之后调试思路会清晰很多。环节Refresh 组件负责业务代码必须负责手指按下与下拉位移采集是否位移到偏移量的换算含阻尼是受friction控制需要调参到达阈值后的状态切换是通过RefreshStatus广播需要监听并响应刷新中头部的停留与回弹是结束时必须把refreshing置回false刷新头部的视觉呈现默认样式或交给builder自定义时全部由你实现数据请求、分页重置、错误处理否全部由你实现看一下表格最后两行就很清楚了Refresh是壳业务是芯。壳子做得再顺滑芯子里请求没收尾、游标没重置用户看到的就是刷了跟没刷一样。构造一个最基础的版本大概长这样Entry Component struct FeedPage { State isRefreshing: boolean false; State items: FeedItem[] []; build() { Refresh({ refreshing: $$this.isRefreshing, offset: 72, friction: 0.6 }) { List({ space: 12 }) { ForEach(this.items, (item: FeedItem) { ListItem() { FeedCard({ data: item }) } }, (item: FeedItem) item.id) } .width(100%) .height(100%) .edgeEffect(EdgeEffect.None) } .onRefreshing(() { this.reloadData(); }) .width(100%) .height(100%) } private async reloadData(): Promisevoid { try { const list await this.repo.query(1, PAGE_SIZE); this.items list; } finally { this.isRefreshing false; } } }注意refreshing: $$this.isRefreshing里的$$这是双向绑定的写法少了它就会出现开头那种卡在半空的现象。另外.edgeEffect(EdgeEffect.None)也不是可选项——List 自己带边缘回弹效果和下拉刷新的位移叠在一起会出现拉一下弹两下的诡异手感。1.2 组件的默认样式够不够用什么时候该自己接管Refresh的默认样式是一个跟随位移的箭头加一行提示文字通过promptText可以改文案。这套默认样式适合列表页快速上线但它有两个明显的限制一是视觉上跟你的设计稿十有八九对不上二是动画只有箭头旋转加减速没法做自定义的图形变形、品牌色进度条、插画位移这类效果。所以判断标准很简单如果设计稿给的下拉头是标准样式里能拼出来的别折腾builder如果要做品牌化的动效直接上builder别在默认样式上硬改。我在第二个项目里就吃过亏为了改一个箭头的颜色去翻组件属性翻到底发现默认样式根本没开放这个能力白白浪费半天最后还是要走builder。还有一种更极端的情况是完全不用Refresh自己用手势加 List 的滚动偏移做下拉。这种方案的自由度最高但边界处理特别多内容不足一屏时的手势冲突、多点触控、和系统侧滑返回的优先级、无障碍焦点……除非你的下拉头要做成整页折叠这种颠覆性的交互否则不建议自研。Refresh已经把 90% 的脏活干掉了剩下的 10% 用builder和状态机就能补齐。2. offset 与 friction两个数字决定了手感的全部产品验收下拉刷新的时候很少有人会说这个状态机写得不对但几乎所有人都会说这个手感有点怪。手感的来源就是两个参数offset和friction。它们的默认值在大多数场景下能用但离好用还差一截。2.1 offset 不只是刷新头的高度很多人以为offset就是下拉头有多高所以随便填个 50、60。实际上它承担了双重职责一是触发刷新的阈值手指下拉超过这个距离后松手才会真正进入刷新二是刷新过程中头部停留的高度。这两个职责绑在一起就有一个矛盾阈值设小了用户轻轻一划就触发刷新容易误触阈值设大了刷新时头部占的空间太多遮住内容。我的经验值是根据下拉头的视觉高度来定允许比视觉高度略大 8 到 16vp。比如你的自定义头部视觉上是 60vp 高offset设 72 左右比较合适——多出来的部分是 越过阈值 的缓冲感用户能明确感知到自己已经拉够了。再提醒一点offset的单位在不同 SDK 版本上对参数类型的支持不完全一样有的版本只吃数字单位 vp较新的版本支持字符串形式比如72vp或者百分比。如果你在低版本上写字符串发现没生效换成纯数字就行。另外一个容易忽略的细节offset 要和你下拉头的容器高度匹配。自定义头部如果写死了 80vp 的高度而offset只给了 48刷新过程中头部会被裁掉一截看起来像是头被压扁了。反过来头部 60vp 而offset给了 100中间会空出一条 40vp 的空白同样难看。2.2 friction 的阻尼换算与调参思路friction是摩擦系数控制手指位移和实际下拉距离之间的换算关系。它的效果是越往下拉越费力这是模拟物理阻尼的常见做法——如果 1:1 跟手下拉会非常廉价用户一不小心就拉过头。调参的时候别盯着数值猜用下面这个流程试两轮基本就能定下来先把friction设成 1感受一下完全跟手的下拉记住这个位移量逐步降到 0.8、0.6、0.4观察手指移动同样的距离头部走了多远找一个你能接受的最大阻尼值然后上升一档因为真机上的手感会比模拟器更轻。我自己常用的区间是 0.6 到 0.8。超过 0.8 阻力偏大用户要拉很长一段才到阈值容易让人以为这个页面不支持下拉低于 0.5 又太容易触发列表稍微一滑就误刷。2.3 一套可以直接抄的参数组合下面这组参数是我在几个列表型页面里用过、验收反馈比较稳的配置可以直接拿去当起点Refresh({ refreshing: $$this.isRefreshing, offset: 72, // 头部视觉高度 60vp留 12vp 缓冲 friction: 0.65, // 中等偏轻的阻尼长列表场景友好 promptText: 下拉刷新 }) { // 列表内容 }如果你做的是内容型 App 的首页信息流friction可以再压低到 0.55因为用户习惯频繁刷新如果是设置页、表单页这种下拉刷新只是顺带能力的页面把friction提到 0.8避免误触。还有个小细节promptText在默认样式下是分阶段显示的下拉未达阈值、超过阈值、刷新中、刷新完成文案会自动切换。你只设置一个promptText的话各阶段的文案是组件内部维护的默认值。如果产品经理要求每个阶段文案都定制那就走builderpromptText帮不上忙。3. 用 builder 接管下拉头把静态提示换成随手指变化的动画默认样式之外builder是目前做自定义下拉头最省事的路子。它的思路是组件依然负责手势和状态你只负责交出长什么样的 UI至于这个 UI 什么时候出现、位移多少靠onOffsetChange把偏移量喂给你的状态变量。3.1 builder 的注入方式与数据流先看结构。builder接收的是一个Builder方法传参的写法在不同版本上略有差异我一般写成带括号调用的形式如果编译报错就换成不带括号的引用形式两种都试一下通常能过Entry Component struct FeedPage { State isRefreshing: boolean false; State pullOffset: number 0; // 当前偏移量由 onOffsetChange 喂进来 State phase: number 0; // 0 下拉中 / 1 松手可刷 / 2 刷新中 Builder refreshHeader() { Row({ space: 8 }) { // 一个随偏移量旋转的箭头 Image($r(app.media.ic_arrow_down)) .width(18) .height(18) .rotate({ angle: this.phase 2 ? 0 : this.arrowAngle() }) .animation({ duration: 220, curve: Curve.EaseOut }) Text(this.phaseText()) .fontSize(13) .fontColor($r(app.color.text_secondary)) if (this.phase 2) { LoadingProgress() .width(18) .height(18) .color($r(app.color.brand)) } } .width(100%) .height(72) .justifyContent(FlexAlign.Center) .alignItems(VerticalAlign.Center) } private arrowAngle(): number { // 偏移量从 0 到阈值映射成 0 到 180 度 const ratio Math.min(this.pullOffset / 72, 1); return ratio * 180; } private phaseText(): string { if (this.phase 2) return 正在刷新; if (this.phase 1) return 松手刷新; return 下拉刷新; } build() { Refresh({ refreshing: $$this.isRefreshing, offset: 72, friction: 0.65, builder: this.refreshHeader() }) { List() { /* ... */ } } .onOffsetChange((offset: number) { this.pullOffset offset; }) .onStateChange((status: RefreshStatus) { if (status RefreshStatus.Drag) { this.phase 0; } else if (status RefreshStatus.OverDrag) { this.phase 1; } else if (status RefreshStatus.Refresh) { this.phase 2; } }) } }这里的数据流是单向的组件采集位移 → onOffsetChange 回调 → 你的状态变量 → builder 里的 UI 重绘。整个过程不需要你去计算头部该往下移动多少组件会自己处理头部的容器位移你只负责头部内部的视觉变化箭头角度、文案、进度环。有一点要特别注意builder里的根容器高度要跟offset对齐。上面例子里Row的高度是 72offset也是 72刷新过程中头部刚好完整露出不会裁切。如果你的头部想做成越拉越高的效果可以在容器高度上加一个由pullOffset派生的动态值但要保证最大高度不超过offset的 1.5 倍左右否则松手回弹的时候会有明显的抽动感。3.2 用 onOffsetChange 驱动逐帧动画而不是主动画这里有个思路上的分歧我最初也走了弯路。一开始我的做法是在onStateChange里判断状态然后用animateTo主动播放一段动画——箭头从 0 转到 180再配合缩放。结果发现手感很僵手指往下拉的时候箭头不动等到越过阈值才突然啪地转过去中间没有任何过渡。正确的方式是把onOffsetChange当作动画的驱动源让视觉状态跟手指位移形成连续映射。上面代码里arrowAngle()就是这个思路偏移量每变化一点箭头角度就跟着变一点整个过程是连续的、跟手的。但连续映射也有代价——onOffsetChange是高频回调每帧都可能触发。如果你在里面直接改State变量会引发整个自定义头部的重绘头部里组件多了就会有掉帧风险。我的处理办法是只用一个数值变量承载偏移量所有派生视觉都用这个数值在 builder 内部现算而不是维护一堆State。派生计算是纯函数开销可以忽略真正贵的是状态变量的写入和依赖收集。// 推荐一个数值变量其余全部派生 State pullOffset: number 0; // 不推荐每个视觉元素一个状态变量 State arrowAngle: number 0; State iconScale: number 1; State textAlpha: number 0;如果你确实需要在某些节点做跳变式的动画比如松手瞬间箭头快速回正那就在onStateChange里用animateTo包裹一次状态变更让这段跳变有过渡。两种方式不冲突一个负责跟手一个负责状态切换。3.3 三条曲线的选择什么时候用哪条动画曲线这件事选错了会出现软塌塌或者硬邦邦两种极端。ArkUI 里常用的三条我列一下适用场景曲线特征适用位置Curve.EaseOut先快后慢收尾柔和箭头旋转、图标缩放这类短时动画Curves.springMotion(0.35, 0.9)弹性回弹有轻微过冲松手回弹、头部收起Curves.interpolatingSpring(0.4, 0.6)阻尼可调更接近物理刷新完成后进度环淡出松手回弹这段我特别推荐弹簧类曲线因为下拉刷新的交互本质就是拉一根橡皮筋用线性缓动会显得很假。springMotion的两个参数分别是阻尼比和刚度阻尼比靠近 1 的时候过冲最小靠近 0.3 的时候弹得最明显。下拉头这种场景我一般用 0.35 到 0.5 之间弹一下但不夸张。// 刷新完成头部收起 animateTo({ duration: 320, curve: Curves.springMotion(0.4, 0.85) }, () { this.phase 0; this.pullOffset 0; });最后提醒一句动画时长别超过 400ms。我见过把回弹做成 600ms 的用户会觉得这个 App 卡了。200 到 350ms 是大多数下拉刷新动效的舒适区。4. onStateChange 的五个状态刷新流程真正的控制中枢onStateChange是这套交互里信息量最大的回调但它也是最容易被误用的地方。很多人把它当成刷新触发的入口在里面直接发网络请求结果出现了重复请求、请求时机错乱、状态抖动一堆问题。4.1 五个状态到底在什么时刻触发先把状态流转理清楚。RefreshStatus一共五个值它们构成一条单向的时间线状态触发时机典型用途Inactive空闲未开始下拉或刷新彻底结束重置所有临时视觉状态Drag手指下拉中位移未达阈值提示文案切到下拉刷新OverDrag位移超过阈值松手就会触发刷新提示文案切到松手刷新可以加震动反馈Refresh松手后进入刷新中视觉切到进度状态但请求不在这里发Done刷新逻辑走完refreshing被置回 false播放收起动画关键在于Done到Inactive这一步refreshing被置回false之后组件才会从Done过渡到Inactive头部才真正收起。如果你的代码在finally里忘了重置或者重置的变量不是双向绑定的那个就会出现状态永远停在Done、头部永远悬着的现象——这跟我开头说的那个 bug 是同一个根因。4.2 为什么不要在 onStateChange 里直接发请求onStateChange里发请求有两个隐患。第一Drag和OverDrag之间可能来回切换用户拉过阈值又退回来再拉过去这个回调会频繁触发第二有些手势路径下状态可能重复广播。你在这个回调里写if (status RefreshStatus.Refresh) this.load()少数情况下会打出两次请求。官方给的触发入口是onRefreshing这个回调只在真正进入刷新时触发一次。所以职责划分应该是onStateChange只做视觉状态的切换onRefreshing只做一次性的数据加载。两者各司其职不要混用。.onStateChange((status: RefreshStatus) { switch (status) { case RefreshStatus.Drag: this.phase 0; break; case RefreshStatus.OverDrag: this.phase 1; break; case RefreshStatus.Refresh: this.phase 2; break; case RefreshStatus.Done: case RefreshStatus.Inactive: this.phase 0; this.pullOffset 0; break; } }) .onRefreshing(() { this.doReload(); // 只在这里发请求 })4.3 一个带防重入的状态机实现即便挪到了onRefreshing还是有一个边界要处理用户在刷新过程中反复下拉。虽然头部已经在刷新态理论上不会再触发一次但如果你的业务里还有点击顶部重新加载按钮这类入口两个入口并发打到同一个加载函数就会互相踩。我的做法是给加载函数套一层带序列号的重入保护。序列号的好处是既能挡住重复触发又能在并发时保证只认最新一次的结果private reqSeq: number 0; private loading: boolean false; private destroyed: boolean false; async doReload(): Promisevoid { if (this.loading) { // 已经有一次刷新在飞直接放弃这次避免把头部状态搞乱 this.isRefreshing false; return; } this.loading true; const seq this.reqSeq; try { const list await this.repo.query(1, PAGE_SIZE); if (seq ! this.reqSeq || this.destroyed) { return; // 过期响应直接丢弃 } this.items list; this.page 1; this.hasMore list.length PAGE_SIZE; } catch (err) { if (seq this.reqSeq !this.destroyed) { this.toast(刷新失败请稍后重试); // 注意失败时不要清空 items用户滚动位置会丢 } } finally { this.loading false; if (!this.destroyed) { this.isRefreshing false; // 无论成功失败都要收头 } } } aboutToDisappear(): void { this.destroyed true; }destroyed这个标志位是必须的。页面被切走或者被销毁之后异步回调仍然会执行这时候去改State变量轻则报警告重则整个组件树状态错乱。我在早期项目里就遇到过退出页面后回来发现刷新头卡住最后定位就是回调在销毁后写入了状态。5. 重新加载内容分页、幂等与空态兜底标题里支持重新加载内容这几个字很多人会理解成下拉之后调一次接口。但真实工程里重新加载要处理的问题比这个多得多刷新完之后上拉加载会不会拿到重复数据、网络差的时候刷新失败了页面显示什么、用户点了空态里的重新加载按钮走的是不是同一套逻辑。5.1 刷新成功必须重置的三样东西刷新成功之后你的页面状态里有三样东西必须一起归位少一样都会出问题。第一样是分页游标。下拉刷新拿到的是第一页数据如果游标还停在第三页用户接着上拉加载就会拉到第四页中间第二页直接丢了。所以刷新成功后一定要把游标重置成 1。第二样是还有更多的标记。这个标记通常根据返回条数判断返回条数等于PAGE_SIZE说明可能还有下一页小于则说明到底了。刷新之后这个标记要重新计算不能沿用刷新前的值。第三样是列表的滚动位置。刷新完成之后列表应该回到顶部而不是停在用户下拉时的位置。这一步不做的话用户会看到新数据插在上面、自己还停在中间某个位置体验非常割裂。private applyRefreshResult(list: FeedItem[]): void { this.items list; // 1. 整体替换不是 concat this.page 1; // 2. 游标归位 this.hasMore list.length PAGE_SIZE; // 3. 重算是否还有更多 this.scroller.scrollToIndex(0); // 4. 回到顶部 }这里有个细节刷新时数据要整体替换而不是合并。整体替换的写法能让ForEach的 key 生成器更高效地做差量更新视觉上不会出现闪烁。如果你用的是LazyForEach对应的是数据源上的notifyDataReload()这个我们放到第 7 节细说。5.2 请求竞态用序列号丢弃过期响应竞态这个问题在下拉刷新 上拉加载的组合里特别容易发生。典型场景用户下拉刷新触发了请求 A网络慢还没回来用户继续往上滑触发加载更多请求 B 发出去B 先回来了列表更新成第二页A 后回来直接把列表替换成第一批数据——用户看到自己滑到的位置内容突然全变了。解决办法就是我上面代码里的序列号方案。每次发起请求前自增一个计数器把当前值存到局部变量回调回来先比对不相等就直接丢弃。这个方案的额外好处是即便是同一个入口比如刷新被快速触发两次也能保证只有最后一次的结果生效。const seq this.reqSeq; const result await this.repo.query(page, PAGE_SIZE); if (seq ! this.reqSeq) { return; // 已经有更新的请求发出去了这次结果作废 }有人问为什么不用取消机制。ArkTS 的网络请求在某些封装里确实支持主动取消但业务代码里发起了才发现要取消的时机很难把握序列号更像是兜底。两层都做当然最好但序列号这一层是必须的因为它不依赖任何底层能力。5.3 失败与空态下的重新加载入口下拉刷新失败的时候最忌讳的做法是把列表清空然后显示一个大大的加载失败。用户本来看着内容刷新失败之后内容没了情绪会非常差。正确做法是保留旧数据用轻提示告知失败用户想再试就再下拉一次。真正需要重新加载按钮的是首屏加载失败和空数据这两种情况。这时候页面本来就没内容给一个明确的按钮比让用户猜下拉能不能刷新要好得多。这两种场景的按钮走的是同一套加载逻辑只是入口不同。Builder emptyOrErrorView() { Column({ space: 16 }) { Image(this.loadFailed ? $r(app.media.ic_error) : $r(app.media.ic_empty)) .width(96) .height(96) Text(this.loadFailed ? 内容加载失败 : 暂时还没有内容) .fontSize(15) .fontColor($r(app.color.text_secondary)) Button(重新加载) .fontSize(14) .height(36) .onClick(() { this.isRefreshing true; // 主动进入刷新态让视觉一致 this.doReload(); }) } .width(100%) .padding({ top: 80 }) }注意这个按钮里我把isRefreshing手动置成了true。目的是让用户点击之后能看到下拉头转起来形成确实在加载的反馈。如果只是按钮变灰用户会怀疑到底有没有反应。但这里有个坑手动置true之后如果加载函数因为重入保护提前 return 了isRefreshing就没人置回false头部会一直转。所以我的doReload在重入保护分支里也补了一句this.isRefreshing false这个细节非常容易被漏掉。6. 嵌套滚动List、Tabs、WaterFlow 一起上的排查链路单个列表加下拉刷新基本跑通就能用。一旦页面结构复杂起来——首页有 Tab 切换、Tab 里面是瀑布流、瀑布流外层还要下拉刷新——各种手势冲突就冒出来了。这一节记录三个我实际排查过的场景排查思路比结论更有参考价值。6.1 内容不足一屏时下拉没有反应这个问题第一次遇到的时候很困惑数据多的页面能正常下拉数据少的页面怎么拉都没反应。原因不复杂Refresh依赖内层滚动容器的滚动能力来区分下拉和滚动。如果列表内容总高度还不到一屏List 本身没有滚动区间手势事件的传递路径就不完整了。排查步骤如下一步一步排除先确认Refresh的直接子组件是不是可滚动容器。Refresh只能有一个子组件而且最好是 List、Scroll、Grid、WaterFlow 这类。如果你在外面包了一层 Column滚动能力就断了。再确认 List 的高度是不是撑满了。写成固定高度或者wrapContent都可能导致滚动区间异常应该是height(100%)。最后检查.edgeEffect()。前面提过要设成EdgeEffect.None但设完还要确认 List 本身没被别的地方限制了高度。修法很简单两个动作确保 List 直接作为Refresh的子组件并且高度撑满父容器。Refresh({ refreshing: $$this.isRefreshing, offset: 72 }) { List() { // 内容 } .width(100%) .height(100%) // 关键撑满保证滚动区间存在 .edgeEffect(EdgeEffect.None) } .width(100%) .height(100%)如果页面结构确实需要外层包一层 Column比如上面有搜索栏那就让 Column 撑满List 用layoutWeight(1)吃掉剩余空间别用百分比高度。6.2 上拉加载和下拉刷新互相打架第二个场景列表滑到底部自动加载更多用户在这个位置继续往下拉结果触发了上拉加载同时因为列表已经到底没有更多可滚手势被 Refresh 接管两个逻辑同时跑起来。表现是底部出现加载中的圈顶部也出现刷新头两个请求同时飞出去。根因在于 List 的触底回调和 Refresh 的阈值判定是两套独立的机制它们都不知道对方在干什么。解决办法是加一个共享的加载锁两个入口都先检查这把锁。private loading: boolean false; private async onReachBottom(): Promisevoid { if (this.loading || !this.hasMore || this.isRefreshing) { return; // 刷新中不触发加载更多 } this.loading true; try { const next this.page 1; const list await this.repo.query(next, PAGE_SIZE); if (list.length 0) { this.items this.items.concat(list); this.page next; this.hasMore list.length PAGE_SIZE; } else { this.hasMore false; } } finally { this.loading false; } }注意onReachBottom里除了loading还判断了isRefreshing这一条很关键。下拉刷新还没结束时列表数据马上要被整体替换这时候再往里 concat 旧分页的数据就会出现新数据下面跟着旧数据的错乱。还有个小细节onReachBottom在某些情况下会连续触发多次列表内容高度变化时会重新判定触底所以loading这个锁必须在函数一进来就上不能等请求发出去再上。6.3 横向手势和下拉手势的仲裁第三个场景是 Tabs 里嵌套列表。Tabs 本身支持左右滑动切页列表外层又有下拉刷新。用户斜着往下划的时候Tabs 会认为这是横向切换Refresh 会认为这是下拉两边抢手势结果两边都没响应完整。这类冲突的排查链路是固定的先判断冲突方向。是横向 vs 纵向还是纵向 vs 纵向。横向和纵向的冲突好解决方向差足够大时系统会自己分配纵向与纵向的冲突比如内层 Scroll 和外层 Refresh才是真麻烦。用onGestureJudgeBegin或者给 Tabs 关掉滑动手势看现象是否消失。如果消失了说明确实是 Tabs 抢的。确定取舍。如果 Tab 页面数量不多、有明确的 Tab 头我一般直接关掉 Tabs 的滑动手势.scrollable(false)让纵向滚动和下拉刷新优先。用户切页靠点 Tab 头心智负担更低。如果产品坚持要保留横滑那就得靠手势方向判定在onGestureJudgeBegin里根据event.angle或者偏移量的横向纵向比值来决定归属代码会复杂不少。我的经验是首页这种高频页面下拉刷新的优先级高于横滑切页。宁可让用户多点一下 Tab 头也别让下拉刷新变得不跟手。这个取舍需要和产品提前对齐别等做完了才发现方向不对。7. 性能与细节打磨那些验收前一晚才会暴露的问题功能跑通只是及格线真正让下拉刷新用起来舒服的是最后这层打磨。下面几条都是我在真机上遇到的模拟器上往往复现不出来。7.1 onOffsetChange 是每帧回调别在里面做正经事前面提过这个回调高频触发但我想再强调一下它的破坏力。我在一个页面里图省事在onOffsetChange里打了一行日志想观察位移曲线。结果真机上一拉控制台刷屏帧率直接从 60 掉到 30 多。日记的字符串拼接在每帧执行几十次开销比想象中大得多。反面的例子还有在onOffsetChange里做数据过滤、做状态判断、甚至调用接口。这些都绝对不行。这个回调里能做的事只有一件把偏移量赋给一个数值变量。.onOffsetChange((offset: number) { this.pullOffset offset; // 就这一句别的什么都别加 })同理builder里的派生计算也要控制复杂度。arrowAngle()这种一次除法的没问题如果你在里面做数组遍历、字符串格式化那就该提前算好存起来别每帧算一次。7.2 LazyForEach 刷新后的位置与闪烁数据量大的列表要用LazyForEach但LazyForEach配下拉刷新有两个坑。第一个坑是刷新后列表跳到顶部但闪一下。原因是先notifyDataReload()再scrollToIndex(0)中间有一帧列表还停在旧位置视觉上就是先显示旧数据、再跳上去、再显示新数据。修法是先滚到顶部再重载数据或者用scrollToIndex(0, false)关闭滚动动画。第二个坑是数据源实现不正确导致刷新后 item 不更新。LazyForEach依赖getData()和 key 生成器来做差量如果你的 key 是数组下标刷新后新老数据下标相同但内容不同组件会认为没变化直接复用旧 UI。key 必须用数据本身的唯一 ID。class FeedDataSource implements IDataSource { private data: FeedItem[] []; private listeners: DataChangeListener[] []; totalCount(): number { return this.data.length; } getData(index: number): FeedItem { return this.data[index]; } registerDataChangeListener(listener: DataChangeListener): void { this.listeners.push(listener); } unregisterDataChangeListener(listener: DataChangeListener): void { const idx this.listeners.indexOf(listener); if (idx 0) this.listeners.splice(idx, 1); } reloadAll(newData: FeedItem[]): void { this.data newData; this.listeners.forEach(l l.onDataReloaded()); } }onDataReloaded()会让整个列表重建可见区域这是刷新场景最合适的通知方式。别用onDataAdd一条条加那样会触发 N 次更新。7.3 深色模式、动效时长与无障碍最后是一些容易被忽略但影响感知的细节。深色模式下下拉头里的图标和文字颜色必须走资源引用$r(app.color.xxx)不能写死十六进制。我见过刷新头在深色模式下变成一片白底的情况就是因为背景色写死了。动效时长前面说过200 到 350ms 之间。还有一个约定俗成的细节刷新完成的收起动画要比下拉时的展开动画更快一点展开 300ms、收起 200ms这样轻快的感觉更明显。无障碍这块下拉刷新本身是手势操作对使用读屏的用户不友好。建议在页面里额外交代一个可点击的刷新入口或者在Refresh的头部加上accessibilityText让读屏能够播报当前状态。这个工作量不大但能覆盖一部分真实用户。还有键盘。如果页面里有输入框键盘弹起时页面高度会变化如果你在onOffsetChange里存了绝对偏移量做布局可能会出现刷新头位置错位。处理办法是键盘弹起时直接重置pullOffset为 0别让这个值在窗口变化时保留。我在实际项目里最后悔的一件事是最开始没有把下拉头做成独立的Builder组件而是全部堆在页面里。后来三个列表页都要用同一套头部样式复制粘贴了三份改一个颜色要改三个地方。如果你现在就打算做多个带下拉刷新的页面建议第一天就把头部抽成公共组件通过Prop接收偏移量和阶段状态页面只负责传值。这个重构越晚做代价越大。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询