支持刷新加载的鸿蒙动态分组列表组件

一、背景

目前货拉拉作为首批和鸿蒙合作适配的厂商 之一,已经在内部开始适配鸿蒙版货拉拉用户端

在鸿蒙开发适配过程中发现,项目中存有列表+分组的场景,按目前已有实现方式存在如下问题:

  1. 官方文档上推荐实现的分组列表是使用ListItemGroup的方式来实现分组

  2. ListItemGroup适用于静态分组,例如已经获取了全部数据之后通讯录或者城市列表分组显示

不太适用于

  1. 需要动态加载更多数据之后给数据动态分组
  2. 需要实时监听item滑动位置的上拉加载更多的场景

因为ListItemGroup被当做一个整体的item,难以实时监听到内部item的滑动位置,所以难以判断需要上拉加载更多

本文的PullToRefresh组件在开源的下拉刷新组件的基础上同时实现下拉刷新、上拉加载更多、列表动态分组功能

二、简介

PullToRefreshFor是鸿蒙下可同时实现动态分组列表进行下拉刷新、上拉加载的组件

在以下版本验证通过:

  • DevEco Studio: 4.1 Canary(4.1.3.500), SDK: API11 (4.1.0)

理论上也支持API 9、10的版本

三、功能特性

  • 特性1:支持下拉刷新和上拉加载更多数据
  • 特性2:同时支持动态分组列表

和这个gitee.com/openharmony...

  1. 监听手势事件的方式不同:PullToRefresh 使用parallelGesture方法获取触摸手势事件,本组件使用onTouch方法获取手势
  2. 灵活度不同:PullToRefresh把整个组件进行一个大的封装,由外部传入 List 组件和数据请求函数即可,优点是使用上手简单,缺点是不太容易定制。本组件则是把下拉刷新、上拉加载、Head 作为单独的组件供外部使用,优点是可自由定制如实现本次分组列表,缺点是需要多处声明

四、安装指南

bash 复制代码
ohpm install @huolala/pull-refresh

五、效果示例

六、代码示例

1、头部刷新部分及头部刷新逻辑

头部下拉刷新UI视图组件为CustomRefreshLoadLayout,当需要下拉刷新时,传入PullRefreshModel里的refreshLayoutConfig,然后添加此组件即可预设刷新 UI

通过@state 注解的 PullRefreshModel 类,当满足相应条件时,自动更新是否可见、刷新时的图片资源、刷新时的文案,控件高度

如当外部更改为可见时则使用预设控件高度显示,否则高度置为 0,则隐藏了刷新控件

scss 复制代码
 // 下拉刷新
CustomRefreshLoadLayout({ config: this.dataModel.refreshLayoutConfig })

@Observed
export  class PullRefreshModel {
  //...
  refreshLayoutConfig: CustomRefreshLoadLayoutConfig = new CustomRefreshLoadLayoutConfig(false)
  //...
}

@Component
export  default struct CustomLayout {
  @ObjectLink customRefreshLoadClass: CustomRefreshLoadLayoutClass;

  build() {
    Row() {
      // UI 视图,跟随状态是动态获取
      // ....省略具体UI
    }
    .clip(true)
    .width(Const.FULL_WIDTH)
    .justifyContent(FlexAlign.Center)
    // 这里通过获取刷新组件是否可见的值,来动态控制的高度是否为 0
    .height(this.customRefreshLoadClass.isVisible == true ? this.customRefreshLoadClass.heightValue : 0)
    .animation({
      duration: 300
    })
  }
}

触发下拉刷新的方式,则是通过监听控件的 onTouch方法,传入 TouchEvent 触摸数据到组件内部,通过判断下滑偏移量来更新下拉刷新组件的PullRefreshModel类的属性值,最后通过数据更新 UI 到上面的CustomRefreshLoadLayout

ini 复制代码
export  function touchMovePullRefresh(dataModel: PullRefreshModel, event: TouchEvent) {
  if (dataModel.startIndex === 0) {
    // 表示已经可以操作下拉刷新
    dataModel.isPullRefreshOperation = true;
    let height = vp2px(dataModel.pullDownRefreshHeight);
    dataModel.offsetY = event.touches[0].y - dataModel.downY;
    // 偏移达到刷新的值.
    if (dataModel.offsetY >= height) {
      pullRefreshState(dataModel, RefreshState.Release);
      dataModel.offsetY = height + dataModel.offsetY * Const.Y_OFF_SET_COEFFICIENT;
    } else {
      // 偏移没达到刷新的值.继续显示"下拉刷新"
      pullRefreshState(dataModel, RefreshState.DropDown);
    }
    if (dataModel.offsetY < 0) {
      dataModel.offsetY = 0;
      dataModel.isPullRefreshOperation = false;
    }
  }
}

