HarmonyOS NEXT 应用开发实战(八、知乎日报List列表下拉刷新及上滑加载更多分页的实现)

在现代应用开发中,列表展示是非常普遍且重要的需求。无论是新闻、社交媒体,还是电商平台,用户通常都希望能够便捷地浏览大量信息。因此,如何高效地实现列表的加载和分页功能显得尤为重要。在这篇文章中,我们将以"知乎日报"的例子,介绍如何使用HarmonyOS NEXT框架实现一个支持下拉刷新和上滑加载更多的列表。

本文主要介绍 ArkUI 开发中最常用的场景下拉刷新,上拉加载,在本文中介绍的内容在实际开发过程当中会高频的使用,特此记录下来留作备忘。 下文以知乎日报的小项目为例子,详细介绍下List组件的下拉刷新和上滑加载更多的实现。

List组件的基本使用

List 组件用于展示一系列相似的内容,并支持垂直或水平滚动。它的优点在于可以高效地处理大量数据,自动优化性能,避免性能瓶颈。

在HarmonyOS中,List组件是用来展示一系列数据项的核心组件。在实现知乎日报时,我们需要展示新闻列表,并且支持分页加载。

TypeScript 复制代码
// 基本结构
// List 组件通常包括 List 和 ListItem。ListItem 用于定义每一项的数据展示。

List() {
    ForEach(dataArray, (item) => {
        ListItem() {
            // 这里可以定义每一项的布局和样式
            Text(item.title);
        }
    });
}
.divider({strokeWidth:2,color:'#F1F3F5'}) //设置分割线
.listDirection(Axis.Vertical)
//这里的alwaysEnabled:true参数重要,后面实现分页加载的关键,否则容易掉坑里
.edgeEffect(EdgeEffect.Spring, {alwaysEnabled:true}) 
.layoutWeight(1) //铺满剩余空间,这个也重要,否则容易掉坑,导致你的list无法铺满剩余空间

组件生命周期

组件的声明周期也很重要,我们需要在组件的生命周期中,初始化并获取知乎的最新新闻列表。这部分主要包括调用API接口获取数据,并进行数据的格式化处理。

javascript 复制代码
aboutToAppear() {
    ...
    getZhiHuNews(this.currentDate.replace(/-/g, '')).then((res) => {
        this.zhiNews = res.data.stories;
        ...
    }).catch((err) => {
        ...
    });
}

下拉刷新的实现

下拉刷新的实现相对简单。在 ArkTS 中,使用 List 组件进行下拉刷新是一种常见且简单的实现方式。通过将 List 组件嵌套在 Refresh 组件中,可以快速实现下拉刷新的功能。本文将详细介绍如何实现这一功能。

基本原理

下拉刷新功能的实现主要依赖于 Refresh 组件。通过设置 refreshing 属性来表示当前是否处于刷新状态,并可以通过 onRefreshing 事件处理程序来执行具体的业务逻辑。

基本实现步骤

1.定义状态管理

首先,需要定义一个状态变量来控制刷新状态。可以在组件中使用 @State 装饰器来声明一个布尔值,例如:

TypeScript 复制代码
@State isRefreshing: boolean = false;

2.包裹 Refresh 组件

在 List 组件外层包裹一个 Refresh 组件,设置其属性以控制刷新状态、偏移量等。可以设置以下属性:

  • refreshing: 当前是否在刷新状态。
  • offset: 下拉的触发偏移量,通常设置为 120。
  • friction: 下拉的摩擦力,可以调整下拉的灵敏度。

下面是包裹的示例代码:

TypeScript 复制代码
Refresh({ refreshing: $$this.isRefreshing, offset: 120, friction: 100, builder: this.LoadingCustom() }) {
    List() {
        // 这里放置 List 组件的内容
    }
}

3.处理刷新逻辑

通过 onRefreshing 方法来处理业务逻辑,例如加载新数据。可以在这个方法中进行数据请求并更新状态:

