支持刷新加载的鸿蒙动态分组列表组件 一、背景 二、简介 三、

一、背景

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

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

  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 作为单独的组件供外部使用,优点是可自由定制如实现本次分组列表,缺点是需要多处声明

四、安装指南

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

五、效果示例

六、代码示例

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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,满足则回调请求数据,即完成一次下拉刷新

1
2
3
4
5
6
7
8
9
10
11
12
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 的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 数据,最后取新的集合展示数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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组件,只是传入的数据不一样

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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里当前滚动数据的角标,如果最后一条数据角标大于当前该页全部的数据的大小,则表示已经滑到该页最后一条数据。然后继续判断是否已经达到上拉触发的滑动阈值,达到就修改标记为已触发上拉加载更多

1
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 ; } } }

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

1
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、整个列表的逻辑部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
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…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%