阅读量:0
列表下拉刷新、上拉加载更多,不管在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 } }