TypeScript 复制代码
.onRefreshing(() => {
    this.isRefreshing = true; // 进入刷新状态
    // 模拟网络请求
    setTimeout(() => {
        // 这里执行数据获取逻辑,完成后关闭刷新状态
        this.isRefreshing = false; // 结束刷新状态
    }, 2000); // 例如,2秒后结束刷新
});

完整示例代码

TypeScript 复制代码
import { getZhiHuNews } from '../../common/api/zhihu';
import { BaseResponse,ErrorResp,ZhiNewsItem } from '../../common/bean/ApiTypes';
import { Log } from '../../utils/logutil'
import { formatDate2 } from '../../utils/time';
import { promptAction } from "@kit.ArkUI";

@Component
export default class NewsList extends Component {
    @State isRefreshing: boolean = false;
    @State newsData = [
        { id: 1, title: '新闻标题 1' },
        { id: 2, title: '新闻标题 2' },
        { id: 3, title: '新闻标题 3' },
    ];

    // 自定义加载效果
    private LoadingCustom() {
        return Text("加载中..."); // 可以自定义加载组件
    }

    build() {
        return (
            Refresh({ refreshing: this.isRefreshing, offset: 120, friction: 100, builder: this.LoadingCustom() }) {
                List() {
                    ForEach(this.newsData, (item) => {
                        ListItem() {
                            Text(item.title).fontSize(20).padding(10);
                        }
                    }, (item) => item.id);
                }
                .onRefreshing(() => {
                    this.isRefreshing = true; // 开始刷新
                    // 模拟数据请求
                    setTimeout(() => {
                        // 这里应该是更新数据逻辑
                        this.isRefreshing = false; // 刷新结束
                    }, 2000);
                });
            }
        );
    }
}

上滑加载更多的实现

上滑加载更多的实现,这确实是个技巧,网上找到的方法大都太繁琐。比如有的是让在onReachEnd中去用分页加载更多,但是这个页面更加载就会进入,之后可能不再触发了。还有的实现方法竟让计算滚动条的x,y位置去比较,太麻烦了,官方也没有提供一个好用的方法。

这里分享一个简单的方法,但需要注意一些细节,算是避坑指南吧,否则容易掉坑里。比如为啥一直无法j触发加载更多啊,List无法铺满剩余空间了等问题。如果谁有更简单更好用的方法,欢迎交流和推荐。

简单的实现方法

1. 核心逻辑

我们可以通过监听滚动事件,判断是否触底来实现加载更多的功能。以下是实现步骤:

  • 状态管理 :使用一个状态变量 isEnd 来指示是否已经到达底部。
  • Scroller 对象:用来处理滚动事件。
  • 事件监听:注册几个关键的事件监听器,以便在合适的时机触发数据加载。
2. 示例代码

以下是实现上滑加载更多的基本代码示例:

TypeScript 复制代码
@Component
export default class NewsList extends Component {

    @State isEnd: boolean = false; // 控制是否触底

    private newsData = [ /* 初始化数据 */ ];

    private scroller: Scroller = new Scroller();

    build() {
        return (
            List() {
                ForEach(this.newsData, (item) => {
                    ListItem() {
                        Text(item.title).fontSize(20).padding(10);
                    }
                }, (item) => item.id);
            }
            .onScrollStart(() => {
                this.isEnd = false; // 当开始滚动时,重置触底状态
            })
            .onScrollStop(() => {
                // 当停止滚动时,如果记录为触底,则加载更多
                if (this.isEnd) {
                    //在这里处理分页,加载更多
                    this.getMoreNews();
                }
            })
            .onReachEnd(() => {
                this.isEnd = true; // 一旦触底,设置状态为 true
                console.log("onReachEnd");
            });
    }

    // 加载更多数据的逻辑
    private getMoreNews() {
        // 这里可以调用API获取更多的数据
        console.log("Loading more news...");
        // 假设请求完成后,更新 this.newsData 数据
        console.log('getMoreNews:')
        promptAction.showToast({ message: '加载数据中' })
       // 注意加载完数据把滚动条移至底部
       this.scroller.scrollEdge(Edge.Bottom);
    }
}
3.避坑指南

这里有个坑,是什么呢?就是你的List铺不满的话,是不支持滑动的,也就无法触发onScrollStart和Stop等事件。如何解决呢?.edgeEffect(EdgeEffect.Spring, {alwaysEnabled:true})就起了关键作用,尤其是后面的alwaysEnabled:true很容易被漏掉。

日报新闻页完整实现

TypeScript 复制代码
import {getSwiperList} from "../../common/api/home"
import { getZhiHuNews } from '../../common/api/zhihu';
import { BaseResponse,ErrorResp,ZhiNewsItem } from '../../common/bean/ApiTypes';
import { Log } from '../../utils/logutil'
import { formatDate2 } from '../../utils/time';
import { promptAction } from "@kit.ArkUI";


class BasicDataSource<T> implements IDataSource {

