列表下拉刷新、上拉加载更多,不管在web时代还是鸿蒙应用都是一个非常常用的功能,基于ArkUI中TS扩展的声明式开发范式实现一个下拉刷新,上拉加载。
上拉加载、下拉刷新
如果数据量过大,可以使用LazyForEach代替ForEach
高阶组件-上拉加载,下拉刷新https://gitee.com/bingtengaoyu/harmonyos-advanced-componen/tree/master/UpDownRefresh
1、涉及的知识点
- 列表容器(List)
- 触摸事件(onTouch)
- 位置设置(offset)
- 显示动画(animateTo)
2、效果图
3、实现思路
根据触摸事件onTouch()处理下拉和上拉,通过记录手指按下的y坐标和move移动的距离判断属于刷拉还是下滑,从而展示不同的内容。
4、关键代码
4.1 生成下拉刷新/上拉加载DOM
@Builder UpDownRefreshBuilder(text: string, state: string) {
Row() {
Image($r('app.media.refreshing'))
.width(32)
.height(32)
Text(text).fontSize(16)
}
.justifyContent(FlexAlign.Center)
.position({
y: state === 'down' ? 20 : -20
})
.zIndex(999)
.width('94%')
.height('8%')
}
4.2 onTouch处理事件
private currentOffsetY: number = 0;
private timer: number = 0;
@State refreshStatus: boolean = false;
@State upRefreshStatus: boolean = false;
aboutToDisappear() {
this.timer = null
}
putDownRefresh(event?: TouchEvent): void {
if (event === undefined) {
return;
}
switch (event.type) {
case TouchType.Down:
this.currentOffsetY = event.touches[0].y;
break;
case TouchType.Move:
if(this.scroller.currentOffset().yOffset < 50) {
this.refreshStatus = event.touches[0].y - this.currentOffsetY > 50;
}
this.upRefreshStatus = event.touches[0].y - this.currentOffsetY < -50;
break;
case TouchType.Cancel:
break;
case TouchType.Up:
// Only simulation effect, no data request
this.timer = setTimeout(() => {
if (this.upRefreshStatus) {
this.scroller.scrollTo({ // 调用scrollTo滚动到具体位置
xOffset: 0, // 竖直方向滚动,该值不起作用
yOffset: 680, // 滚动到底部
animation: { // 滚动动画
duration: 1500,
curve: Curve.EaseOut
}
})
}
this.refreshStatus = false;
this.upRefreshStatus = false;
}, 1500);
break;
}
}
5、完整代码
兵腾傲宇/harmonyos-healthy-live - Gitee.com
import router from '@ohos.router'
import curves from '@ohos.curves'
import { BreakpointSystem, BreakPointType } from '../common/BreakpointSystem'
import { FoodInfo, Category } from '../model/DataModels'
import { getFoods, getFoodCategories, getSortedFoodData } from '../model/DataUtil'
import { Records } from './components/DietRecord'
import { PersonalCenter } from './PersonalCenter'
interface FoodId {
foodId: FoodInfo;
}
@Component
struct FoodListItem {
private foodItem?: FoodInfo
build() {
Navigator({ target: 'pages/FoodDetail' }) {
Row() {
Image(this.foodItem!.image!)
.objectFit(ImageFit.Contain)
.autoResize(false)
.height(40)
.width(40)
.backgroundColor('#FFf1f3f5')
.margin({ right: 16 })
.borderRadius(6)
.sharedTransition(this.foodItem!.letter, {
duration: 400,
curve: curves.cubicBezier(0.2, 0.2, 0.1, 1.0),
delay: 100
})
Text(this.foodItem?.name)
.fontSize(14)
Blank()
Text($r('app.string.calorie_with_kcal_unit', this.foodItem?.calories.toString()))
.fontSize(14)
}
.height(64)
.width('100%')
}
.params({ foodId: this.foodItem } as FoodId)
.margin({ right: 24, left: 32 })
}
}
@Component
struct ListModeFoods {
private foodItems: Array<FoodInfo | string> = getSortedFoodData()
private currentOffsetY: number = 0;
private timer: number = 0;
@State refreshStatus: boolean = false;
@State upRefreshStatus: boolean = false;
aboutToDisappear() {
this.timer = null
}
putDownRefresh(event?: TouchEvent): void {
if (event === undefined) {
return;
}
switch (event.type) {
case TouchType.Down:
this.currentOffsetY = event.touches[0].y;
break;
case TouchType.Move:
if(this.scroller.currentOffset().yOffset < 50) {
this.refreshStatus = event.touches[0].y - this.currentOffsetY > 50;
}
this.upRefreshStatus = event.touches[0].y - this.currentOffsetY < -50;
break;
case TouchType.Cancel:
break;
case TouchType.Up:
// Only simulation effect, no data request
this.timer = setTimeout(() => {
if (this.upRefreshStatus) {
this.scroller.scrollTo({ // 调用scrollTo滚动到具体位置
xOffset: 0, // 竖直方向滚动,该值不起作用
yOffset: 680, // 滚动到底部
animation: { // 滚动动画
duration: 1500,
curve: Curve.EaseOut
}
})
}
this.refreshStatus = false;
this.upRefreshStatus = false;
}, 1500);
break;
}
}
@Builder DownRefreshBuilder(text: string, state: string) {
Row() {
Image($r('app.media.refreshing'))
.width(32)
.height(32)
Text(text).fontSize(16)
}
.justifyContent(FlexAlign.Center)
.position({
y: state === 'down' ? 20 : -20
})
.zIndex(999)
.width('94%')
.height('8%')
}
private scroller: Scroller = new Scroller(); // 创建一个滚动控制器
build() {
Column() {
Text($r("app.string.title_food_list"))
.width('100%')
.height(56)
.padding({ left: 20 })
.backgroundColor('#FF1f3f5')
.fontSize(20)
Scroll(this.scroller) {
if(this.refreshStatus) {
this.DownRefreshBuilder('正在刷新', 'down')
}
List() {
ForEach(this.foodItems, (item: FoodInfo) => {
ListItem() {
if (item.letter !== undefined) {
FoodListItem({ foodItem: item })
} else {
if (typeof (item) === 'string') {
Text(item)
.fontSize(14)
.height(48)
.margin({ left: 24 })
.width('100%')
}
}
}
})
if(this.upRefreshStatus) {
ListItem(){
this.DownRefreshBuilder('正在加载', 'up')
}
}
}
.layoutWeight(1)
}
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.height('90%')
.onTouch((event?: TouchEvent) => {
this.putDownRefresh(event);
})
}
}
}
@Component
struct FoodGridItem {
private foodItem?: FoodInfo
build() {
Column() {
Image(this.foodItem!.image!)
.objectFit(ImageFit.Contain)
.backgroundColor('#f1f3f5')
.width('100%')
.height(152)
.sharedTransition(this.foodItem!.letter, {
duration: 400,
curve: curves.cubicBezier(0.2, 0.2, 0.1, 1.0),
delay: 100
})
Row() {
Text(this.foodItem?.name)
.fontSize(14)
Blank()
Text($r('app.string.calorie_with_kcal_unit', this.foodItem?.calories.toString()))
.fontSize(14)
.fontColor(0x99000000)
}
.padding({ left: 12, right: 12 })
.width('100%')
.height(32)
.backgroundColor('#E5E5E5')
}
.height(184)
.clip(new Rect({ width: '100%', height: '100%', radius: 12 }))
.onClick(() => {
router.pushUrl({ url: 'pages/FoodDetail', params: { foodId: this.foodItem } })
})
}
}
@Component
struct FoodGrid {
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'sm'
private foodItems?: FoodInfo[]
build() {
Grid() {
ForEach(this.foodItems!, (item: FoodInfo) => {
GridItem() {
FoodGridItem({ foodItem: item })
}
})
}
.columnsTemplate(new BreakPointType({
sm: '1fr 1fr',
md: '1fr 1fr 1fr',
lg: '1fr 1fr 1fr 1fr'
}).getValue(this.currentBreakpoint) as string)
.columnsGap(8)
.rowsGap(8)
.padding({ left: 16, right: 16 })
}
}
@Component
struct CategoryModeFoods {
@State currentTabIndex: number = 0
private foodItems: FoodInfo[] = getFoods()
private foodCategories: Category[] = getFoodCategories()
/* *
* 头部分类导航栏
*
* */
@Builder
tabBarItemBuilder(value: Resource, index: number) {
Text(value)
.fontColor(this.currentTabIndex === index ? Color.Blue : 'rgba(0,0,0,0.6)')
.fontSize(this.currentTabIndex === index ? 20 : 18)
.fontWeight(this.currentTabIndex === index ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
.height(56)
}
build() {
Tabs() {
TabContent() {
FoodGrid({ foodItems: this.foodItems })
}.tabBar(this.tabBarItemBuilder($r('app.string.category_all'), 0))
ForEach(this.foodCategories, (foodCategory: Category, index?: number) => {
TabContent() {
FoodGrid({ foodItems: this.foodItems.filter(item => (item.categoryId === foodCategory.id)) })
}.tabBar(this.tabBarItemBuilder(foodCategory.name!,
index! + 1))
})
}
.animationDuration(0)
.barWidth('80%')
.onChange((index) => {
this.currentTabIndex = index
})
}
}
@Component
struct FoodsDisplay {
@State isCategoryMode: boolean = true
@State isMoreIconOnClick: boolean = false
@State isMoreIconOnHover: boolean = false
@State isMoreIconOnFocus: boolean = false
getMoreIconBgColor() {
if (this.isMoreIconOnClick) {
return $r('sys.color.ohos_id_color_click_effect')
} else if (this.isMoreIconOnHover) {
return $r('sys.color.ohos_id_color_hover')
} else {
return this.isCategoryMode ? Color.White : '#F1F3F5' || Color.Transparent
}
}
build() {
Stack({ alignContent: Alignment.TopEnd }) {
if (this.isCategoryMode) {
CategoryModeFoods()
} else {
ListModeFoods()
}
Row() {
Image($r("app.media.ic_switch"))
.height(24)
.width(24)
.margin({ left: 24, right: 24 })
.focusable(true)
}
.height(56)
.backgroundColor(this.getMoreIconBgColor())
.stateStyles({
focused: {
.border({
radius: $r('sys.float.ohos_id_corner_radius_clicked'),
color: $r('sys.color.ohos_id_color_focused_outline'),
style: BorderStyle.Solid
})
},
normal: {
.border({
radius: $r('sys.float.ohos_id_corner_radius_clicked'),
width: 0
})
}
})
.onFocus(() => this.isMoreIconOnFocus = true)
.onBlur(() => this.isMoreIconOnFocus = false)
.onHover((isOn) => this.isMoreIconOnHover = isOn)
.onClick(() => {
this.isCategoryMode = !this.isCategoryMode
})
}
}
}
@Entry
@Component
struct Home {
@State currentTabIndex: number = 0
@StorageProp('currentBreakpoint') currentBreakpoint: string = 'sm'
private breakpointSystem: BreakpointSystem = new BreakpointSystem()
/* *
* 主页和记录的tabs
* */
@Builder
bottomBarItemBuilder(name: Resource, icon: Resource, index: number) {
Flex({
direction: new BreakPointType({
sm: FlexDirection.Column,
md: FlexDirection.Row,
lg: FlexDirection.Column
}).getValue(this.currentBreakpoint),
justifyContent: FlexAlign.Center,
alignItems: ItemAlign.Center
}) {
Image(icon)
.height(24)
.width(24)
.fillColor(this.getTabBarColor(index))
Text(name)
.margin(new BreakPointType<Padding>({
sm: { top: 4 },
md: { left: 8 },
lg: { top: 4 }
}).getValue(this.currentBreakpoint) as Padding)
.fontSize(11)
.fontColor(this.getTabBarColor(index))
}
}
aboutToAppear() {
this.breakpointSystem.register()
}
aboutToDisappear() {
this.breakpointSystem.unregister()
}
build() {
Tabs({
barPosition: new BreakPointType({
sm: BarPosition.End,
md: BarPosition.End,
lg: BarPosition.Start
}).getValue(this.currentBreakpoint)
}) {
TabContent() {
FoodsDisplay()
}.tabBar(this.bottomBarItemBuilder($r("app.string.tab_bar_home"), $r("app.media.ic_bottom_home"), 0))
TabContent() {
Records()
}.tabBar(this.bottomBarItemBuilder($r("app.string.tab_bar_record"), $r("app.media.ic_bottom_record"), 1))
TabContent() {
PersonalCenter()
}.tabBar(this.bottomBarItemBuilder($r("app.string.tab_bar_me"), $r("app.media.ic_public_me"), 2))
}
.vertical(new BreakPointType({ sm: false, md: false, lg: true }).getValue(this.currentBreakpoint) as boolean)
.barWidth(new BreakPointType({ sm: '100%', md: '100%', lg: '56vp' }).getValue(this.currentBreakpoint) as string)
.barHeight(new BreakPointType({ sm: '56vp', md: '56vp', lg: '60%' }).getValue(this.currentBreakpoint) as string)
.animationDuration(300)
.onChange((index) => {
this.currentTabIndex = index
})
}
private getTabBarColor(index: number) {
return this.currentTabIndex == index ? $r('app.color.tab_bar_select_color') : $r('app.color.tab_bar_normal_color')
}
}
6、另外一个思路实现上拉加载,下拉刷新
根据List中的回调方法onScrollIndex()监听当前列表首尾索引,根据触摸事件onTouch()处理下拉和上拉。
const TopHeight = 200;
@Entry
@Component
struct Index {
@State list: Array<number> = []
// 列表y坐标偏移量
@State offsetY: number = 0
// 按下的y坐标
private downY = 0
// 上一次移动的y坐标
private lastMoveY = 0
// 当前列表首部的索引
private startIndex = 0
// 当前列表尾部的索引
private endIndex = 0
// 下拉刷新的布局高度
private pullRefreshHeight = 70
// 下拉刷新文字:下拉刷新、松开刷新、正在刷新、刷新成功
@State pullRefreshText: string= '下拉刷新'
// 下拉刷新图标:与文字对应
@State pullRefreshImage: Resource = $r("app.media.ic_pull_refresh_down")
// 是否可以刷新:未达到刷新条件,收缩回去
private isCanRefresh = false
// 是否正在刷新:刷新中不进入触摸逻辑
private isRefreshing: boolean = false
// 是否已经进入了下拉刷新操作
private isPullRefreshOperation = false
// 上拉加载的布局默认高度
private loadMoreHeight = 70
// 上拉加载的布局是否显示
@State isVisibleLoadMore: boolean = false
// 是否可以加载更多
private isCanLoadMore = false
// 是否加载中:加载中不进入触摸逻辑
private isLoading: boolean = false
// 自定义下拉刷新布局
@Builder CustomPullRefreshLayout(){
Flex({ justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) {
Image(this.pullRefreshImage)
.width(18)
.height(18)
Text(this.pullRefreshText)
.margin({ left: 7, bottom: 1 })
.fontSize(17)
}
.width('100%')
.height(this.pullRefreshHeight)
// 布局跟着列表偏移量移动
.offset({ x: 0, y: `${vp2px(-this.pullRefreshHeight) + this.offsetY}px` })
}
// 自定义加载更多布局
@Builder CustomLoadMoreLayout(){
Flex({ justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) {
Image($r("app.media.ic_loading"))
.width(18)
.height(18)
Text('加载更多中...')
.margin({ left: 7, bottom: 1 })
.fontSize(17)
}
.width('100%')
.height(this.loadMoreHeight)
.backgroundColor('#f4f4f4')
.visibility(this.isVisibleLoadMore ? Visibility.Visible : Visibility.None)
}
// 刷新测试数据
private refreshData(){
this.list = []
for (var i = 0; i < 15; i++) {
this.list.push(i)
}
}
// 加载更多测试数据
private loadMoreData(){
let initValue = this.list[this.list.length-1] + 1
for (var i = initValue; i < initValue + 10; i++) {
this.list.push(i)
}
}
build() {
Column() {
// 下拉刷新布局
this.CustomPullRefreshLayout()
// 列表布局
List() {
ForEach(this.list, item => {
ListItem() {
Column() {
Text(`Item ${item}`)
.padding(15)
.fontSize(18)
}
}
}, item => item.toString())
// 加载更多布局
ListItem(){
this.CustomLoadMoreLayout()
}
}
.backgroundColor(Color.White) // 背景
.divider({ color: '#e2e2e2', strokeWidth: 1 }) // 分割线
.edgeEffect(EdgeEffect.None) // 去掉回弹效果
.offset({ x: 0, y: `${this.offsetY - TopHeight}px` }) // touch事件计算的偏移量单位是px,记得加上单位
.onScrollIndex((start, end) => { // 监听当前列表首位索引
console.info(`${start}=start============end=${end}`)
this.startIndex = start
this.endIndex = end
})
}
.width('100%')
.height('100%')
.backgroundColor('#f4f4f4')
.onTouch((event) => this.listTouchEvent(event))// 父容器设置touch事件,当列表无数据也可以下拉刷新
.onAppear(() => {
this.refreshData()
})
}
// 触摸事件
listTouchEvent(event: TouchEvent){
switch (event.type) {
case TouchType.Down: // 手指按下
// 记录按下的y坐标
this.downY = event.touches[0].y
this.lastMoveY = event.touches[0].y
break
case TouchType.Move: // 手指移动
// 下拉刷新中 或 加载更多中,不进入处理逻辑
if(this.isRefreshing || this.isLoading){
console.info('========Move刷新中,返回=========')
return
}
// 判断手势
let isDownPull = event.touches[0].y - this.lastMoveY > 0
// 下拉手势 或 已经进入了下拉刷新操作
if ((isDownPull || this.isPullRefreshOperation) && !this.isCanLoadMore) {
this.touchMovePullRefresh(event)
} else {
this.touchMoveLoadMore(event)
}
this.lastMoveY = event.touches[0].y
break
case TouchType.Up: // 手指抬起
case TouchType.Cancel: // 触摸意外中断:来电界面
// 刷新中 或 加载更多中,不进入处理逻辑
if(this.isRefreshing || this.isLoading){
console.info('========Up刷新中,返回=========')
return
}
if (this.isPullRefreshOperation) {
this.touchUpPullRefresh()
} else {
this.touchUpLoadMore()
}
break
}
}
//============================================下拉刷新==================================================
// 手指移动,处理下拉刷新
touchMovePullRefresh(event:TouchEvent){
// 当首部索引位于0
if (this.startIndex == 0) {
this.isPullRefreshOperation = true
// 下拉刷新布局高度
var height = vp2px(this.pullRefreshHeight)
// 滑动的偏移量
this.offsetY = event.touches[0].y - this.downY
// 偏移量大于下拉刷新布局高度,达到刷新条件
if (this.offsetY >= height) {
// 状态1:松开刷新
this.pullRefreshState(1)
// 偏移量的值缓慢增加
this.offsetY = height + this.offsetY * 0.15
} else {
// 状态0:下拉刷新
this.pullRefreshState(0)
}
if (this.offsetY < 0) {
this.offsetY = 0
this.isPullRefreshOperation = false
}
}
}
// 手指抬起,处理下拉刷新
touchUpPullRefresh(){
// 是否可以刷新
if (this.isCanRefresh) {
console.info('======执行下拉刷新========')
// 偏移量为下拉刷新布局高度
this.offsetY = vp2px(this.pullRefreshHeight)
// 状态2:正在刷新
this.pullRefreshState(2)
// 模拟耗时操作
setTimeout(() => {
this.refreshData()
this.closeRefresh()
}, 2000)
} else {
console.info('======关闭下拉刷新!未达到条件========')
// 关闭刷新
this.closeRefresh()
}
}
// 下拉刷新状态
// 0下拉刷新、1松开刷新、2正在刷新、3刷新成功
pullRefreshState(state:number){
switch (state) {
case 0:
// 初始状态
this.pullRefreshText = '下拉刷新'
this.pullRefreshImage = $r("app.media.ic_pull_refresh_down")
this.isCanRefresh = false
this.isRefreshing = false
break;
case 1:
this.pullRefreshText = '松开刷新'
this.pullRefreshImage = $r("app.media.ic_pull_refresh_up")
this.isCanRefresh = true
this.isRefreshing = false
break;
case 2:
this.offsetY = vp2px(this.pullRefreshHeight)
this.pullRefreshText = '正在刷新'
this.pullRefreshImage = $r("app.media.ic_loading")
this.isCanRefresh = true
this.isRefreshing = true
break;
case 3:
this.pullRefreshText = '刷新成功'
this.pullRefreshImage = $r("app.media.ic_refresh_succeed")
this.isCanRefresh = true
this.isRefreshing = true
break;
}
}
// 关闭刷新
closeRefresh() {
// 如果允许刷新,延迟进入,为了显示刷新中
setTimeout(() => {
var delay = 50
if (this.isCanRefresh) {
// 状态3:刷新成功
this.pullRefreshState(3)
// 为了显示刷新成功,延迟执行收缩动画
delay = 500
}
animateTo({
duration: 150, // 动画时长
delay: delay, // 延迟时长
onFinish: () => {
// 状态0:下拉刷新
this.pullRefreshState(0)
this.isPullRefreshOperation = false
}
}, () => {
this.offsetY = 0
})
}, this.isCanRefresh ? 500 : 0)
}
//============================================加载更多==================================================
// 手指移动,处理加载更多
touchMoveLoadMore(event:TouchEvent) {
// 因为加载更多是在列表后面新增一个item,当一屏能够展示全部列表,endIndex 为 length+1
if (this.endIndex == this.list.length - 1 || this.endIndex == this.list.length) {
// 滑动的偏移量
this.offsetY = event.touches[0].y - this.downY
if (Math.abs(this.offsetY) > vp2px(this.loadMoreHeight)/2) {
// 可以刷新了
this.isCanLoadMore = true
// 显示加载更多布局
this.isVisibleLoadMore = true
// 偏移量缓慢增加
this.offsetY = - vp2px(this.loadMoreHeight) + this.offsetY * 0.1
}
}
}
// 手指抬起,处理加载更多
touchUpLoadMore() {
animateTo({
duration: 200, // 动画时长
}, () => {
// 偏移量设置为0
this.offsetY = 0
})
if (this.isCanLoadMore) {
console.info('======执行加载更多========')
// 加载中...
this.isLoading = true
// 模拟耗时操作
setTimeout(() => {
this.closeLoadMore()
this.loadMoreData()
}, 2000)
} else {
console.info('======关闭加载更多!未达到条件========')
this.closeLoadMore()
}
}
// 关闭加载更多
closeLoadMore() {
this.isCanLoadMore = false
this.isLoading = false
this.isVisibleLoadMore = false
}
}