> 文档中心 > ArkUI实战,自定义下拉刷新组件RefreshList

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 也会跟着一起滑动,这种体验是非常不友好的,因此可以在 ListonScrollBegin() 方法中处理滑动冲突,简化代码如下所示:
@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() 方法返回的 dyRemain0 即可。

  • 完整代码
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 刷新组件……