  private listeners: DataChangeListener[] = [];
  private originDataArray: T[] = [];

  totalCount(): number {
    return this.originDataArray.length;
  }

  getData(index: number): T {
    return this.originDataArray[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.slice(pos, 1);
    }
  }

  // 通知LazyForEach组件需要重新重载所有子组件
  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    })
  }

  // 通知LazyForEach组件需要在index对应索引处添加子组件
  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index);
    })
  }
}

class SwiperDataSource<T> extends BasicDataSource<T> {

  private dataArray: T[] = [];

  totalCount(): number {
    return this.dataArray.length;
  }

  getData(index: number): T {
    return this.dataArray[index];
  }

  // 在列表末尾添加数据并通知监听器
  pushData(data: T): void {
    this.dataArray.push(data);
    this.notifyDataAdd(this.dataArray.length - 1);
  }

  // 重载数据
  reloadData(): void {
    // 不会引起状态变化
    this.dataArray = [];
    // 必须通过DataChangeListener来更新
    this.notifyDataReload();
  }
}

@Component
export default struct ZhiHu{
  @State message: string = 'Hello World';
  private swiperController: SwiperController = new SwiperController()
  private swiperData: SwiperDataSource<ZhiNewsItem> = new SwiperDataSource()
  @State zhiNews:ZhiNewsItem[] = []

  private currentDate= '' // 初始化为今天的日期
  private previousDate= '' // 上一天的日期
  pageStack: NavPathStack = new NavPathStack()

  @State isRefreshing: boolean = false

  // 判断滚动条是否触底
  @State
  private isEnd: boolean = false;
  // 使用Scroller对象
  scroller: Scroller = new Scroller();

  // 组件生命周期
  aboutToAppear() {
    Log.info('ZhiHu aboutToAppear');
    this.currentDate = formatDate2(new Date())
    getSwiperList().then((res) => {
      Log.debug(res.data.message)
      Log.debug("request","res.data.code:%{public}d",res.data.code)
      Log.debug("request","res.data.data[0]:%{public}s",res.data.data[0].id)
      Log.debug("request","res.data.data[0]:%{public}s",res.data.data[0].imageUrl)
      Log.debug("request","res.data.data[0]:%{public}s",res.data.data[0].title)
    }).catch((err:BaseResponse<ErrorResp>) => {
      Log.debug("request","err.data.code:%d",err.data.code)
      Log.debug("request",err.data.message)
    });

    //获取知乎新闻列表
    //this.finished = false
    getZhiHuNews(this.currentDate.replace(/-/g, '')).then((res) => {
      Log.debug(res.data.message)
      Log.debug("request","res.data.code:%{public}d",res.data.code)
      res.data.stories.map((item, index) => {
        item.isShowDivider = false
        if (item.date !== this.previousDate) {
          this.previousDate = item.date;
          item.isShowDivider = true
        }
      });
      this.zhiNews = res.data.stories
      for (const itm of res.data.top_stories) {
        this.swiperData.pushData(itm)
      }
      //this.getMoreNews();

    }).catch((err:BaseResponse<ErrorResp>) => {
      Log.debug("request","err.data.code:%d",err.data.code)
      Log.debug("request",err.data.message)
    });
  }

