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

2,717 阅读4分钟

一、写在前面

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

二、目标

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

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

三、开始吧

1、刷新

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

2、加载更多

滑动初始状态

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

// "加载更多"布局的偏移量 外部无需关心
@State private dragY: number = this.loadMoreViewHeight

Row() {
  this.LoadFooter()
}.width("100%")
  .height(this.loadMoreViewHeight)
  .translate({ y: this.dragY })

拖动状态

TouchType.Down:

// 拖动过程中,y轴的起始拖动位置, 用于计算上面的dragY
private yStart: number = 0; // list触摸事件起始纵坐标


this.yStart = event.touches[0].y;

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

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.isLoadMoreWhenUp = false
  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是当前加载中的标记,防止多次加载:

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
  

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

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

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:

完整代码:

// 手指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、惯性滚动

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

.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、控制器

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

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对象由外部控件设置即可。

aboutToAppear(): void {
  // 绑定Controller
  this.viewController?.bind(this)
}

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

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

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)
}

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

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.isLoadMoreWhenUp = false
            // 没到底部 就置回起始状态
            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是自定义的头布局和底部布局,这个根据自己需要写一下即可,就不贴代码了。

贴下使用代码:

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包起来即可。

四、写在最后

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