HarmonyOS7 下拉刷新和上拉加载别每页重写:通用 ScrollRefresh 组件实战

📅 2026/6/30 14:29:29
HarmonyOS7 下拉刷新和上拉加载别每页重写:通用 ScrollRefresh 组件实战
文章目录前言刷新状态不能只用 booleanPagedDataSource分页数据源封装自定义刷新动画通用 ScrollRefresh 组件业务层接入状态机帮你避开的坑一个小建议前言做 App 列表页下拉刷新和上拉加载更多是最基本的交互。HarmonyOS 原生的Refresh组件能用但自定义空间有限动画样式不好改状态管理也得自己拼。我在这个系列前面几篇里零零散散用过刷新逻辑但每次都是临时写的状态管理乱七八糟。这次花了两天时间把这块彻底搞成了一个通用组件以后直接用。刷新状态不能只用 boolean很多人写刷新逻辑就用一个isRefreshing: boolean但实际场景比这复杂得多。用户正在下拉还没松手、正在刷新中、正在加载更多、已经加载完了——这些状态的转换逻辑如果用散落的if-else来写迟早会出 bug。我用了一个显式的状态机来管理enumRefreshState{IDLEidle,// 静止状态PULLINGpulling,// 用户正在下拉REFRESHINGrefreshing,// 下拉刷新中LOADINGloading,// 上拉加载更多中NO_MOREnoMore// 没有更多数据了}状态转换规则很明确IDLE → PULLING → REFRESHING → IDLE下拉刷新链路IDLE → LOADING → IDLE或IDLE → LOADING → NO_MORE上拉加载链路。两条链路互斥不会同时走。PagedDataSource分页数据源封装刷新组件只管 UI 交互数据怎么来是另一件事。我封装了一个PagedDataSource来统一处理分页请求classPagedDataSourceT{privatecurrentPage:number1privatepageSize:number20privateallItems:T[][]privatefetcher:(page:number,size:number)PromiseT[]Stateitems:T[][]StatehasMore:booleantrueconstructor(fetcher:(page:number,size:number)PromiseT[],pageSize:number20){this.fetcherfetcherthis.pageSizepageSize}// 下拉刷新重置页码重新请求asyncrefresh():Promisevoid{this.currentPage1this.hasMoretruetry{constdataawaitthis.fetcher(1,this.pageSize)this.allItems[...data]this.items[...data]this.hasMoredata.lengththis.pageSize}catch(e){this.hasMoretrue// 失败后允许重试throwe}}// 上拉加载更多asyncloadMore():Promisevoid{if(!this.hasMore)returnthis.currentPagetry{constdataawaitthis.fetcher(this.currentPage,this.pageSize)this.allItems[...this.allItems,...data]this.items[...this.allItems]if(data.lengththis.pageSize){this.hasMorefalse}}catch(e){this.currentPage--// 失败回退页码this.hasMoretruethrowe}}// 外部更新单条数据updateItem(predicate:(item:T)boolean,updater:(item:T)T):void{this.itemsthis.items.map(itempredicate(item)?updater(item):item)}}这个类把分页逻辑全吃掉了业务层只需要传一个 fetch 函数进去。refresh和loadMore两个方法对应两种交互出错时页码自动回退不会出现跳页的问题。自定义刷新动画HarmonyOS 的Refresh组件那个默认的下拉圈圈说实话挺丑的而且位置不能调。我自己用LoadingProgress 旋转动画搞了一个BuilderRefreshingIndicator(progress:number){// progress 是 0~1 的下拉进度Row({space:8}){LoadingProgress().width(24).height(24).color(#4FC08D).opacity(progress0.3?1:progress/0.3)Text(progress1?松开刷新:下拉刷新).fontSize(14).fontColor(#999999)}.width(100%).height(60).justifyContent(FlexAlign.Center)}BuilderLoadingMoreIndicator(){Row({space:8}){LoadingProgress().width(20).height(20).color(#999999)Text(加载中...).fontSize(13).fontColor(#999999)}.width(100%).height(50).justifyContent(FlexAlign.Center)}BuilderNoMoreIndicator(){Text(— 没有更多了 —).fontSize(13).fontColor(#CCCCCC).width(100%).height(50).textAlign(TextAlign.Center)}刷新指示器有个细节progress 0.3才开始显示 LoadingProgress 图标下拉太少的时候不弹出来避免视觉干扰。通用 ScrollRefresh 组件把状态机、数据源、自定义动画组合在一起Componentexportstruct ScrollRefreshT{PropdataSource:PagedDataSourceTBuilderParamitemBuilder:(item:T,index:number)voidBuilderParamrefreshIndicator?:(progress:number)voidBuilderParamloadingMoreIndicator?:()voidBuilderParamnoMoreIndicator?:()voidBuilderParamemptyView?:()voidStateprivaterefreshState:RefreshStateRefreshState.IDLEStateprivatepullProgress:number0privatepullThreshold:number80// 下拉多少像素触发刷新build(){Stack(){Scroll(){Column(){// 顶部刷新指示器if(this.refreshStateRefreshState.PULLING||this.refreshStateRefreshState.REFRESHING){Column(){if(this.refreshIndicator){this.refreshIndicator(this.pullProgress)}else{this.RefreshingIndicator(this.pullProgress)}}.height(this.refreshStateRefreshState.REFRESHING?60:60*this.pullProgress).clip(true)}// 列表内容LazyForEach(this.dataSource.items,(item:T,index:number){Column(){this.itemBuilder(item,index)}})// 底部加载指示器if(this.refreshStateRefreshState.LOADING){if(this.loadingMoreIndicator){this.loadingMoreIndicator()}else{this.LoadingMoreIndicator()}}if(this.refreshStateRefreshState.NO_MOREthis.dataSource.items.length0){if(this.noMoreIndicator){this.noMoreIndicator()}else{this.NoMoreIndicator()}}}}.onScrollEdge((side:Edge){// 上拉加载更多滚到底部时触发if(sideEdge.Bottomthis.refreshStateRefreshState.IDLEthis.dataSource.hasMore){this.triggerLoadMore()}}).onTouch((event:TouchEvent){// 手动处理下拉刷新的手势this.handlePullGesture(event)})// 空状态if(this.dataSource.items.length0this.refreshStateRefreshState.IDLE){if(this.emptyView){this.emptyView()}}}}privatehandlePullGesture(event:TouchEvent):void{// 只处理顶部下拉if(this.refreshStateRefreshState.LOADING||this.refreshStateRefreshState.NO_MORE){return}switch(event.type){caseTouchType.Down:if(this.refreshStateRefreshState.IDLE){this.refreshStateRefreshState.PULLING}breakcaseTouchType.Move:if(this.refreshStateRefreshState.PULLING){constpullDistanceevent.touches[0].y-event.touches[0].ythis.pullProgressMath.min(1,Math.abs(pullDistance)/this.pullThreshold)}breakcaseTouchType.Up:if(this.refreshStateRefreshState.PULLING){if(this.pullProgress1){this.triggerRefresh()}else{this.refreshStateRefreshState.IDLEthis.pullProgress0}}break}}privateasynctriggerRefresh():Promisevoid{this.refreshStateRefreshState.REFRESHINGtry{awaitthis.dataSource.refresh()}catch(e){// 刷新失败可以弹个 Toast}this.refreshStatethis.dataSource.hasMore?RefreshState.IDLE:RefreshState.NO_MOREthis.pullProgress0}privateasynctriggerLoadMore():Promisevoid{this.refreshStateRefreshState.LOADINGtry{awaitthis.dataSource.loadMore()}catch(e){this.refreshStateRefreshState.IDLEreturn}this.refreshStatethis.dataSource.hasMore?RefreshState.IDLE:RefreshState.NO_MORE}}业务层接入用起来非常简洁。传一个PagedDataSource和一个列表项构建器进去就行Componentstruct ProductListPage{StatedataSource:PagedDataSourceProductnewPagedDataSource(async(page:number,size:number){constresponseawaitfetchProducts(page,size)returnresponse.data.list},20)aboutToAppear():void{this.dataSource.refresh()}build(){ScrollRefresh({dataSource:this.dataSource,itemBuilder:(item:Product,index:number){ProductCard({product:item})},emptyView:(){Column({space:12}){Image($r(app.media.ic_empty)).width(120).height(120)Text(暂无商品).fontSize(14).fontColor(#999999)}.width(100%).height(100%).justifyContent(FlexAlign.Center)}})}}状态机帮你避开的坑用状态机管理刷新逻辑后有几个原来容易踩的坑自动消失了重复触发——正在刷新时用户再拉一次状态不是IDLE直接忽略。上拉和下拉冲突——LOADING状态下禁止下拉REFRESHING状态下禁止上拉。加载完继续触发——NO_MORE状态下triggerLoadMore不会被调用。这些以前靠if (this.isLoading) return来挡散落各处现在全在状态机里统一管控。一个小建议如果你项目里有超过 3 个列表页强烈建议把这套东西封装好。每多一个列表页用原生Refresh拼逻辑就要多写一遍状态判断和错误处理。封装一次后面全是复制粘贴一行代码的事。另外PagedDataSource的fetcher函数建议统一走你项目的网络层在里面处理 token 过期、错误码之类的通用逻辑。这样分页组件就真的只管分页了职责清晰改起来也不慌。