  // 组件生命周期
  aboutToDisappear() {
    Log.info('ZhiHu aboutToDisappear');
  }

  // 触底之后触发下一页
  getMoreNews():void {
    console.log('getMoreNews:')
    promptAction.showToast({ message: '加载数据中' })
    const date_ = new Date(this.currentDate);
    date_.setDate(date_.getDate() - 1); // 日期减一
    //console.log(date_);
    let currentDate_ = formatDate2(date_);
    console.log('currentDate_:'+currentDate_);
    getZhiHuNews(currentDate_.replace(/-/g, '')).then((result) => {
      console.log("getZhiHuNews,result:");
      // 加载完数据把滚动条移至底部
      this.scroller.scrollEdge(Edge.Bottom);
      console.log(result.data.message);
      this.currentDate = formatDate2(date_);
      if (result.data.stories != null){
        result.data.stories.map((item, index) => {
          item.isShowDivider = false
          if (item.date !== this.previousDate) {
            this.previousDate = item.date;
            item.isShowDivider = true
          }
        })
        this.zhiNews = this.zhiNews.concat(result.data.stories);
      }
    });
  }

  @Builder
  private LoadingCustom() {
    Stack() {
      Row() {
        LoadingProgress().width(30).color("#4095cb")
      }
    }.width('100%')
  }

  build() {
    Navigation(this.pageStack){
        Column({ space: 0 }) {

          // 内容项
          Swiper(this.swiperController) {
            LazyForEach(this.swiperData, (item: ZhiNewsItem) => {
              Stack({ alignContent: Alignment.Center }) {
                Image(item.image)
                  .width('100%')
                  .height(200)
                  .backgroundColor(0xAFEEEE)
                  .zIndex(1)
                  .onClick(() => {
                    //this.pageStack.pushPathByName("PageOne", item)
                    this.pageStack.pushDestinationByName("PageOne", { id:"9773231" }).catch((e:Error)=>{
                      // 跳转失败,会返回错误码及错误信息
                      console.log(`catch exception: ${JSON.stringify(e)}`)
                    }).then(()=>{
                      // 跳转成功
                    });
                  })

                // 显示轮播图标题
                Text(item.title)
                  .padding(5)
                  .margin({ top:60 })
                  .width('100%').height(50)
                  .textAlign(TextAlign.Center)
                  .maxLines(2)
                  .textOverflow({overflow:TextOverflow.Clip})
                  .fontSize(20)
                  .fontColor(Color.White)
                  .opacity(100) // 设置标题的透明度 不透明度设为100%,表示完全不透明
                  .backgroundColor('#808080AA') // 背景颜色设为透明
                  .zIndex(2)
              }
            }, (item: ZhiNewsItem) => item.id)
          }
          .cachedCount(2)
          .index(1)
          .autoPlay(true)
          .interval(4000)
          .loop(true)
          .indicatorInteractive(true)
          .duration(1000)
          .itemSpace(0)
          .curve(Curve.Linear)
          .onChange((index: number) => {
            console.info(index.toString())
          })
          .onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {
            console.info("index: " + index)
            console.info("current offset: " + extraInfo.currentOffset)
          })
          .height(200) // 设置高度

          Refresh({ refreshing: $$this.isRefreshing, offset: 120, friction: 100,builder: this.LoadingCustom() }) {
            // list组件
            List({ space: 1 }) {
              ForEach(this.zhiNews, (item:ZhiNewsItem) => {
                ListItem() {
                  Column({ space: 0 }) {
                    Row() {
                      Column({ space: 15 }) {
                        Text(item.title).fontSize(16).fontWeight(FontWeight.Bold).align(Alignment.Start).width('100%')
                        Text(item.hint).align(Alignment.Start).width('100%')
                      }.justifyContent(FlexAlign.Start).width('70%').padding(5)

                      Image(item.image).objectFit(ImageFit.Cover).borderRadius(5).height(100).padding(2)

                    }.size({ width: '100%', height: 100 })

                  }.size({ width: '100%', height: 100 })
                }
              }, (itm:ZhiNewsItem) => itm.id)
            }
            .divider({strokeWidth:2,color:'#F1F3F5'})
            .listDirection(Axis.Vertical)
            .edgeEffect(EdgeEffect.Spring, {alwaysEnabled:true})
            // 当画面能滚动说明没有触底
            .onScrollStart(() => {
              this.isEnd = false
            })
            // 判断当前是否停止滚动
            .onScrollStop(() => {
              // 如果停止滚动并且满足滚动条已经在底部进行数据的加载
              if (this.isEnd) {
                // 加载数据
                this.getMoreNews();
              }
            })
            // 当滚动条触底把 flag 设置成 true
            .onReachEnd(() => {
              this.isEnd = true
              console.log("onReachEnd")
            })
            .onScrollIndex((firstIndex: number, lastIndex: number) => {
              console.info('first' + firstIndex)
              console.info('last' + lastIndex)
              //this.getmorePage()
            })


            //.backgroundColor('#ff940a31')
          }
          .onStateChange((refreshStatus: RefreshStatus) => {
            console.info('Refresh onStatueChange state is ' + refreshStatus)
          })
          // 设置触发刷新的下拉偏移量
          .refreshOffset(64)
          // 设置当下拉距离超过refreshOffset时是否触发刷新
          .pullToRefresh(true)
          .onRefreshing(() => {
            setTimeout(() => {
              this.isRefreshing = false
            }, 1000)
            console.log('onRefreshing test')
          })

        }
        //.backgroundColor('#ff94610a')

    }
    .mode(NavigationMode.Stack)
    .titleMode(NavigationTitleMode.Mini)
    .title("知乎日报")
    .hideBackButton(true)
    //.backgroundColor('#ff0a3894')
    .width('100%').height('100%')

  }
}