export  function pullRefreshState(dataModel: PullRefreshModel, state: number) {
  switch (state) {
    case RefreshState.DropDown:
      dataModel.refreshLayoutConfig.textValue = $r('app.string.pull_down_refresh_text');
      dataModel.refreshLayoutConfig.imageSrc = $r('app.media.client_ic_pull_down_refresh');
      dataModel.isCanRefresh = false;
      dataModel.isRefreshing = false;
      dataModel.refreshLayoutConfig.isVisible = true;
  break;
    case RefreshState.Release:
      dataModel.refreshLayoutConfig.textValue = $r('app.string.release_refresh_text');
      dataModel.refreshLayoutConfig.imageSrc = $r('app.media.client_ic_pull_up_refresh');
      dataModel.isCanRefresh = true;
      dataModel.isRefreshing = false;
      break;
    //...
  }
}

当松开手指后,根据此前下拉滑动时记录的已满足下拉刷新的标记isCanRefresh,满足则回调请求数据,即完成一次下拉刷新

scss 复制代码
export  function touchUpPullRefresh(dataModel: PullRefreshModel, getDataCallBack: (isLoadMore: boolean) => void) {
  if (dataModel.isCanRefresh === true) {
    // 满足可以刷新请求数据
    dataModel.offsetY = vp2px(dataModel.pullDownRefreshHeight);
    pullRefreshState(dataModel, RefreshState.Refreshing);
    // 页码置为 1
    dataModel.currentPage = 1;
    getDataCallBack(false)
  } else {
    closeRefresh(dataModel, false);
  }
}

2、占位head及列表head部分及交互逻辑

由于使用 ListItemGroup 会无法监听到 ListItemGroup 内部的 Item,但业务场景仍然需要分组的 UI,所以这里使用单独的占位 head 去作为分组标题的来显示

占位 head 总共有两处,一处是在 List 列表布局外面,一个是 List列表首条 Item 里

这两条 head 的用处分别是,第一条 head 用于在滑动的时候,始终悬浮在最顶部,并且通过onScrollIndex方法获取到当前首条 Item,数据来动态更新占位 head 的数据

scss 复制代码
Row() {
    // 1. 假的占位 head 头
    this.itemHead()
  }
  .visibility(this.showFakeHead? Visibility.Visible : Visibility.None)

 List({space:20, scroller: this.scroller }) {
    ListItem() {
      Row() {
          // 2. 列表的head头
        this.itemHead()
      }.visibility(!this.showFakeHead? Visibility.Visible : Visibility.None)
    }
 }

3、列表动态分组实现逻辑

动态分组是指在获取到数据之后才能去实现分组,而不是像通讯录那种可以一次获取所有列表数据和分组数据

如果是后端给的数据已经实现分组,则可以直接按照给的分组进行 UI 渲染,然后直接进行下一页获取即可。但如果是后端给的数据里没有包含任何分组数据,则需要由我们来进行动态分组和更新数据来渲染 UI

具体做法是构建一个用来展示的 model 类的数据集合,在拿到原始数据的时候,判断每一条 head 的数据和之前记录的 head 数据是否相符,如果不符,则手动插入一条 head 数据,这条数据仅用来显示分组的标题,如果相同则继续添加原来的数据进去新的集合,只是这是一条普通的 Item 数据,最后取新的集合展示数据

ini 复制代码
let currentHead: string = ""
private  getList(data: ListData): ListDisplayBean[] {
  let  listDisplay: ListDisplayBean[] = []

  if (data.list == null || data.list == undefined) {
    return orderList
  }
  for (let i = 0; i < data.list.length; i++) {
    let item = data.list[i]
    if (this.currentHead != item.head) {
      // 
      bean.isMonth = true
orderList.push(bean)
    }

    let bean = new  ListDisplayBean()
    bean.item = item
    listDisplay.push(bean)
    this.currentHead = item.head
  }
  return orderList
}

4、底部加载更多部分及加载更多逻辑

底部上拉加载视图为CustomRefreshLoadLayout,和下拉刷新一样,复用同样的一个UI组件,只是传入的数据不一样

与下拉刷新不同的是,必须是有下一页数据时才会显示这个组件,是否有下一页数据,则在每次请求完数据的时候根据条数确定,否则显示没有更多数据的组件

