【HarmonyOS开发】ArkUI实现下拉刷新/上拉加载

avatar
作者
猴君
阅读量:0

 列表下拉刷新、上拉加载更多,不管在web时代还是鸿蒙应用都是一个非常常用的功能,基于ArkUI中TS扩展的声明式开发范式实现一个下拉刷新,上拉加载。

上拉加载、下拉刷新

如果数据量过大,可以使用LazyForEach代替ForEach

高阶组件-上拉加载,下拉刷新icon-default.png?t=N7T8https://gitee.com/bingtengaoyu/harmonyos-advanced-componen/tree/master/UpDownRefresh 

1、涉及的知识点

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

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!