总结

通过以上的实现,我们成功构建了一个支持下拉刷新和上滑加载更多功能的知乎日报应用。使用HarmonyOS NEXT的List组件及自定义数据源,可以有效地管理列表数据,并提供良好的用户体验。希望大家在实际开发中能够灵活应用这些技巧,构建出更优秀的应用!

写在最后

最后,推荐下笔者的业余开源app影视项目"爱影家",推荐分享给与我一样喜欢免费观影的朋友。【注】:该项目仅限于学习研究使用!请勿用于其他用途!

开源地址: 爱影家app开源项目介绍及源码

https://gitee.com/yyz116/imovie

其他资源

文档中心

OpenHarmony三方库中心仓--abner/refresh(V1.3.7)

【鸿蒙实战开发】数据的下拉刷新与上拉加载_鸿蒙下拉刷新-CSDN博客

相关推荐
小冷爱学习!5 小时前
华为动态路由-OSPF-完全末梢区域
服务器·网络·华为
2501_904447746 小时前
华为发力中端,上半年nova14下半年nova15,大力普及原生鸿蒙
华为·智能手机·django·scikit-learn·pygame
MarkHD7 小时前
第十八天 WebView深度优化指南
华为·harmonyos
塞尔维亚大汉7 小时前
OpenHarmony(鸿蒙南向)——平台驱动开发【MIPI CSI】
harmonyos·领域驱动设计
别说我什么都不会8 小时前
鸿蒙轻内核M核源码分析系列十五 CPU使用率CPUP
操作系统·harmonyos
feiniao86519 小时前
2025年华为手机解锁BL的方法
华为·智能手机
塞尔维亚大汉10 小时前
OpenHarmony(鸿蒙南向)——平台驱动开发【I3C】
harmonyos·领域驱动设计
VVVVWeiYee10 小时前
BGP配置华为——路径优选验证
运维·网络·华为·信息与通信
今阳11 小时前
鸿蒙开发笔记-6-装饰器之@Require装饰器,@Reusable装饰器
android·app·harmonyos
余多多_zZ12 小时前
鸿蒙初学者学习手册(HarmonyOSNext_API14)_组件截图(@ohos.arkui.componentSnapshot (组件截图) )
学习·华为·harmonyos·鸿蒙·鸿蒙系统