scss 复制代码
/**
 * 上拉加载更多组件
 */
@Component
export struct LoadMoreLayout {
  @ObjectLink loadMoreLayoutClass: CustomRefreshLoadLayoutClass;

  build() {
    Column() {
      CustomRefreshLoadLayout({
        customRefreshLoadClass: new CustomRefreshLoadLayoutClass(this.loadMoreLayoutClass.isVisible,
          this.loadMoreLayoutClass.imageSrc, this.loadMoreLayoutClass.textValue, this.loadMoreLayoutClass.heightValue)
      })
    }
  }
}

/**
 * 没有更多数据组件.
 */
@Component
export struct NoMoreLayout {
  build() {
    Row() {
      Text('没有更多数据了')
        .margin({ left: Const.NoMoreLayoutConstant_NORMAL_PADDING })
        .fontSize(Const.NoMoreLayoutConstant_TITLE_FONT)
        .textAlign(TextAlign.Center)
    }
    .width(Const.FULL_WIDTH)
    .justifyContent(FlexAlign.Center)
    .height(Const.CUSTOM_LAYOUT_HEIGHT)
  }
}

实现上拉加载更多逻辑,需要先获取是否当前已经滑动到当前页的最后一条数据了,获取的方法是通过.onScrollIndex里当前滚动数据的角标,如果最后一条数据角标大于当前该页全部的数据的大小,则表示已经滑到该页最后一条数据。然后继续判断是否已经达到上拉触发的滑动阈值,达到就修改标记为已触发上拉加载更多

matlab 复制代码
export  function  touchMoveLoadMore ( dataModel: PullRefreshModel, event: TouchEvent ) {  if (dataModel. endIndex >= dataModel. dataSize - 1 ) { dataModel. offsetY = event. touches [ 0 ]. y - dataModel. downY ;  if ( Math . abs (dataModel. offsetY ) > vp2px (dataModel. pullUpLoadHeight ) / 2 ) { dataModel. isCanLoadMore = true ; dataModel. loadMoreLayoutConfig . isVisible = true ; dataModel. offsetY = - vp2px (dataModel. pullUpLoadHeight ) + dataModel. offsetY * Const . Y_OFF_SET_COEFFICIENT ; } } } 

获取到上面的标记之后,则在手指松开之后,会调用获取下一页的数据,这样就完成了上拉加载更多

