自定义组件之<八>自定义下拉刷新(RefreshList)

2024-12-31 10:16:31
32次阅读
0个评论

9.8:自定义下拉刷新(RefreshList)

下拉刷新是一个很常用的功能,绝大多数的 APP 都有该功能,比如新闻类 APP 使用下拉刷新更新最新新闻资讯等场景,本节笔者简单实现一个基于 List 的自定义下拉刷新组件 RefreshList,该组件的运行效果如下图所示:

9_8_1.gif

9.8.1:RefreshList布局拆分

下拉刷新组件都是分为上下两部分,上边是刷新头:refreshHead,该刷新头根据手指的下滑距离提示是否达到刷新条件;下边是刷新体:refreshContent,当触发下拉刷新条件后对外回调,从而实现内容更新。笔者实现的 RefreshList 也是按照以上布局实现的,简化图如下所示:

9_8_1_1.png

默认情况下 refreshHead 是布局在 RefreshList 可视区域外边,可以使用 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%")
  }
}

9.8.2:RefreshList滑动处理

ArkUI 开发框架对于手势事件的处理遵循 W3C 标准,首先是目标捕获阶段,然后再是事件冒泡阶段,下拉刷新的操作就是在事件冒泡阶段处理的,因此直接实现 refresh_listonTouch() 方法即可,在该方法内根据手指的滑动距离动态实现 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) {
  }
}

9.8.3:RefreshList滑动冲突

由于 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 即可。

9.8.4:RefreshList完整代码

动态布局完 refreshHeaderrefreshContent 后,在 refresh_listonTouch() 方法内处理滑动操作,一个下拉刷新的轮廓基本就形成了,笔者实现的 RefreshList 完整代码如下所示:

export class Constant {
  static readonly REFRESH_PULL_TO_REFRESH = "下拉刷新";
  static readonly REFRESH_FREE_TO_REFRESH = "释放立即刷新";
  static readonly REFRESH_REFRESHING      = "正在刷新";
  static readonly REFRESH_SUCCESS         = "刷新成功";
}

@ComponentV2
export struct RefreshList {

  @BuilderParam
  itemLayout?: (item: Object, index: number) => void;

  @Require @Param refreshing: boolean;
  @Event $refreshing: (refreshing: boolean) => void = (refreshing: boolean) => {}

  @Require @Param dataSet: Array<Object>;

  @Param onRefresh: () => void = () => {}
  @Param onStatusChanged: (status: RefreshStatus) => void = (status: RefreshStatus) => {}

  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;

  @Local offsetY: number = -this.headHeight;
  @Local refreshHeadIcon: Resource = $r("app.media.icon_refresh_down");
  @Local refreshHeadText: string = Constant.REFRESH_PULL_TO_REFRESH;
  @Local refreshContentH: number = 0;
  @Local touchEnabled: boolean = true;
  @Local headerVisibility: Visibility = Visibility.None;

  private listScroller: Scroller = new Scroller();

  @Monitor("refreshing")
  notifyRefreshingChanged(monitor: IMonitor) {
    console.log("notifyRefreshingChanged: " + this.refreshing)
    if (this.refreshing) {
      this.showRefreshingStatus();
    } else {
      this.finishRefresh();
    }
  }

