搞这个自定义控件之前,我其实纠结过一阵子。列表页的下拉刷新和上拉加载更多,鸿蒙框架自己有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(负责滚动和数据展示) ├── ListItem(Header) ├── ForEach(真实数据列表) └── ListItem(Footer)这种层层嵌套看起来很常规,但每一个节点承担的任务是单一且清晰的。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。如果你漏掉置 false,header 会一直停在“正在刷新数据”的状态,列表也无法再次参与正常的滚动刷新手势。
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里检查isLoading,onReachEnd里检查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切换成WaterFlow,header 和 footer 的挂载方式会有所变化,但状态机的设计思路完全一致。先把这个通用框架跑通,后续做布局适配会顺很多。