鸿蒙自定义下拉刷新/上拉加载更多控件

鸿蒙自定义下拉刷新/上拉加载更多控件

技术博客 admin 155 浏览

一、写在前面

鸿蒙开发,如火如荼,随着第一版本的上线,节奏暂时可以放慢一点,趁现在回过头对之前的东西优化一下,之前的列表刷新控件不太符合设计要求,因此在此做下优化,重写了一个。

二、目标

这次的开发本质是对之前的功能在鸿蒙上做一下实现,贴个图展示一下效果:

功能很简单,就是下拉刷新,上拉加载更多,同时加载完数据后显示没有更多的布局

三、开始吧

1、刷新

刷新使用的是官方ReFresh控件,没什么好说的,我们只需要监听onRefreshing回调即可:

2、加载更多

滑动初始状态

我们都知道,加载更多操作是由手势拖动触发的,所以这个肯定是需要今天空间的onTouch事件了,然后我们考虑下页面结构,底部的加载更多布局和上面的列表可以是Column, Y轴上下结构,也可以用Stack,Z轴上下覆盖,然后给底部的加载更多布局一个初始偏移量,移出屏幕。

kotlin
复制代码
// "加载更多"布局的偏移量 外部无需关心 @State private dragY: number = this.loadMoreViewHeight Row() { this.LoadFooter() }.width("100%") .height(this.loadMoreViewHeight) .translate({ y: this.dragY })

拖动状态

TouchType.Down:

ini
复制代码
// 拖动过程中,y轴的起始拖动位置, 用于计算上面的dragY private yStart: number = 0; // list触摸事件起始纵坐标 this.yStart = event.touches[0].y;

此处制作一件事,就是标记手指按下的y轴起始坐标。

TouchType.Move:

这是整个控件的核心位置,处理拖动的主要逻辑,先贴下这部分的完整代码:

kotlin
复制代码
// 标记当前位置的坐标 const yEnd = event.touches[0].y; // 手指离开屏幕的纵坐标 // 已经滑到底部了 if (this.scroller.isAtEnd() && !this.isLoading) { // 展示底部局部 this.isShowLoadingWhenTouch = true // 计算加载更多布局的偏移量;*0.6是用来调手感的, const changeY = (yEnd - this.yStart) * 0.6 + this.loadMoreViewHeight // 偏移量边界处理 this.dragY = changeY < -this.loadMoreViewHeight ? -this.loadMoreViewHeight : changeY console.debug(`this.dragY = ${this.dragY}`) // 判断上滑,且list跟随手势滑动 if (yEnd <= this.yStart) { // 标记手势Up时,是否要回调加载更多 if (changeY < this.loadMoreViewHeight) { this.isLoadMoreWhenUp = true } else { this.isLoadMoreWhenUp = false } } else { // 如果往下滑,且超过了起始位置,则置回起始位置,同时更新手势的起始位置 if (this.dragY >= this.loadMoreViewHeight) { this.dragY = this.loadMoreViewHeight this.isLoadMoreWhenUp = false this.yStart = yEnd } else { // 同上面yEnd <= this.yStart的逻辑 if (changeY < this.loadMoreViewHeight) { this.isLoadMoreWhenUp = true } else { this.isLoadMoreWhenUp = false } } } } else { // 没到底部 就置回起始状态 this.isShowLoadingWhenTouch = false this.yStart = yEnd if (!this.scroller.isAtEnd()) { animateTo({ duration: 100, curve: Curve.EaseOut, playMode: PlayMode.Normal, }, () => { this.dragY = this.loadMoreViewHeight }) } }

简单说下逻辑,先判断是不是滑到最底下,这个可以使用List的scroller.isAtEnd() 来获取,this.isLoading是当前加载中的标记,防止多次加载:

kotlin
复制代码
if (this.scroller.isAtEnd() && !this.isLoading)

以此为界限,实时计算当前的偏移量,同时做边界处理:

kotlin
复制代码
// 展示底部局部 this.isShowLoadingWhenTouch = true // 计算加载更多布局的偏移量;*0.6是用来调手感的, const changeY = (yEnd - this.yStart) * 0.6 + this.loadMoreViewHeight // 偏移量边界处理 this.dragY = changeY < -this.loadMoreViewHeight ? -this.loadMoreViewHeight : changeY