  @Monitor("offsetY")
  notifyOffsetYChanged(monitor: IMonitor) {
    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: Object, index: number) => {
              ListItem() {
                if (this.itemLayout) {
                  this.itemLayout(item, index)
                }
              }
              .width("100%")
            }, (item: Object, index: number) => item.toString())
          }
        }
        .width("100%")
        .height("100%")
        .edgeEffect(EdgeEffect.None)
        // .onScrollBegin((dx: number, dy: number) => {
        //   dy = this.listScrollable ? dy : 0;
        //   return {dxRemain: dx, dyRemain: dy}
        // })
        .onScrollFrameBegin((offset: number, state: ScrollState) => {
          offset = this.listScrollable ? offset : 0;
          return {offsetRemain: offset}
        })
      }
      .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;
    let refreshing = (status == RefreshStatus.Refresh);
    this.$refreshing(refreshing)
    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.logD("手指向上拖动还未到达临界值,不让 list 滚动")
          this.offsetY = this.offsetY + px2vp(deltaY) * this.flingFactor;
          this.listScrollable = false;
        } else {
          this.logD("手指向上拖动到达临界值了,开始让 list 滚动")
          this.offsetY = -this.headHeight;
          this.listScrollable = true;
          this.downY = this.lastY;
        }
      } else {
        // 手势向下拖动
        this.logD("手指向下拖动中")
        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;
          this.logD("Touch MOVE: 手指向下滑动,达到了拖动条件")
        }
      }
    }

    if(this.dragging) {
      if (currentY >= this.downY) {
        if (this.offsetY >= 0 || (this.headHeight - Math.abs(this.offsetY)) > this.headHeight * 4 / 5) {
          this.refreshHeadText = Constant.REFRESH_FREE_TO_REFRESH;
          this.refreshHeadIcon = $r("app.media.icon_refresh_up");
          this.setRefreshStatus(RefreshStatus.OverDrag);
        } else {
          this.refreshHeadText = 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.logD("Touch   UP: 最终达到下拉刷新条件")
        this.refreshHeadIcon = $r("app.media.icon_refresh_loading");
        this.refreshHeadText = Constant.REFRESH_REFRESHING;
        this.setRefreshStatus(RefreshStatus.Refresh);
        this.scrollToTop();

        this.notifyRefreshStart();
      } else {
        this.logD("Touch   UP: 最终未达到下拉刷新条件")
        this.refreshHeadIcon = $r("app.media.icon_refresh_down");
        this.refreshHeadText = 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 = Constant.REFRESH_PULL_TO_REFRESH;
    this.setRefreshStatus(RefreshStatus.Inactive);
  }

  private finishRefresh(): void {
    this.refreshHeadText = 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 = 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);
  }
}

9.8.5:RefreshList完整样例

笔者实现的 RefreshList 很简单,使用方式也和 Refresh 的用法保持了一致,唯一需要注意的就是想要自己实现 List 的每一个 item 局部,样例代码如下所示:

import {RefreshList} from "./widget/refresh_list"

@Entry @ComponentV2 struct ArkUIClubRefreshListTest {

  @Local dataSet: Array<string> = [];
  @Local refreshing: boolean = false;

  @Builder itemLayout(item: Object, index: number) {
    Text("item:" + item + ", index: " + index)
      .width('100%')
      .height(100)
      .margin({top: 10})
      .fontSize(16)
      .textAlign(TextAlign.Center)
      .borderRadius(10)
      .backgroundColor('#bbccaa')
  }

  build() {
    Column() {
      Row({space: 10}) {
        Button("开始刷新")
          .onClick(() => {
            this.refreshing = true;
          })
        Button("结束刷新")
          .onClick(() => {
            this.refreshing = false;
          })
      }
      .width("100%")
      .height(50)

      Column() {
        RefreshList({
          refreshing: this.refreshing!!,
          dataSet: this.dataSet,
          itemLayout: (item: Object, index: number) => {
            this.itemLayout(item, index);
          },
          onRefresh: () => {
            this.doRefresh();
          },
          onStatusChanged: (status) => {
            console.log("current status: " + status);
          }
        })
      }
      .width("100%")
      .layoutWeight(1)
    }
    .width("100%")
    .width('100%')
  }

  aboutToAppear() {
    this.initDataSet(0, 10);
  }

  private doRefresh() {
    setTimeout(() => {
      console.log("finish refresh")
      this.initDataSet((Math.random() * 100), 30);
      this.refreshing = false;
    }, 2500);
  }

  private initDataSet(start: number, count: number) {
    let dataSet = new Array<string>();
    for (let i = 0; i < count; i++) {
      dataSet.push((i + start).toFixed(2));
    }
    this.dataSet = dataSet;
  }

}

9.8.6:小结

本节笔者简单实现了一个下拉刷新控件 RefreshList,主要是利用了组件的 position() 方法实现动态布局,结合 onTouch() 方法实现下拉刷新,另外在 ListonScrollBegin() 方法内控制滑动值来解决滑动冲突,具体其它细节读者可自行阅读代码,笔者也期待读者能扩展出更多的功能,比如自定义刷新头,上拉加载更多等功能,或者读者自己实现一个 RefreshGrid 刷新控件……

备注

作者:灰太狼

出处:https://www.arkui.club/

链接:https://www.nutpi.net/

來源:坚果派

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。否则追究相关责任。

收藏00

登录 后评论。没有帐号? 注册 一个。