lua 复制代码
export  function  touchUpLoadMore ( dataModel: PullRefreshModel, getDataCallBack: (isLoadMore: boolean ) => void ) {  let  self : PullRefreshModel = dataModel;  animateTo ({  duration : Const . ANIMATION_DURATION , }, () => { self. offsetY = 0 ; })    // isCanLoadMore 为 true 表示当前已经到第一页最后一条数据并且手势上滑到了阈值    // hasMore 为 true 表示数据还有下一页,默认是 true   if ((self. isCanLoadMore === true ) && (self. hasMore === true )) { self. isLoading = true ;  getDataCallBack ( true ) } else {  closeLoadMore (self); } } 

5、整个列表的逻辑部分

scss 复制代码
@State data: GroupData[] = [];
@State headTitle: GroupData = new GroupData()
@State showFakeHead: boolean = true
// 需绑定列表或宫格组件
private scroller: Scroller = new Scroller();
@State private dataModel: PullRefreshModel = new PullRefreshModel()
private itemDataGroupNew: GroupData[] = [....]// 假数据省略

@Builder
private getListView() {
    // 列表首条 Item
  CustomRefreshLoadLayout({ config: this.dataModel.refreshLayoutConfig })
    

   // 1. 假的占位 head 头
  Row() {
    this.itemHead()
  }
  .visibility(this.showFakeHead? Visibility.Visible : Visibility.None)

  List({space:20, scroller: this.scroller }) {
    ListItem() {
      Row() {
          // 2. 列表的head头
        this.itemHead()
      }.visibility(!this.showFakeHead? Visibility.Visible : Visibility.None)
    }

    ForEach(this.data, (item: GroupData) => {
      ListItem() {
        Column() {
          Row() {
            // 3. 列表中不悬浮的 head
            Text(item.head)
              .fontSize(20)
              .height(50)
              .backgroundColor('#FF667075')
              .width('100%')
          }.visibility(item.isHead ? Visibility.Visible : Visibility.None)

          Text(item.content)
            .width('100%')
            .height(150)
            .fontSize(20)
            .textAlign(TextAlign.Center)
            .backgroundColor('#FF6600')
            .visibility(!item.isHead ? Visibility.Visible : Visibility.None)
        }
      }
    })
    // 列表末条 Item
    ListItem() {
      if (this.dataModel.hasMore) {
        CustomRefreshLoadLayout({ config: this.dataModel.loadMoreLayoutConfig })
      } else {
        NoMoreLayout()
      }
    }
  }
  .onTouch((event: TouchEvent | undefined) => {
    if (event) {
      if (this.dataModel.pageState === PageState.Success) {
        listTouchEvent(this.dataModel, event, (isLoadMore: boolean) => {
          this.getData(isLoadMore)
        });
      }
    }
  })
  .onScrollIndex((start: number, end: number) => {
    console.log(`headfloat start:${start}`)
    if (this.data.length > start) {
      let startValue = this.data[start]
      // 4. 赋值 head 数据
      this.headTitle = startValue
    }
    let yOffset: number = this.scroller.currentOffset().yOffset
    if (yOffset >= -0.01) {
        // 5. 控制 head 头展示
      this.showFakeHead = true
} else {
      this.showFakeHead = false
}
    this.dataModel.startIndex = start;
    this.dataModel.endIndex = end;
  })
  .backgroundColor('#eeeeee')
  .edgeEffect(EdgeEffect.None) // 必须设置列表为滑动到边缘无效果
}

七、原理说明

通过分别构造滑动时假 head 头和未滑动时的 head 头,第一个 head 头在滑动后,通过监听onScrollIndex首条出现的ListItem 的角标动态设置数据,并且该控件处在 UI在 List 控件之上,达到悬停的效果

第二个 head 头与第一个 head 头互斥出现,滑动后即消失,在视觉上就像是通讯录分组一样的效果

八、类接口说明

  1. RefreshLayout:下拉刷新的UI控件,可定制
  2. itemHead:分组 head 头
  3. LoadMoreLayout:上拉加载更多 UI 空间,可定制
  4. NoMoreLayout:没有更多 UI 空间,可定制
  5. PullRefreshModel:用于控制下拉刷新和上拉加载状态记录的 model 类
属性 类型 释义 默认值
dataSize number 数据大小 0
currentPage number 当前页码 1
pageSize number 每页大小 20
pullDownRefreshHeight number 下拉刷新组件的高度 70
pullUpLoadText Resource 上拉加载时的文案 加载中..
offsetY number Y 轴偏移值 0
pageState number 当前刷新组件状态,如加载中,加载完成 loading 状态
startIndex number 列表的第一条角标值 0
endIndex number 列表的最后一条角标值 0
downY number 按下屏幕时的 Y 坐标 0
lastMoveY number 移动手指时最新的 Y 坐标 0
isRefreshing boolean 当前是否正在下拉刷新中 false
isCanRefresh boolean 是否已经满足松开手指触发刷新 fasle
isPullRefreshOperation boolean 当前正在下拉操作 false
isLoading boolean 是否正在上拉加载更多数据中 false
hasMore boolean 是否有下一页 false
isCanLoadMore boolean 是否可以加载下一页 false
refreshLayoutConfig CustomRefreshLoadLayoutConfig 下拉刷新组件内部使用的属性值 -
loadMoreLayoutConfig CustomRefreshLoadLayoutConfig 上拉加载组件内部使用的数值 -

九、开源地址

github.com/HuolalaTech...

相关推荐
消失的旧时光-194321 分钟前
kotlin的密封类
android·开发语言·kotlin
服装学院的IT男2 小时前
【Android 13源码分析】WindowContainer窗口层级-4-Layer树
android
CCTV果冻爽3 小时前
Android 源码集成可卸载 APP
android
码农明明3 小时前
Android源码分析:从源头分析View事件的传递
android·操作系统·源码阅读
秋月霜风4 小时前
mariadb主从配置步骤
android·adb·mariadb
Python私教5 小时前
Python ORM 框架 SQLModel 快速入门教程
android·java·python
编程乐学6 小时前
基于Android Studio 蜜雪冰城(奶茶饮品点餐)—原创
android·gitee·android studio·大作业·安卓课设·奶茶点餐
problc7 小时前
Android中的引用类型:Weak Reference, Soft Reference, Phantom Reference 和 WeakHashMap
android
IH_LZH7 小时前
Broadcast:Android中实现组件及进程间通信
android·java·android studio·broadcast
去看全世界的云7 小时前
【Android】Handler用法及原理解析
android·java