根据yEnd <= this.yStart来判断是上滑还是下滑,上滑时这个偏移量小于起始位置(位置在起始位置的上面,this.loadMoreViewHeight是初始值) ,则做个标记,用于手势抬起时做加载回调;

下滑时,如果到达初始位置,则更新起始位置,如果没有,则也要判断是否达到可触发回调的逻辑,因为拖动过程中,手指不离开屏幕,可以来回滑:

kotlin
复制代码
if (yEnd <= this.yStart) { // 标记手势Up时,是否要回调加载更多 if (changeY < this.loadMoreViewHeight) { this.isLoadMoreWhenUp = true } else { this.isLoadMoreWhenUp = false } } else { // 如果往下滑,且超过了起始位置,则置回起始位置,同时更新手势的起始位置 if (this.dragY >= this.loadMoreViewHeight) { this.dragY = this.loadMoreViewHeight this.isLoadMoreWhenUp = false this.yStart = yEnd } else { // 同上面yEnd <= this.yStart的逻辑 if (changeY < this.loadMoreViewHeight) { this.isLoadMoreWhenUp = true } else { this.isLoadMoreWhenUp = false } } }

TouchType.Up:

完整代码:

kotlin
复制代码
// 手指up时,如果需要回调事件就回调,同时加载中布局全部展示,否则置回起始状态 if (this.isLoadMoreWhenUp) { if (!this.isRefreshing && !this.isLoading && !this.isNoMoreData) { this.loadMore() this.isLoading = true animateTo({ duration: 100, curve: Curve.EaseOut, playMode: PlayMode.Normal, }, () => { this.dragY = 0 }) } else { this.dragY = this.loadMoreViewHeight this.isShowLoadingWhenTouch = false } } else { animateTo({ duration: 100, curve: Curve.EaseOut, playMode: PlayMode.Normal, onFinish: () => { this.isShowLoadingWhenTouch = false } }, () => { this.dragY = this.loadMoreViewHeight }) }

这部分就是根据之前标记的isLoadMoreWhenUp字段,同时当前没有在加载中状态来触发回调(同时展示完整的加载中布局),其他情况都是动画变成起始状态。

3、惯性滚动

这里有一种其他状态,就是惯性滚动,快速滑动屏幕的过程中,由于惯性,列表到底后,会继续带有回弹效果地滚动,这种状态我们也需要响应逻辑,不然每次到达底部后,加载下一页都需要手指再拖动一下,体验不好,贴下代码:

kotlin
复制代码
.onScroll((scrollOffset: number, scrollState: ScrollState) => { this.onScroll(scrollOffset, scrollState) // 惯性滚动时 达到底部后 回弹过程中 if (scrollState === ScrollState.Fling) { // 累加持续滚动的距离 this.yStartFling += scrollOffset // 已经滑到底部了 且不是在加载中 if (this.scroller.isAtEnd() && !this.isLoading) { // 更改标记 让“加载中”布局展示 this.isShowLoadingWhenTouch = true // 计算当前“加载中”布局的偏移量,this.dragY为当前的偏移量;之所减去yStartFling是因为偏移量往下是正方向,页面往上拖scrollState为正方向,减去yStartFling才是最终的偏移量 const changeY = this.dragY - this.yStartFling // 赋值偏移量,边界处理,值为正负的loadMoreViewHeight this.dragY = changeY > this.loadMoreViewHeight ? this.loadMoreViewHeight : changeY < -this.loadMoreViewHeight ? -this.loadMoreViewHeight : changeY // 判断上滑,且list跟随手势滑动 if (this.yStartFling > 0) { // 如果惯性滚动的距离小于0 if (changeY < 0) { // 判断不是在加载更多中 if (!this.isLoading && !this.isNoMoreData) { // 执行加载更多的回调 this.loadMore() this.isLoading = true // 执行动画 让整个加载布局全部展示 animateTo({ duration: 100, curve: Curve.EaseOut, playMode: PlayMode.Normal, }, () => { this.dragY = 0 }) } } } } else { // 隐藏加载更多的布局 this.isShowLoadingWhenTouch = false this.yStartFling = 0 this.dragY = this.loadMoreViewHeight } } })

如代码所示,列表滚动过程中是有onScroll((scrollOffset: number, scrollState: ScrollState) 的回调的(12中环城onDidScroll() 了),第一个参数是位移值,第二个是滚动的状态(ScrollState),关于ScrollState有三种状态:

  • Idle:空闲状态。使用控制器提供的方法控制滚动时触发,拖动滚动条滚动时触发
  • Scroll:滚动状态。使用手指拖动List滚动时触发。
  • Fling:惯性滚动状态。快速划动松手后进行惯性滚动和划动到边缘回弹时触发。

所以此处我们使用Fling状态来过滤时间即可。

这里注意下scrollOffset,再滚动过程中,往上拖动列表惯性滚动时,也就是列表往下滚时,scrollOffset是负数,所以这里const changeY = this.dragY - this.yStartFling, 需要减去yStartFling,才是真正的偏移量。

整个惯性滚动,我们只需要判断是触发加载更多的回调即可(完全展示加载更多的布局),其它都是置回初始位置。

4、控制器

控件内部的事件只有触发回调,什么时候结束加载中的状态,是由外部控制的,所以这里将时间协程控制器的形式:

typescript
复制代码
export class CppPullToRefreshController { private cppRefreshView: CppPullToRefresh | undefined bind(cppRefreshView: CppPullToRefresh) { this.cppRefreshView = cppRefreshView } needLoadMore(value: boolean) { this.cppRefreshView?.needLoadMore(value) } finishLoadMore() { this.cppRefreshView?.finishLoadMore() } finishRefresh() { this.cppRefreshView?.finishRefresh() } // 没有更多 noMoreData(value: boolean) { this.cppRefreshView?.noMoreData(value) } }
  • bind

绑定控制前和页面的关系

  • needLoadMore

是否需要加载更多,有的页面不需要加载更多,可以直接通过这个函数设置

  • finishLoadMore

完成加载更多

  • finishRefresh

完成刷新

  • noMoreData

没有更多数据,展示后缀,没有更多数据的时候,上拉是不会触发加载更多事件的。

绑定函数是在控件的aboutToAppear函数中调用,做绑定关系,viewController对象由外部控件设置即可。

kotlin
复制代码
aboutToAppear(): void { // 绑定Controller this.viewController?.bind(this) }

下面的代码是放在接口加载列表回调的地方,noMoreData是在当前列表有数据,只是当前这一页没有数据的时候,才设置为true,不然空态页有加载更多也不合理。

needLoadMore只要当前页面没有数据,就设置false。

kotlin
复制代码
if (value?.data.length === 0) { // 当前页没有数据 且整个列表有数据 则展示没有更多块 this.viewController.noMoreData(!this.isEmpty) } else { this.viewController.noMoreData(false) } if(this.isEmpty){ this.viewController.needLoadMore(false) }else{ this.viewController.needLoadMore(true) }

好了,整个控件就写完了,贴下完整代码:

kotlin
复制代码
import { LoadingMoreLayout, RefreshHeaderLayout } from '@ohos/uicomponents/Index' // 列表刷新 加载更多的控件 @Component export struct CppPullToRefresh { // CppPullToRefresh的控制器 控制 viewController: CppPullToRefreshController | undefined // 外部传入布局的内容,注意这里是填充List ,所以外部传入的要用listItem 或者ListGroupItem包裹 @BuilderParam body?: () => void // 刷新的回调 reFresh = () => { } // 加载更多的回调 loadMore = () => { } onScroll = (scrollOffset: number, scrollState: ScrollState) => { } //////////// 以上需要外部传入 //////////////// // 是否需要加载更多的状态,可以直接设置,也可以通过viewController设置 // PS:直接设置只有第一次,实时的需要通过viewController设置 @State isNeedLoadMore: boolean = true // 是否要展示"没有更多"提示的布局 默认是要的 @State isNeedNoMoreData: boolean = true // "没有更多"展示的提示 // PS:isNeedNoMoreData为true时,isNoMoreData才会生效 @State noMoreDataText: string = '没有更多数据' //////////// 以上也是外部传入,不是必须 //////////////// //////////// 以下参数外部无需关注 //////////////// // 列表滚动的scroller private scroller: Scroller = new Scroller() // 底部加载更多布局的高度 private loadMoreViewHeight: number = 70; // list触摸事件起始纵坐标 // 是否是刷新的状态 @State isRefreshing: boolean = false // 是否是加载中 @State private isLoading: boolean = false // 是否是"没有更多"的状态 // PS:isNeedNoMoreData为true时,isNoMoreData才会生效 @State private isNoMoreData: boolean = false // "加载更多"布局的偏移量 外部无需关心 @State private dragY: number = this.loadMoreViewHeight // 拖动过程中是否展示加载更多布局 @State private isShowLoadingWhenTouch: boolean = false // 拖动过程中,y轴的起始拖动位置, 用于计算上面的dragY private yStart: number = 0; // list触摸事件起始纵坐标 // 拖动过程后,手指起来时,是不是要触发加载更多的标记 private isLoadMoreWhenUp: boolean = false; // list惯性滚动起始累加值 也是判断是否要加载更多的判断 private yStartFling: number = 0; aboutToAppear(): void { // 绑定Controller this.viewController?.bind(this) } build() { Stack({ alignContent: Alignment.Bottom }) { Refresh({ refreshing: $$this.isRefreshing, builder: this.RefreshHeader() }) { List({ scroller: this.scroller }) { if (this.body) { this.body() } else { ListItem() { this.buildBody() } } if (this.isNeedNoMoreData && this.isNoMoreData) { ListItem() { Text(this.noMoreDataText) .fontColor('#FF5A5A5A') .fontSize('12fp') .textAlign(TextAlign.Center) .height(40) .width("100%") }.width("100%") } }.onDidScroll((scrollOffset: number, scrollState: ScrollState) => { this.onScroll(scrollOffset, scrollState) // 惯性滚动时 达到底部后 回弹过程中 if (scrollState === ScrollState.Fling) { // 累加持续滚动的距离 this.yStartFling += scrollOffset // 已经滑到底部了 且不是在加载中 if (this.scroller.isAtEnd() && !this.isLoading) { // 更改标记 让“加载中”布局展示 this.isShowLoadingWhenTouch = true // 计算当前“加载中”布局的偏移量,this.dragY为当前的偏移量;之所减去yStartFling是因为偏移量往下是正方向,页面往上拖scrollState为正方向,减去yStartFling才是最终的偏移量 const changeY = this.dragY - this.yStartFling // 赋值偏移量,边界处理,值为正负的loadMoreViewHeight this.dragY = changeY > this.loadMoreViewHeight ? this.loadMoreViewHeight : changeY < -this.loadMoreViewHeight ? -this.loadMoreViewHeight : changeY // 判断上滑,且list跟随手势滑动 if (this.yStartFling > 0) { // 如果惯性滚动的距离小于0 if (changeY < 0) { // 判断不是在加载更多中 if (!this.isLoading && !this.isNoMoreData) { // 执行加载更多的回调 this.loadMore() this.isLoading = true // 执行动画 让整个加载布局全部展示 animateTo({ duration: 100, curve: Curve.EaseOut, playMode: PlayMode.Normal, }, () => { this.dragY = 0 }) } } } } else { // 隐藏加载更多的布局 this.isShowLoadingWhenTouch = false this.yStartFling = 0 this.dragY = this.loadMoreViewHeight } } }) .scrollBar(BarState.Off) .width("100%") .height("100%") }.onRefreshing(() => { // if(!this.isRefreshing){ this.reFresh() this.isNoMoreData = false // } }).width("100%") .height("100%") // 底部加载更多的布局 if (this.isShowLoadingWhenTouch && this.isNeedLoadMore && !this.isNoMoreData) { Row() { this.LoadFooter() }.width("100%") .height(this.loadMoreViewHeight) .translate({ y: this.dragY }) } }.onTouch((event: TouchEvent) => { switch (event.type) { case TouchType.Down: // 标记起始位置的坐标 this.yStart = event.touches[0].y; break case TouchType.Move: // 标记当前位置的坐标 const yEnd = event.touches[0].y; // 手指离开屏幕的纵坐标 // 已经滑到底部了 if (this.scroller.isAtEnd() && !this.isLoading) { // 展示底部局部 this.isShowLoadingWhenTouch = true // 计算加载更多布局的偏移量;*0.6是用来调手感的, const changeY = (yEnd - this.yStart) * 0.6 + this.loadMoreViewHeight // 偏移量边界处理 this.dragY = changeY < -this.loadMoreViewHeight ? -this.loadMoreViewHeight : changeY console.debug(`this.dragY = ${this.dragY}`) // 判断上滑,且list跟随手势滑动 if (yEnd <= this.yStart) { // 标记手势Up时,是否要回调加载更多 if (changeY < this.loadMoreViewHeight) { this.isLoadMoreWhenUp = true } else { this.isLoadMoreWhenUp = false } } else { // 如果往下滑,且超过了起始位置,则置回起始位置,同时更新手势的起始位置 if (this.dragY >= this.loadMoreViewHeight) { this.dragY = this.loadMoreViewHeight this.isLoadMoreWhenUp = false this.yStart = yEnd } else { // 同上面yEnd <= this.yStart的逻辑 if (changeY < this.loadMoreViewHeight) { this.isLoadMoreWhenUp = true } else { this.isLoadMoreWhenUp = false } } } } else { // 没到底部 就置回起始状态 this.isShowLoadingWhenTouch = false this.yStart = yEnd if (!this.scroller.isAtEnd()) { animateTo({ duration: 100, curve: Curve.EaseOut, playMode: PlayMode.Normal, }, () => { this.dragY = this.loadMoreViewHeight }) } } break case TouchType.Up: // 手指up时,如果需要回调事件就回调,同时加载中布局全部展示,否则置回起始状态 if (this.isLoadMoreWhenUp) { if (!this.isRefreshing && !this.isLoading && !this.isNoMoreData) { this.loadMore() this.isLoading = true animateTo({ duration: 100, curve: Curve.EaseOut, playMode: PlayMode.Normal, }, () => { this.dragY = 0 }) } else { this.dragY = this.loadMoreViewHeight this.isShowLoadingWhenTouch = false } } else { animateTo({ duration: 100, curve: Curve.EaseOut, playMode: PlayMode.Normal, onFinish: () => { this.isShowLoadingWhenTouch = false } }, () => { this.dragY = this.loadMoreViewHeight }) } break } }) .width("100%") .height("100%") } finishRefresh() { this.isRefreshing = false } finishLoadMore() { this.dragY = this.loadMoreViewHeight this.isLoading = false } // 没有更多数据 noMoreData(value: boolean) { this.isNoMoreData = value } // 没有更多数据 needLoadMore(value: boolean) { this.isNeedLoadMore = value } @Builder buildBody() { Text('未设置body') } @Builder RefreshHeader() { Column() { RefreshHeaderLayout() } } @Builder LoadFooter() { Column() { LoadingMoreLayout() }.width("100%") .backgroundColor(Color.White) } } export class CppPullToRefreshController { private cppRefreshView: CppPullToRefresh | undefined bind(cppRefreshView: CppPullToRefresh) { this.cppRefreshView = cppRefreshView } needLoadMore(value: boolean) { this.cppRefreshView?.needLoadMore(value) } finishLoadMore() { this.cppRefreshView?.finishLoadMore() } finishRefresh() { this.cppRefreshView?.finishRefresh() } // 没有更多 noMoreData(value: boolean) { this.cppRefreshView?.noMoreData(value) } }

LoadingMoreLayout, RefreshHeaderLayout是自定义的头布局和底部布局,这个根据自己需要写一下即可,就不贴代码了。

贴下使用代码:

js
复制代码
CppPullToRefresh({ viewController:this.viewController, // isNeedLoadMore:true, // isNeedNoMoreData:true, body: (): void => { this.buildBody() }, reFresh:()=>{ this.petHistoryRecords(true) }, loadMore:()=>{ this.petHistoryRecords() } }) .width("100%") @Builder buildBody() { ListItem() {} or LazyForEach(){ ListItem() { } } }

因为控件内部是List,所以只能传ListItem/ListGroupItem的子节点,如果只是对一个布局做刷新,可以传一个布局用ListItem包起来即可。

四、写在最后

最后没有最后,问下,你们开始适配鸿蒙了吗?

源文:鸿蒙自定义下拉刷新/上拉加载更多控件

如有侵权请联系站点删除!

Technical cooperation service hotline, welcome to inquire!