ArkUI实战,自定义下拉刷新组件RefreshList
下拉刷新是一个高频使用的功能,ArkUI 开发框架也提供了下拉刷新组件 Refresh,该组件的使用非常简单,读者可参阅笔者在《ArkUI实战》第六章 第 5 小节 的介绍,本文笔者讲解一下笔者在项目上实现的一个下拉刷新组件 RefreshList,该组件的运行效果如下图所示:
- 布局拆分
下拉刷新组件都是分为上下两部分,上边是刷新头:refreshHead,该刷新头根据手指的下滑距离提示是否达到刷新条件;下边是刷新体:refreshContent,当触发下拉刷新条件后对外回调,从而实现内容更新。笔者实现的 RefreshList 也是按照以上布局实现的,简化图如下所示:
默认情况下 refreshHead 是布局在 RefreshList 可视区域外边,笔者在第三章 第 1 小节 讲解过可以使用 position() 方法实现布局定位,简化代码如下所示:
@Component struct RefreshList { build() { Column() { Row() { // header布局 } .id("refresh_header") .width("100%") .height(50) .position({ // 利用该属性,把refresh_header布局在 Column 顶部 x: 0, y: -50 }) Column() { // content 布局 } .id("refresh_content") .width("100%") .height("100%") .position({ // 利用该属性,把refresh_content布局向上做偏移 x: 0, y: 0 }) } .id("refresh_list") .width("100%") .height("100%") }}
- 滑动处理
ArkUI 开发框架对于手势事件的处理遵循 W3C 标准,首先是目标捕获阶段,然后再是事件冒泡阶段,下拉刷新的操作就是在事件冒泡阶段处理的,因此直接实现 refresh_list 的 onTouch() 方法即可,在该方法内根据手指的滑动距离动态实现 refreshHeader 和 refreshContent 的布局定位即可,简化代码如下所示:
@Component struct RefreshList { private refreshHeaderHeight: number = 50; private offsetY: number = -this.refreshHeaderHeight; private lastX: number; private lastY: number; private downY: number; build() { Column() { Row() .id("refresh_header") .width("100%") .height(this.refreshHeaderHeight) .backgroundColor("#bbaacc") .position({ x: 0, y: this.offsetY }) Column() { } .id("refresh_content") .width("100%") .height("100%") .backgroundColor("#aabbcc") .position({ x: 0, y: this.offsetY + this.refreshHeaderHeight }) } .id("refresh_list") .width("100%") .height("100%") .onTouch((event) => { if (event.type == TouchType.Down) { // 处理 down 事件 this.onTouchDown(event); } else if (event.type == TouchType.Move) { // 处理 move 事件 this.onTouchMove(event); } else if (event.type == TouchType.Cancel || event.type == TouchType.Up) { // 处理 up 事件 this.onTouchUp(event); } }) } private onTouchDown(event: TouchEvent) { this.lastX = event.touches[0].screenX; this.lastY = event.touches[0].screenY; this.downY = this.lastY; } private onTouchMove(event: TouchEvent) { let currentX = event.touches[0].screenX; let currentY = event.touches[0].screenY; let deltaX = currentX - this.lastX; let deltaY = currentY - this.lastY; if (Math.abs(deltaX) < Math.abs(deltaY) && Math.abs(deltaY) > 5) { // 达到滑动条件 } } private onTouchUp(event: TouchEvent) { }}
- 滑动冲突
由于 refreshContent 内部包含的是 List 组件,该组件比较特殊,它会默认响应手势的滑动操作,在处理外层滑动的时候该 List 也会跟着一起滑动,这种体验是非常不友好的,因此可以在 List 的 onScrollBegin() 方法中处理滑动冲突,简化代码如下所示:
@Component struct RefreshList { build() { Column() { Row() .id("refresh_header") .position({ x: 0, y: this.offsetY }) Column() { List({scroller: this.listScroller}) { } .edgeEffect(EdgeEffect.None) .onScrollBegin((dx: number, dy: number) => { // 处理滑动冲突 dy = this.listScrollable ? dy : 0; return {dxRemain: dx, dyRemain: dy} }) } .id("refresh_content") .position({ x: 0, y: this.offsetY + this.refreshHeaderHeight }) } .id("refresh_list") }}
listScrollable 属性表示 List 是否可以滚动,当在处理外部滑动的时候禁止内部的 List 滑动,此时让 onScrollBegin() 方法返回的 dyRemain 为 0 即可。
- 完整代码
export namespace refresh { export class Constant { static readonly REFRESH_PULL_TO_REFRESH = "下拉刷新"; static readonly REFRESH_FREE_TO_REFRESH = "释放立即刷新"; static readonly REFRESH_REFRESHING = "正在刷新"; static readonly REFRESH_SUCCESS = "刷新成功"; } @Component export struct RefreshList { @BuilderParam itemLayout?: (item: any, index: number) => any; @Watch("notifyRefreshingChanged") @Link refreshing: boolean; @Link dataSet: Array<any>; onRefresh?: () => void; onStatusChanged?: (status: RefreshStatus) => void; private headHeight: number = 55; private lastX: number = 0; private lastY: number = 0; private downY: number = 0; private flingFactor: number = 0.75; private touchSlop: number = 2; private offsetStep: number = 10; private intervalTime: number = 20; private listScrollable: boolean = true; private dragging: boolean = false; private refreshStatus: RefreshStatus = RefreshStatus.Inactive; @Watch("notifyOffsetYChanged") @State offsetY: number = -this.headHeight; @State refreshHeadIcon: Resource = $r("app.media.icon_refresh_down"); @State refreshHeadText: string = refresh.Constant.REFRESH_PULL_TO_REFRESH; @State refreshContentH: number = 0; @State touchEnabled: boolean = true; @State headerVisibility: Visibility = Visibility.None; private listScroller: Scroller = new Scroller(); private notifyRefreshingChanged() { if (this.refreshing) { this.showRefreshingStatus(); } else { this.finishRefresh(); } } private notifyOffsetYChanged() { this.headerVisibility = (this.offsetY == -this.headHeight) ? Visibility.None : Visibility.Visible; } @Builder headLayout() { Row() { Blank() Image(this.refreshHeadIcon) .width(30) .aspectRatio(1) .objectFit(ImageFit.Contain) Text(this.refreshHeadText) .fontSize(16) .width(150) .textAlign(TextAlign.Center) Blank() } .width("100%") .height(this.headHeight) .backgroundColor("#44bbccaa") .visibility(this.headerVisibility) .position({ x: 0, y: this.offsetY }) } build() { Column() { this.headLayout() Column() { List({scroller: this.listScroller}) { if (this.dataSet) {ForEach(this.dataSet, (item, index) => { ListItem() { if (this.itemLayout) { this.itemLayout(item, index) } } .width("100%")}, item => item) } } .width("100%") .height("100%") .edgeEffect(EdgeEffect.None) .onScrollBegin((dx: number, dy: number) => { dy = this.listScrollable ? dy : 0; return {dxRemain: dx, dyRemain: dy} }) } .width("100%") .layoutWeight(1) .backgroundColor(Color.Pink) .position({ x: 0, y: this.offsetY + this.headHeight }) } .width("100%") .height("100%") .enabled(this.touchEnabled) .onAreaChange((oldArea, newAre) => { console.log("Refresh height: " + newAre.height); this.refreshContentH = Number(newAre.height); }) .clip(true) .onTouch((event) => { if (event.touches.length != 1) { this.logD("TOUCHES LENGTH INVALID: " + JSON.stringify(event.touches)) event.stopPropagation(); return } switch (event.type) { case TouchType.Down: this.onTouchDown(event); break; case TouchType.Move: this.onTouchMove(event); break; case TouchType.Up: case TouchType.Cancel: this.onTouchUp(event); break; } event.stopPropagation(); }) } private setRefreshStatus(status: RefreshStatus) { this.refreshStatus = status; this.refreshing = (status == RefreshStatus.Refresh); this.touchEnabled = (status != RefreshStatus.Refresh && status != RefreshStatus.Done); this.notifyStatusChanged(); } private canRefresh() { return this.listScroller.currentOffset().yOffset == 0; } private onTouchDown(event: TouchEvent) { this.lastX = event.touches[0].screenX; this.lastY = event.touches[0].screenY; this.downY = this.lastY; this.dragging = false; this.listScrollable = true; this.logD("Touch DOWN: " + event.touches[0].screenX.toFixed(2) + " x " + event.touches[0].screenY.toFixed(2) + ", offset: " + this.offsetY); } private onTouchMove(event: TouchEvent) { let currentX = event.touches[0].screenX; let currentY = event.touches[0].screenY; let deltaX = currentX - this.lastX; let deltaY = currentY - this.lastY; if (this.dragging) { this.logD("offsetY: " + this.offsetY.toFixed(2) + ", head: " + (-this.headHeight)); if (deltaY < 0) { if (this.offsetY > -this.headHeight) { this.offsetY = this.offsetY + px2vp(deltaY) * this.flingFactor; this.listScrollable = false; } else { this.offsetY = -this.headHeight; this.listScrollable = true; this.downY = this.lastY; } } else { if (this.canRefresh()) { this.offsetY = this.offsetY + px2vp(deltaY) * this.flingFactor; this.listScrollable = false; } else { this.listScrollable = true; } } this.lastX = currentX; this.lastY = currentY; } else { if (Math.abs(deltaX) < Math.abs(deltaY) && Math.abs(deltaY) > this.touchSlop) { if (deltaY > 0 && this.canRefresh()) { this.dragging = true; this.listScrollable = false this.lastX = currentX; this.lastY = currentY; } } } if(this.dragging) { if (currentY >= this.downY) { if (this.offsetY >= 0 || (this.headHeight - Math.abs(this.offsetY)) > this.headHeight * 4 / 5) { this.refreshHeadText = refresh.Constant.REFRESH_FREE_TO_REFRESH; this.refreshHeadIcon = $r("app.media.icon_refresh_up"); this.setRefreshStatus(RefreshStatus.OverDrag); } else { this.refreshHeadText = refresh.Constant.REFRESH_PULL_TO_REFRESH; this.refreshHeadIcon = $r("app.media.icon_refresh_down"); this.setRefreshStatus(RefreshStatus.Drag); } } } // this.logD("Touch MOVE: " + event.touches[0].screenX + " x " + event.touches[0].screenY + ", offset: " + this.offsetY); } private onTouchUp(event: TouchEvent) { this.logD("Touch UP: " + event.touches[0].screenX.toFixed(2) + " x " + event.touches[0].screenY.toFixed(2) + ", offset: " + this.offsetY); if (this.dragging) { if (this.offsetY >= 0 || (this.headHeight - Math.abs(this.offsetY)) > this.headHeight * 4 / 5) { this.refreshHeadIcon = $r("app.media.icon_refresh_loading"); this.refreshHeadText = refresh.Constant.REFRESH_REFRESHING; this.setRefreshStatus(RefreshStatus.Refresh); this.scrollToTop(); this.notifyRefreshStart(); } else { this.refreshHeadIcon = $r("app.media.icon_refresh_down"); this.refreshHeadText = refresh.Constant.REFRESH_PULL_TO_REFRESH; this.setRefreshStatus(RefreshStatus.Drag); this.scrollByTop(); } } } private scrollToTop() { this.offsetY = 0; } private scrollByTop() { if (this.offsetY != -this.headHeight) { this.logD("scrollByTop() start, offsetY: " + this.offsetY.toFixed(2)); let intervalId = setInterval(() => { if(this.offsetY <= -this.headHeight) { this.resetRefreshStatus(); clearInterval(intervalId); this.logD("scrollByTop() finish, offsetY: " + this.offsetY.toFixed(2)); } else { this.offsetY = ((this.offsetY - this.offsetStep) < -this.headHeight) ? (-this.headHeight) : (this.offsetY - this.offsetStep); } }, this.intervalTime); } else { this.logD("scrollByTop(): already scrolled to top edge") } } private resetRefreshStatus() { this.offsetY = -this.headHeight; this.refreshHeadIcon = $r("app.media.icon_refresh_down"); this.refreshHeadText = refresh.Constant.REFRESH_PULL_TO_REFRESH; this.setRefreshStatus(RefreshStatus.Inactive); } private finishRefresh(): void { this.refreshHeadText = refresh.Constant.REFRESH_SUCCESS; this.refreshHeadIcon = $r("app.media.icon_refresh_success"); this.setRefreshStatus(RefreshStatus.Done); setTimeout(() => { this.scrollByTop(); }, 1500); } aboutToAppear() { if (this.refreshing) { this.showRefreshingStatus(); } } private showRefreshingStatus() { this.offsetY = 0; this.refreshHeadIcon = $r("app.media.icon_refresh_loading"); this.refreshHeadText = refresh.Constant.REFRESH_REFRESHING; this.setRefreshStatus(RefreshStatus.Refresh); } private notifyRefreshStart() { if (this.onRefresh) { this.onRefresh(); } } private notifyStatusChanged() { if (this.onStatusChanged) { this.onStatusChanged(this.refreshStatus); } } private logD(msg: string) { console.log(msg + ", canRefresh: " + this.canRefresh() + ", dragging: " + this.dragging + ", listScrollable: " + this.listScrollable + ", refreshing: " + this.refreshing); } }}
以上就是笔者在实际项目上实现的自定义刷新组件的实现代码,主要是利用了组件的 position() 方法实现动态布局,结合 onTouch() 方法实现下拉刷新,另外在 List 的 onScrollBegin() 方法内控制滑动值来解决滑动冲突,更多细节读者可在笔记写的《ArkUI实战》电子书的第九章第7节中查看,也期待读者能扩展出更多的功能,比如自定义刷新头,上拉加载更多等功能,或者自己实现一个 RefreshGrid 刷新组件……