
搞这个自定义控件之前我其实纠结过一阵子。列表页的下拉刷新和上拉加载更多鸿蒙框架自己有Refresh容器可以用看起来够省事。但产品上线的活儿干多了你就会发现默认样式的刷新提示和加载尾部根本经不起设计稿的反复打磨字色、高度、加载动画、文案“没有更多了”的位置每一样都会成为联调时被抠的细节。与其在每个页面里各写一套不如一次性封装成带 header 和 footer 的自定义控件既能把交互逻辑收拢到一处也能让业务页面只关心数据本身。这篇文章把我在鸿蒙应用里做这类控件的完整思路和代码骨架分享出来涉及组件选型、header/footer 的状态设计、下拉刷新和上拉加载的手势接管、防重入处理以及几个我实际踩过的坑。如果你正在准备做一个列表型业务页面或者单纯想把刷新加载这套交互沉淀成团队自己的公共组件这篇内容应该能帮你省下不少试错时间。1. 需求拆解这个控件的定位和设计思路1.1 什么场景下值得自定义下拉刷新和上拉加载控件很多开发者的第一反应是直接用系统Refresh把数据塞进List就完事了。这个思路没有错但等你的应用进入正式版本迭代就会发现几个很现实的问题。第一视觉定制受限。默认刷新指示器是一个固定大小的圆圈加载更多通常直接由一个LoadingProgress尾随在列表底部。产品想做成“下拉时文字从‘下拉刷新’变成‘松开立即刷新’松手后显示‘正在刷新数据’再附带一个转圈动画”这种情况下默认能力就很难优雅满足。第二footer 的状态要多态。正常加载时是转圈没有更多时要显示一条带灰线的“已经到底了”加载失败时还要给出“点击重试”的入口。这些状态如果依赖业务页面自己维护代码会很快变得散乱。第三交互状态要联动刷新的时候不能同时触发加载更多反之亦然。这些约束放在公共控件里处理一次比在每个页面里各自防御要靠谱得多。我这次的目标不是做一个花哨的东西而是把一个列表页最通用的交互模式沉淀下来顶部是一个可定制样式的刷新 header底部是一个可定制样式的加载 footer中间是数据列表区域。业务页面传入数据源和分页回调其余逻辑都交给控件维护。1.2 技术选型Refresh List ListItem 的组合在鸿蒙 ArkUI 里完成这个需求可选的方案大致有三种用Scroll组件包住所有子元素自己监听滚动偏移来算“是否触底”。用List组件渲染数据通过onScrollIndex或者onReachEnd判断触底位置。用Refresh容器包裹内容内部再套List或Scroll。我的选择是Refresh容器负责下拉手势内部用List渲染列表数据header 和 footer 都作为ListItem放在列表的开头和结尾。这样做的原因很直接List自带虚拟滚动和视图复用的能力当数据量涨到几百条时性能开销比Scroll里堆ForEach要稳定得多而Refresh容器把用户下拉的手势识别、弹性回弹这些底层交互吃掉了我不需要自己用onTouch去算手势距离和回弹动画。组件结构大体上是这样Refresh接管下拉刷新 └── List负责滚动和数据展示 ├── ListItemHeader ├── ForEach真实数据列表 └── ListItemFooter这种层层嵌套看起来很常规但每一个节点承担的任务是单一且清晰的。Refresh 只关心手势List 只关心滚动和复用Header 和 Footer 只关心自身状态展示。后续不管是你想换刷新动画还是想调整 footer 的交互修改范围都能控制在一个组件内部。1.3 从“页面里的代码”到“独立控件”的封装思路很多初次尝试封装的人会把所有状态都放在页面里导致一个页面的State变量一大堆。更合理的做法是把“刷新中”“加载中”“是否还有更多”这些状态收敛进子组件页面只负责提供数据源以及真正的数据请求动作。我设计这个控件时对外暴露的接口非常少dataSource列表数据数组由页面传入控件内部用Link做双向同步。onRefresh下拉刷新时触发的回调页面在这里重新请求第一页数据。onLoadMore触底加载时触发的回调页面在这里请求下一页数据。hasMore是否还有更多数据控制 footer 显示“上拉加载”还是“已经到底了”。业务页面不需要关心 header 当前是“下拉中”还是“刷新中”也不需要知道 footer 是不是正在转圈。这些状态被封装在控件内部页面的职责只剩下请求数据、更新dataSource、更新hasMore。这个接口宽度我在实际项目里用了很长时间足够覆盖绝大多数列表场景又不至于复杂到让人不想用。2. Header 与 Footer 的构建样式与状态分离2.1 Header 的两种核心状态下拉提示与刷新中Header 的位置在列表最顶部但它不能是静态的。用户手指往下拉时header 会跟着列表一起位移此时展示“下拉刷新”提示当下拉距离超过触发阈值松手后Refresh容器进入刷新状态header 需要立刻切换成“正在刷新数据”的视觉。我把 Header 单独抽成了一个Component接收一个isRefreshing参数来控制内容切换。别小看这层拆分它让 Header 的样式变化不污染列表主逻辑。Component export struct RefreshHeader { Prop isRefreshing: boolean false; build() { Row() { Blank() if (this.isRefreshing) { LoadingProgress() .width(20) .height(20) .color(#1890FF) Text(正在刷新数据...) .fontSize(14) .fontColor(#666666) .margin({ left: 8 }) } else { Text(下拉刷新) .fontSize(14) .fontColor(#999999) } Blank() } .width(100%) .height(60) .backgroundColor(#F7F8FA) } }这里我特意用Blank()把内容顶在中间而不是直接写一个居中的Text原因是当头部高度变化或者后续你加入图标时元素依然能自然保持在视觉中心。另外Prop是单向传递父组件刷新状态变化时会自动驱动这里重新渲染成本很低。有一些细节值得注意header 的背景色不要做得太突兀很多设计稿会把刷新头和列表项用同一底色然后靠文案和间距区分。高度建议不低于 50vp否则在窄屏手机上用户手指下拉时几乎看不到提示文字。2.2 Footer 的三态设计加载中、上拉加载、没有更多了Footer 比 Header 更复杂一点因为它至少需要面对三种状态正在加载下一页、等待用户上拉触发加载、所有数据加载完成。我习惯再加一个“加载失败”状态但为了保持控件核心逻辑清晰下面先以三态展开。加载中用转圈加文字表示等待加载时显示“上拉加载更多”这既是一种视觉提示也告诉用户当前手势操作还没有被禁用没有更多时显示一条居中的“已经没有更多了”这里通常还需要搭配一条 1vp 的浅色分割线让页面不至于因为结尾文案显得太空。Component export struct LoadMoreFooter { Prop isLoading: boolean false; Prop hasMore: boolean true; build() { Column() { if (this.hasMore) { Row() { Blank() if (this.isLoading) { LoadingProgress() .width(18) .height(18) .color(#999999) Text(正在加载更多...) .fontSize(13) .fontColor(#999999) .margin({ left: 6 }) } else { Text(上拉加载更多) .fontSize(13) .fontColor(#999999) } Blank() } .height(60) } else { Row() { Divider() .vertical(false) .color(#E8E8E8) Text(已经没有更多了) .fontSize(12) .fontColor(#BBBBBB) .margin({ left: 8, right: 8 }) Divider() .vertical(false) .color(#E8E8E8) } .height(60) .padding({ left: 16, right: 16 }) } } .width(100%) .backgroundColor(#FFFFFF) } }这里有个容易被忽略的逻辑点hasMore为 false 时就算用户继续上拉也不应该触发加载请求。而这个保护我放在控件主体的滚动回调里而不是让 footer 自己去阻止事件。footer 只负责“展示得对不对”拦截动作要交给上层的onReachEnd判断。2.3 用 Builder 保持 Header 和 Footer 的可扩展性如果每个团队的 header 样式都不同直接把组件代码复制改动也不是不行但维护成本会慢慢上升。更推荐的做法是把这个抽象能力再往上提一层用Builder参数让调用方传入自定义的 header/footer 构建函数。在 ArkUI 中Builder可以作为组件参数传递这样外层控件只提供“摆放位置和状态数据”具体长什么样完全由使用方决定。比如控件内部这样声明Component export struct RefreshLoadMoreList { BuilderParams headerBuilder: () void; BuilderParams footerBuilder: () void; }不过这个方案对很多项目来说可能过度设计了毕竟开通自定义样式的自由度意味着你必须定义清楚回调参数的约束否则业务方用起来容易迷路。实际项目中我倾向于在控件内部先固化一套默认 Header/Footer再预留一个customFooter布尔开关按需切换。这个度需要根据团队情况自己掌握我的经验是如果没有明确的三方主题需求不要把 Builder 参数暴露得过于随意。3. 核心交互实现下拉刷新与上拉加载的完整逻辑3.1 用 Refresh 容器打包下拉手势鸿蒙的Refresh组件是系统提供的现成容器它把用户下拉手势、回弹动画、刷新触发的阈值判断都封装好了。我们只需要把内容放进去并绑定一个刷新状态变量。这里我使用$$this.isRefreshing的双向绑定方式让Refresh容器内部状态和外部变量保持同步。当你把isRefreshing设为 true容器会主动进入刷新中的展示状态当数据请求完成你把它设为 false容器就会收回头部。Refresh({ refreshing: $$this.isRefreshing, offset: 100, friction: 80 }) { // 列表内容 } .onRefreshing(() { if (!this.isRefreshing) { this.isRefreshing true; this.onRefresh(); } })offset参数指触发刷新的下拉距离friction是阻尼系数。数值越大手指下拉时越费力。我给的是offset: 100、friction: 80这个组合在 6 英寸左右的屏幕上体感适中不会太灵敏也不会让人觉得拉不到触发点。你可以根据设计的预期再微调。有一个关键点必须记住onRefreshing回调是在用户手势已经触发刷新时调用的但它并不意味着你不需要自己维护isRefreshing。标准写法是进入回调后立刻把isRefreshing置为 true数据请求完成后置为 false。如果你漏掉置 falseheader 会一直停在“正在刷新数据”的状态列表也无法再次参与正常的滚动刷新手势。3.2 上拉加载更多onReachEnd 的实战细节List组件提供了onReachEnd事件用于感知滚动到底部。它的问题是触发时机比较粗糙——只要你滚动到列表末尾它就可能触发。而我真正需要的是“footer 出现在可视区域并且需要加载更多”的时候再发请求。所以在回调里我加了三层保护isLoading为 true 时不重复触发。isRefreshing为 true 时不触发。hasMore为 false 时不触发。.onReachEnd(() { if (this.isLoading || this.isRefreshing || !this.hasMore) { return; } this.isLoading true; this.onLoadMore(); })这样做的原因是onReachEnd在快速滚动时会多次回调如果你在回调里直接发起网络请求同一页数据可能被请求三五次。加锁保护是最基本的防御策略我甚至建议在页面层的网络请求库里再做一层重复请求过滤但那是后话了。另外一个容易踩的坑是onReachEnd和onScrollIndex的配合。有些开发者担心onReachEnd不够准会自己用onScrollIndex去判断最后一个索引是否等于数据长度减一。实际用下来onReachEnd已经足够满足绝大多数分页场景而且onScrollIndex在索引计算时还要考虑 header 和 footer 占位容易搞出错位。除非你的列表布局极其特殊否则优先信任onReachEnd。3.3 防重入与状态联动isRefreshing 和 isLoading 的协同下拉刷新和上拉加载这两个动作本质上不应该同时发生。虽然用户理论上不可能同时下拉又上拉但网络回调的时序是不确定的。如果刷新请求刚发出footer 又触发了加载更多两个回调同时改dataSource页面很可能出现数据错位或者重复追加。状态联动的核心是两条硬规则下拉刷新开始时如果正在加载更多先取消或者等它结束再执行刷新。触底加载时如果正在刷新中直接忽略本次加载请求。我的实现里这两个状态互相当作对方的开关。onRefreshing里检查isLoadingonReachEnd里检查isRefreshing任何一方在运行期间都不会被另一方插入。这也是我前面把三层保护放在onReachEnd里的原因。这里我想分享一个实际的体验在分页加载的列表页刷新和加载更多的“锁”最好放在公共控件内部不要让页面层去判断。页面层的开发者通常只关注“我请求数据、我赋值数组”让业务去理解锁的规则容易出错。控件内部把锁做好页面层就是无脑调用回调而已。3.4 数据模拟与真实接口对接的注意事项开发阶段没有后端接口时我们常用 setTimeout 模拟网络延迟来看刷新和加载动画。模拟数据的代码很简单但有几个对真实接口很有用的经验。第一刷新接口返回后通常要重置分页页码为 1把返回的数组直接替换dataSource而不是追加。第二加载更多接口返回后如果数据量小于 pageSize或者后端明确返回了“没有更多”的标记要立刻把hasMore置为 false同时把isLoading置为 false。第三异常处理网络失败时不能藏着错误不告诉用户最好把 footer 切换成“加载失败点击重试”的状态这个我在前面提过但真正实现时很多人因为嫌麻烦直接忽略了。我写过一版通用逻辑除了三态以外加入了一个loadFailed布尔值footer 在失败时显示“加载失败点击重试”点击后重新触发加载回调。这个状态一旦加上控件完整度会明显提升值得多花半小时。4. 完整示例代码从零搭建一个可运行的控件4.1 父页面数据源和业务状态的管理在实际页面中控件要接收业务数据并去执行请求。下面的Index页面是我用来演示的父组件。它维护了dataSource数组、page页码、hasMore是否更多以及模拟请求用的isRefreshing和isLoading。Entry Component struct Index { State dataSource: string[] []; State page: number 1; State hasMore: boolean true; State isRefreshing: boolean false; State isLoading: boolean false; private readonly pageSize: number 10; aboutToAppear() { this.loadFirstPage(); } loadFirstPage() { this.page 1; // 模拟刷新/首次加载 setTimeout(() { this.dataSource this.generateData(1); this.hasMore this.dataSource.length this.pageSize; this.isRefreshing false; }, 1500); } loadNextPage() { if (!this.hasMore) return; const nextPage this.page 1; setTimeout(() { const moreData this.generateData(nextPage); this.dataSource this.dataSource.concat(moreData); this.page nextPage; this.hasMore moreData.length this.pageSize; this.isLoading false; }, 1000); } generateData(page: number): string[] { const arr: string[] []; for (let i 0; i this.pageSize; i) { arr.push(第${page}页 - 数据项 ${i 1}); } return arr; } build() { Column() { RefreshLoadMoreList({ dataSource: this.dataSource, isRefreshing: this.isRefreshing, isLoading: this.isLoading, hasMore: this.hasMore, onRefresh: () { this.loadFirstPage(); }, onLoadMore: () { this.isLoading true; this.loadNextPage(); } }) } .width(100%) .height(100%) } }注意几个关键的细节。loadFirstPage里我重置了page为 1刷新后数据直接替换。loadNextPage里我把isLoading置为 true 的时机放在外层回调里而不是控件内部去 set这样便于页面在请求真正开始前控制 loading 展示。当然你也可以统一由控件内部管理我这里是为了演示更贴近接口请求的写法。4.2 子组件RefreshLoadMoreList 的封装与外部接口下面是这个自定义控件的核心主体。它对外接收数组、刷新状态、加载状态、更多标记以及两个回调。控件内部通过Refresh和List组织布局把 header、数据项、footer 依次放进列表。Component export struct RefreshLoadMoreList { Link dataSource: string[]; Link isRefreshing: boolean; Link isLoading: boolean; Link hasMore: boolean; onRefresh: () void () {}; onLoadMore: () void () {}; build() { Refresh({ refreshing: $$this.isRefreshing, offset: 100, friction: 80 }) { List() { ListItem() { RefreshHeader({ isRefreshing: this.isRefreshing }) } ForEach(this.dataSource, (item: string) { ListItem() { Text(item) .width(100%) .height(70) .fontSize(16) .fontColor(#333333) .backgroundColor(#FFFFFF) .padding({ left: 16, right: 16 }) } }, (item: string) item) ListItem() { LoadMoreFooter({ isLoading: this.isLoading, hasMore: this.hasMore }) } } .width(100%) .height(100%) .onReachEnd(() { if (this.isLoading || this.isRefreshing || !this.hasMore) { return; } this.isLoading true; this.onLoadMore(); }) } } }这里有几个地方值得展开说说。Refresh里的$$this.isRefreshing是双向同步的刷新动画结束或者用户下拉时内部状态会同步到组件变量上所以RefreshHeader才能根据同步结果切换文案。onScrollIndex我没有用因为onReachEnd在这个场景里已经足够。ForEach的第三个参数是 key generator我直接用item字符串本身作为 key。如果列表数据里有重复项这种做法会有问题生产环境建议给它加一个唯一 id。这里主要为了演示简洁。List的高度设为100%放在Refresh里面整个容器撑满父组件。如果你只给List一个自然高度而不是撑满父级列表会基于内容高度自动扩展这会导致onReachEnd根本无法出发因为列表没有滚动的概念。这一点尤其容易在嵌套Scroll的场景里出现我见过不少开发者把List嵌在Scroll里然后发现触底事件怎么都不触发原因就是滚动容器被外层Scroll接管了。4.3 如何把状态和界面分离避免控件越来越臃肿如果你的项目里有多个页面需要复用这套逻辑我强烈建议把“请求逻辑”和“UI 逻辑”再做一层拆分。父页面的loadFirstPage、loadNextPage可以抽象成一个ListViewModel类专门管理分页状态、数据存储和网络请求。控件只负责接收这个 ViewModel 暴露出的数据快照。这种拆分的好处是当你把相同的分页逻辑用到“消息列表”“商品列表”“关注列表”时每个页面只替换数据请求的 URL 和列表项的 UI状态管理完全复用。我曾经在三个业务页面上同时迁移这套逻辑痛苦的并不是写控件本身而是发现每个页面都有大量重复的分页 if-else。抽出来之后新增一个列表页的时间从一下午缩短到了半小时。5. 常见问题与排查实录5.1 列表内容不满一屏时onReachEnd 立刻触发怎么办这是所有分页列表都绕不开的边界问题。当首页数据不足一屏时列表根本不存在“滚动到底部”这个动作但onReachEnd依然会被触发一次。如果没加保护控件会连续请求好几页直到把屏幕填满或者hasMore变为 false。处理思路是在页面层增加自动补页逻辑。我在loadNextPage之后判断如果dataSource数量仍然小于一个屏幕能展示的估算量就继续触发下一页请求。核算方式相对粗暴用屏幕高度除以列表项的大致高度得到一个期望的可见条数。不推荐在控件内部做这件事因为每页数据量、item 高度都不确定交给页面层更灵活。loadNextPage() { // ...请求逻辑 setTimeout(() { // 数据追加后 if (this.dataSource.length 5 this.hasMore) { this.isLoading false; this.loadNextPage(); } }, 1000); }这里我以 5 条为占位判断实际项目中可以用ListItem高度和屏幕高度算一个更准确的值。但核心思路是保证首次进入页面时列表内容能撑满可视区域让用户有东西可滑动。5.2 为什么加载更多会被连续触发多次这个问题的原因通常是onReachEnd的回调里没有做防重入判断。我在前面代码里用了if (this.isLoading || this.isRefreshing || !this.hasMore) return;做保护。但还有一个隐藏场景当加载完成、isLoading被置为 false 的那一刻如果列表依然停留在底部onReachEnd会再次被回调。如果你把hasMore也置为了 false那没问题但如果返回数据恰好还够一页就会立刻触发下一次加载形成“连锁加载”。解决办法是给你的一次性加载过程加一个“结束标记”例如在请求完成后延迟一小段时间再把isLoading改为 false或者用setTimeout做节流。我个人的做法是在onReachEnd里额外加一个loadingMore的局部锁它和isLoading不同loadingMore只有在下次网络请求真正发起前才打开避免回调风暴。5.3 Refresh 的自定义 header 动画不好使使用Refresh容器时你可能会发现在自定义 header 里写的动画和下拉位置无法完美同步。这是因为Refresh容器内部对下拉偏移有自己的计算逻辑header 中单独写的translate或者scale动画未必与手势同步。我的建议是不要过度依赖自绘动画去模拟下拉位移。如果确实需要很复杂的刷新动画可以先关闭或者弱化Refresh自带的默认刷新视图把视觉重点放在业务数据区的变化上。简化的做法是 header 只展示静态提示文字和 loading真正的手势反馈由系统容器的弹性效果承担这样视觉上已经足够自然。5.4 列表数据量大时滚动有明显卡顿这个和控件本身关系不大但我会一起说。List虽然做了虚拟滚动优化但如果你的ListItem在渲染时包含大量ForEach嵌套或者每个 item 都创建了复杂的自定义组件滚动性能依然会受影响。我常用的性能优化手段有两条一是给List设置合理的cachedCount让它预加载当前视口附近的几个 item二是在ForEach中保证 key 的稳定性避免因 key 变化导致整项重建。另外图片类 item 要特别注意内存占用。鸿蒙上加载网络图片时优先使用官方图片组件并设置适当的尺寸裁剪尽量不在小 item 里放原始分辨率的大图。这类问题不是控件代码能解决的但一旦列表卡顿用户第一反应会怪这个刷新加载控件不好用所以周边性能也需要留意。5.5 快速上拉翻页时会出现页脚闪烁页脚闪烁一般是isLoading和hasMore同时短暂变化导致的。比如加载中时 footer 显示“正在加载更多”刚加载完又因为网络延迟显示“上拉加载更多”紧接着又触发了下一页请求。视觉上就是一个内容在闪跳。解决办法是给 footer 的状态切换增加一个过渡动画最简单的做法是使用animation修饰符让文字和 loading 的透明度、高度在切换时有平滑过渡。从产品体验上讲footer 在加载中最好保持固定高度不要因为文案长度变化导致列表底部上下跳动。个人经验分享这个控件我在实际项目中迭代了几轮最大的体会是不要为了“自定义”而自定义。系统的Refresh和列表能力已经解决了 80% 的通用需求真正需要自定义的往往只是头部文案、footer 文案、加载动画的视觉细节。如果你的视觉还原度要求不高直接用系统组件反而更省心。但一旦决定沉淀自定义控件就要把状态边界理清楚刷新、加载、还有没有更多这三者的关系最好在控件内部一次性锁死不要让业务页面去反复判断。页面层只回答“数据从哪来”控件层只回答“界面怎么响应”各自守住边界这个控件才会真正成为可复用的基础组件。最后再分享一个扩展思路目前这个控件主要面向垂直列表。如果你的业务里还需要横向分页、瀑布流布局可以把List切换成WaterFlowheader 和 footer 的挂载方式会有所变化但状态机的设计思路完全一致。先把这个通用框架跑通后续做布局适配会顺很多。