文档管理中心

Repeat:可复用的循环渲染

说明

Repeat从API version 12开始支持。

本文档仅为开发者指南。API参数说明见:Repeat API参数说明

概述

Repeat基于数组类型数据来进行循环渲染,一般与容器组件配合使用。Repeat组件包含两种模式:non-virtualScroll模式virtualScroll模式

  • non-virtualScroll模式:Repeat在初始化页面时加载列表中的所有子组件,适合短数据列表/组件全部加载的场景。详细描述见non-virtualScroll模式
  • virtualScroll模式(开启virtualScroll开关):Repeat根据容器组件的有效加载范围(可视区域+预加载区域) 加载子组件。当容器滑动/数组改变时,Repeat会根据父容器组件传递的参数重新计算有效加载范围,实时管理列表节点的创建与销毁。

    该模式适合需要懒加载的长数据列表/通过组件复用优化性能表现的场景。详细描述见virtualScroll模式

说明

Repeat与ForEach、LazyForEach的区别:

  • 相较于ForEach组件,non-virtualScroll模式在以下两个维度实现了优化升级:首先,针对特定数组更新场景的渲染性能进行了优化;其次,将子组件的内容/索引管理职责转移至框架层面。
  • 相较于LazyForEach组件,virtualScroll模式直接监听状态变量的变化,而LazyForEach需要开发者实现IDataSource接口,手动管理子组件内容/索引的修改。除此之外,Repeat还增强了节点复用能力,提高了长列表滑动和数据更新的渲染性能。Repeat增加了模板(template)的能力,在同一个数组中,根据开发者自定义的模板类型(template type)渲染不同的子组件。

下面的示例代码使用Repeat组件的virtualScroll模式进行循环渲染。

收起
自动换行
深色代码主题
复制
  1. // 在List容器组件中使用Repeat virtualScroll模式
  2. @Entry
  3. @ComponentV2 // 推荐使用V2装饰器
  4. struct RepeatExample {
  5. @Local dataArr: Array<string> = []; // 数据源
  6. aboutToAppear(): void {
  7. for (let i = 0; i < 50; i++) {
  8. this.dataArr.push(`data_${i}`); // 为数组添加一些数据
  9. }
  10. }
  11. build() {
  12. Column() {
  13. List() {
  14. Repeat<string>(this.dataArr)
  15. .each((ri: RepeatItem<string>) => { // 默认模板
  16. ListItem() {
  17. Text('each_A_' + ri.item).fontSize(30).fontColor(Color.Red) // 文本颜色为红色
  18. }
  19. })
  20. .key((item: string, index: number): string => item) // 键值生成函数
  21. .virtualScroll({ totalCount: this.dataArr.length }) // 打开virtualScroll模式,totalCount为期望加载的数据长度
  22. .templateId((item: string, index: number): string => { // 根据返回值寻找对应的模板子组件进行渲染
  23. return index <= 4 ? 'A' : (index <= 10 ? 'B' : ''); // 前5个节点模板为A,接下来的5个为B,其余为默认模板
  24. })
  25. .template('A', (ri: RepeatItem<string>) => { // 'A'模板
  26. ListItem() {
  27. Text('ttype_A_' + ri.item).fontSize(30).fontColor(Color.Green) // 文本颜色为绿色
  28. }
  29. }, { cachedCount: 3 }) // 'A'模板的缓存列表容量为3
  30. .template('B', (ri: RepeatItem<string>) => { // 'B'模板
  31. ListItem() {
  32. Text('ttype_B_' + ri.item).fontSize(30).fontColor(Color.Blue) // 文本颜色为蓝色
  33. }
  34. }, { cachedCount: 4 }) // 'B'模板的缓存列表容量为4
  35. }
  36. .cachedCount(2) // 容器组件的预加载区域大小
  37. .height('70%')
  38. .border({ width: 1 }) // 边框
  39. }
  40. }
  41. }

运行后界面如下图所示。

使用限制

  • Repeat一般与容器组件配合使用,子组件应当是允许包含在容器组件中的子组件,例如,Repeat与List组件配合使用时,子组件必须为ListItem组件。
  • 当Repeat与自定义组件或@Builder函数混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递RepeatItem.item或RepeatItem.index,将会出现UI渲染异常。详细见Repeat与@Builder混用的限制

Repeat virtualScroll模式使用限制:

  • 必须在滚动类容器组件内使用,仅有ListGridSwiper以及WaterFlow组件支持Repeat virtualScroll模式。
  • virtualScroll模式不支持V1装饰器,混用V1装饰器会导致渲染异常,不建议开发者同时使用。
  • 必须创建且只允许创建一个子组件,生成的子组件必须是允许包含在Repeat父容器组件中的子组件。
  • 滚动容器组件内只能包含一个Repeat。以List为例,同时包含ListItem、ForEach、LazyForEach的场景是不推荐的;同时包含多个Repeat也是不推荐的。
  • totalCount值大于数组长度时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。解决方案见totalCount值大于数据源长度

Repeat通过键值识别数组如何改变:增加了哪些数据、删除了哪些数据,以及哪些数据改变了位置(索引)。键值生成函数.key()的使用建议如下:

  • 即使数组发生变化,开发者也必须保证键值key唯一。
  • 每次执行.key()函数时,使用相同的数据项作为输入,输出必须是一致的。
  • 允许在.key()中使用index,但不建议开发者这样做。因为在数据项移动时索引index发生变化,key值因此改变,导致Repeat认为数据发生了变化,从而触发子组件重新渲染,降低性能表现。
  • 推荐将简单类型数组转换为类对象数组,并添加一个readonly id属性,在构造函数中给它赋一个唯一的值。
说明

Repeat子组件节点的操作分为四种:节点创建、节点更新、节点复用、节点销毁。其中,节点更新和节点复用的区别为:

  • 节点更新:组件节点不下树,只有状态变量刷新。
  • 节点复用:旧的节点下树,但不会销毁,存储在空闲节点缓存池;新节点从缓存池中获取可复用的节点,重新上树。

non-virtualScroll模式

键值生成规则

.key()方法为每一项数据生成一个键值。请注意键值(key)与索引(index)的区别:键值是数据项的唯一标识符,Repeat根据键值是否发生变化判断数据项是否更新;索引则只标识数据项在数据源中的位置。

.key()的逻辑如下图所示。

当.key()缺省时,Repeat会生成新的随机键值。当发现有重复key时,Repeat会在已有键值的基础上递归生成新的键值,直到没有重复键值。

子组件渲染逻辑

在Repeat首次渲染时,子组件全部创建。数组发生改变后,Repeat的处理分为以下几个步骤:

首先,遍历旧数组键值,如果新数组中没有该键值,将其加入键值集合deletedKeys。

其次,遍历新数组键值,依次判断以下条件,符合条件时进行对应的操作:

  1. 若在旧数组中能找到相同键值,直接使用对应的子组件节点,并更新索引index;
  2. 若deletedKeys非空,按照先进后出的顺序,更新该集合中的键值所对应的节点;
  3. 若deletedKeys为空,则表示没有可以更新的节点,需要创建新节点。

最后,如果新数组键值遍历结束后,deletedKeys非空,则销毁集合中的键值所对应的节点。

以下图中的数组变化为例。

根据上述判断逻辑,item_0没有变化,item_1和item_2只更新了索引,item_n1和item_n2分别由item_4和item_3进行节点更新获得,item_n3为新创建的节点。

virtualScroll模式

键值生成规则

和non-virtualScroll模式的逻辑基本一致,如下图所示。

.key()方法为每一项数据生成一个键值。请注意键值(key)与索引(index)的区别:键值是数据项的唯一标识符,Repeat根据键值是否发生变化判断数据项是否更新;索引则只标识数据项在数据源中的位置。

当.key()缺省时,Repeat会生成新的随机键值。当存在重复key时,Repeat会重新生成随机key作为当前数据项的键值并且放进该列表。列表中已有的键值不受影响。随机key的构成:___${index}_+_${key}_+_${Math.random()},其中的变量依次为:索引、旧键值、随机数。

子组件渲染逻辑

在Repeat首次渲染时,根据容器组件的有效加载范围(可视区域+预加载区域)创建当前需要的子组件。

在容器滑动/数组改变时,将失效的子组件节点(离开有效加载范围)加入空闲节点缓存列表中(断开与组件树的关系,但不销毁),在需要生成新的组件时,对缓存里的组件进行复用(更新被复用子组件的变量值,重新上树)。

通过典型的滑动场景数据更新场景示例来展示virtualScroll模式下子组件的渲染逻辑。定义长度为20的数组,数组前5项的template type为aa,其余项为bb。aa缓存池容量为3,bb缓存池容量为4。容器组件的预加载区域大小为2。为了便于理解,在aa和bb缓存池中分别加入一个和两个空闲节点。

首次渲染,列表的节点状态如下图所示。

滑动场景

将屏幕向右滑动(屏幕内容右移)一个节点的距离,Repeat将开始复用缓存池中的节点。index=10的节点进入有效加载范围,计算出其template type为bb。由于bb缓存池非空,Repeat会从bb缓存池中取出一个空闲节点进行复用,更新其节点属性,该子组件中涉及数据item和索引index的其他孙子组件会根据V2状态管理的规则做同步更新。其他节点仍在有效加载范围,均只更新索引index。

index=0的节点滑出了有效加载范围。当UI主线程空闲时,会检查aa缓存池是否已满,此时aa缓存池未满,将该节点加入到对应的缓存池中。

如果此时对应template type的缓存池已满,Repeat会销毁掉多余的节点。

数据更新场景

在上一小节的基础上做如下的数组更新操作,删除index=4的节点,修改节点数据item_7为new_7。

首先,删除index=4的节点后,失效节点加入aa缓存池。后面的列表节点前移,新进入有效加载区域的节点item_11会复用bb缓存池中的空闲节点,其他节点均只更新索引index。如下图所示。

其次,节点item_5前移,索引index更新为4。根据template type的计算规则,节点item_5的template type变为aa,需要从aa缓存池中复用空闲节点,并且将旧节点加入bb缓存池。如下图所示。

template:子组件渲染模板

template模板目前只支持在virtualScroll模式下使用。

  • 每个节点会根据.templateId()得到template type,从而渲染对应的.template()中的子组件。
  • 当多个template type相同时,Repeat会覆盖先定义的.template()函数,仅生效最后定义的.template()。
  • 如果找不到对应的template type,Repeat会优先渲染type为空的.template()中的子组件,如果没有,则渲染.each()中的子组件。

totalCount:期望加载的数据长度

totalCount表示期望加载的数据长度,默认为原数组长度,可以大于已加载数据项的数量。令arr.length表示数据源长度,以下为totalCount的处理规则:

  • totalCount缺省/非自然数时,totalCount默认为arr.length,列表正常滚动。
  • 0 <= totalCount < arr.length时,界面中只渲染“totalCount”个数据。
  • totalCount > arr.length时,代表Repeat将渲染totalCount个数据,滚动条样式根据totalCount值变化。
注意

当totalCount > arr.length时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,开发者需要对数据请求的错误场景(如网络延迟)进行保护操作,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。

cachedCount:空闲节点缓存列表大小

cachedCount是相应的template type的缓存池中可缓存子组件节点的最大数量,仅在virtualScroll模式下生效。

说明

滚动容器组件属性.cachedCount()和Repeat组件属性.template()的参数cachedCount都是为了平衡性能和内存,但是含义是不同的。

  • 滚动类容器组件.cachedCount():是指在可见范围外预加载的节点,这些节点会位于组件树上,但不是可见范围内,List/Grid等容器组件会额外渲染这些可见范围外的节点,从而达到其性能收益。Repeat会将这些节点视为“可见”的。
  • .template()中的cachedCount: 是指Repeat视为“不可见”的节点,这些节点是空闲的,框架会暂时保存,在需要使用的时候更新这些节点,从而实现复用。

将cachedCount设置为当前模板的节点在屏上可能出现的最大数量时,Repeat可以做到尽可能多的复用。但后果是当屏上没有当前模板的节点时,缓存池也不会释放,应用内存会增大。需要开发者根据具体情况自行把控。

  • cachedCount缺省时,框架会分别对不同template,根据屏上节点+预加载的节点个数来计算cachedCount。当屏上节点+预加载的节点个数变多时,cachedCount也会对应增长。需要注意cachedCount数量不会减少。
  • 显式指定cachedCount,推荐设置成和屏幕上节点个数一致。需要注意,不推荐设置cachedCount小于2,因为这会导致在快速滑动场景下创建新的节点,从而导致性能劣化。

使用场景

non-virtualScroll数据展示&操作

数据源变化

收起
自动换行
深色代码主题
复制
  1. @Entry
  2. @ComponentV2
  3. struct Parent {
  4. @Local simpleList: Array<string> = ['one', 'two', 'three'];
  5. build() {
  6. Row() {
  7. Column() {
  8. Text('点击修改第3个数组项的值')
  9. .fontSize(24)
  10. .fontColor(Color.Red)
  11. .onClick(() => {
  12. this.simpleList[2] = 'new three';
  13. })
  14. Repeat<string>(this.simpleList)
  15. .each((obj: RepeatItem<string>)=>{
  16. ChildItem({ item: obj.item })
  17. .margin({top: 20})
  18. })
  19. .key((item: string) => item)
  20. }
  21. .justifyContent(FlexAlign.Center)
  22. .width('100%')
  23. .height('100%')
  24. }
  25. .height('100%')
  26. .backgroundColor(0xF1F3F5)
  27. }
  28. }
  29. @ComponentV2
  30. struct ChildItem {
  31. @Param @Require item: string;
  32. build() {
  33. Text(this.item)
  34. .fontSize(30)
  35. }
  36. }

第三个数组项重新渲染时会复用之前的第三项的组件,仅对数据做了刷新。

索引值变化

下方例子当交换数组项1和2时,若键值和上次保持一致,Repeat会复用之前的组件,仅对使用了index索引值的组件做数据刷新。

收起
自动换行
深色代码主题
复制
  1. @Entry
  2. @ComponentV2
  3. struct Parent {
  4. @Local simpleList: Array<string> = ['one', 'two', 'three'];
  5. build() {
  6. Row() {
  7. Column() {
  8. Text('交换数组项1,2')
  9. .fontSize(24)
  10. .fontColor(Color.Red)
  11. .onClick(() => {
  12. let temp: string = this.simpleList[2];
  13. this.simpleList[2] = this.simpleList[1];
  14. this.simpleList[1] = temp;
  15. })
  16. .margin({bottom: 20})
  17. Repeat<string>(this.simpleList)
  18. .each((obj: RepeatItem<string>)=>{
  19. Text("index: " + obj.index)
  20. .fontSize(30)
  21. ChildItem({ item: obj.item })
  22. .margin({bottom: 20})
  23. })
  24. .key((item: string) => item)
  25. }
  26. .justifyContent(FlexAlign.Center)
  27. .width('100%')
  28. .height('100%')
  29. }
  30. .height('100%')
  31. .backgroundColor(0xF1F3F5)
  32. }
  33. }
  34. @ComponentV2
  35. struct ChildItem {
  36. @Param @Require item: string;
  37. build() {
  38. Text(this.item)
  39. .fontSize(30)
  40. }
  41. }

virtualScroll数据展示&操作

本小节将展示virtualScroll模式下,Repeat的实际使用场景和组件节点的复用情况。根据复用规则可以衍生出大量的测试场景,篇幅原因,只对典型的数据变化进行解释。

一个template

下面的代码示例展示了Repeat virtualScroll模式下修改数组的常见操作,包括插入数据、修改数据、删除数据、交换数据。点击下拉框选择索引index值,点击相应的按钮即可进行数据修改操作。依次点击数据项可以交换被点击的两个数据项。

收起
自动换行
深色代码主题
复制
  1. @ObservedV2
  2. class Repeat005Clazz {
  3. @Trace message: string = '';
  4. constructor(message: string) {
  5. this.message = message;
  6. }
  7. }
  8. @Entry
  9. @ComponentV2
  10. struct RepeatVirtualScroll {
  11. @Local simpleList: Array<Repeat005Clazz> = [];
  12. private exchange: number[] = [];
  13. private counter: number = 0;
  14. @Local selectOptions: SelectOption[] = [];
  15. @Local selectIdx: number = 0;
  16. @Monitor("simpleList")
  17. reloadSelectOptions(): void {
  18. this.selectOptions = [];
  19. for (let i = 0; i < this.simpleList.length; ++i) {
  20. this.selectOptions.push({ value: i.toString() });
  21. }
  22. if (this.selectIdx >= this.simpleList.length) {
  23. this.selectIdx = this.simpleList.length - 1;
  24. }
  25. }
  26. aboutToAppear(): void {
  27. for (let i = 0; i < 100; i++) {
  28. this.simpleList.push(new Repeat005Clazz(`item_${i}`));
  29. }
  30. this.reloadSelectOptions();
  31. }
  32. handleExchange(idx: number): void { // 点击交换子组件
  33. this.exchange.push(idx);
  34. if (this.exchange.length === 2) {
  35. let _a = this.exchange[0];
  36. let _b = this.exchange[1];
  37. let temp: Repeat005Clazz = this.simpleList[_a];
  38. this.simpleList[_a] = this.simpleList[_b];
  39. this.simpleList[_b] = temp;
  40. this.exchange = [];
  41. }
  42. }
  43. build() {
  44. Column({ space: 10 }) {
  45. Text('virtualScroll each()&template() 1t')
  46. .fontSize(15)
  47. .fontColor(Color.Gray)
  48. Text('Select an index and press the button to update data.')
  49. .fontSize(15)
  50. .fontColor(Color.Gray)
  51. Select(this.selectOptions)
  52. .selected(this.selectIdx)
  53. .value(this.selectIdx.toString())
  54. .key('selectIdx')
  55. .onSelect((index: number) => {
  56. this.selectIdx = index;
  57. })
  58. Row({ space: 5 }) {
  59. Button('Add No.' + this.selectIdx)
  60. .onClick(() => {
  61. this.simpleList.splice(this.selectIdx, 0, new Repeat005Clazz(`${this.counter++}_add_item`));
  62. this.reloadSelectOptions();
  63. })
  64. Button('Modify No.' + this.selectIdx)
  65. .onClick(() => {
  66. this.simpleList.splice(this.selectIdx, 1, new Repeat005Clazz(`${this.counter++}_modify_item`));
  67. })
  68. Button('Del No.' + this.selectIdx)
  69. .onClick(() => {
  70. this.simpleList.splice(this.selectIdx, 1);
  71. this.reloadSelectOptions();
  72. })
  73. }
  74. Button('Update array length to 5.')
  75. .onClick(() => {
  76. this.simpleList = this.simpleList.slice(0, 5);
  77. this.reloadSelectOptions();
  78. })
  79. Text('Click on two items to exchange.')
  80. .fontSize(15)
  81. .fontColor(Color.Gray)
  82. List({ space: 10 }) {
  83. Repeat<Repeat005Clazz>(this.simpleList)
  84. .each((obj: RepeatItem<Repeat005Clazz>) => {
  85. ListItem() {
  86. Text(`[each] index${obj.index}: ${obj.item.message}`)
  87. .fontSize(25)
  88. .onClick(() => {
  89. this.handleExchange(obj.index);
  90. })
  91. }
  92. })
  93. .key((item: Repeat005Clazz, index: number) => {
  94. return item.message;
  95. })
  96. .virtualScroll({ totalCount: this.simpleList.length })
  97. .templateId(() => "a")
  98. .template('a', (ri) => {
  99. Text(`[a] index${ri.index}: ${ri.item.message}`)
  100. .fontSize(25)
  101. .onClick(() => {
  102. this.handleExchange(ri.index);
  103. })
  104. }, { cachedCount: 3 })
  105. }
  106. .cachedCount(2)
  107. .border({ width: 1 })
  108. .width('95%')
  109. .height('40%')
  110. }
  111. .justifyContent(FlexAlign.Center)
  112. .width('100%')
  113. .height('100%')
  114. }
  115. }

该应用列表内容为100项自定义类RepeatClazz的message字符串属性,List组件的cachedCount设为2,模板'a'的缓存池大小设为3。应用界面如下图所示:

多个template

收起
自动换行
深色代码主题
复制
  1. @ObservedV2
  2. class Repeat006Clazz {
  3. @Trace message: string = '';
  4. constructor(message: string) {
  5. this.message = message;
  6. }
  7. }
  8. @Entry
  9. @ComponentV2
  10. struct RepeatVirtualScroll2T {
  11. @Local simpleList: Array<Repeat006Clazz> = [];
  12. private exchange: number[] = [];
  13. private counter: number = 0;
  14. @Local selectOptions: SelectOption[] = [];
  15. @Local selectIdx: number = 0;
  16. @Monitor("simpleList")
  17. reloadSelectOptions(): void {
  18. this.selectOptions = [];
  19. for (let i = 0; i < this.simpleList.length; ++i) {
  20. this.selectOptions.push({ value: i.toString() });
  21. }
  22. if (this.selectIdx >= this.simpleList.length) {
  23. this.selectIdx = this.simpleList.length - 1;
  24. }
  25. }
  26. aboutToAppear(): void {
  27. for (let i = 0; i < 100; i++) {
  28. this.simpleList.push(new Repeat006Clazz(`item_${i}`));
  29. }
  30. this.reloadSelectOptions();
  31. }
  32. handleExchange(idx: number): void { // 点击交换子组件
  33. this.exchange.push(idx);
  34. if (this.exchange.length === 2) {
  35. let _a = this.exchange[0];
  36. let _b = this.exchange[1];
  37. let temp: Repeat006Clazz = this.simpleList[_a];
  38. this.simpleList[_a] = this.simpleList[_b];
  39. this.simpleList[_b] = temp;
  40. this.exchange = [];
  41. }
  42. }
  43. build() {
  44. Column({ space: 10 }) {
  45. Text('virtualScroll each()&template() 2t')
  46. .fontSize(15)
  47. .fontColor(Color.Gray)
  48. Text('Select an index and press the button to update data.')
  49. .fontSize(15)
  50. .fontColor(Color.Gray)
  51. Select(this.selectOptions)
  52. .selected(this.selectIdx)
  53. .value(this.selectIdx.toString())
  54. .key('selectIdx')
  55. .onSelect((index: number) => {
  56. this.selectIdx = index;
  57. })
  58. Row({ space: 5 }) {
  59. Button('Add No.' + this.selectIdx)
  60. .onClick(() => {
  61. this.simpleList.splice(this.selectIdx, 0, new Repeat006Clazz(`${this.counter++}_add_item`));
  62. this.reloadSelectOptions();
  63. })
  64. Button('Modify No.' + this.selectIdx)
  65. .onClick(() => {
  66. this.simpleList.splice(this.selectIdx, 1, new Repeat006Clazz(`${this.counter++}_modify_item`));
  67. })
  68. Button('Del No.' + this.selectIdx)
  69. .onClick(() => {
  70. this.simpleList.splice(this.selectIdx, 1);
  71. this.reloadSelectOptions();
  72. })
  73. }
  74. Button('Update array length to 5.')
  75. .onClick(() => {
  76. this.simpleList = this.simpleList.slice(0, 5);
  77. this.reloadSelectOptions();
  78. })
  79. Text('Click on two items to exchange.')
  80. .fontSize(15)
  81. .fontColor(Color.Gray)
  82. List({ space: 10 }) {
  83. Repeat<Repeat006Clazz>(this.simpleList)
  84. .each((obj: RepeatItem<Repeat006Clazz>) => {
  85. ListItem() {
  86. Text(`[each] index${obj.index}: ${obj.item.message}`)
  87. .fontSize(25)
  88. .onClick(() => {
  89. this.handleExchange(obj.index);
  90. })
  91. }
  92. })
  93. .key((item: Repeat006Clazz, index: number) => {
  94. return item.message;
  95. })
  96. .virtualScroll({ totalCount: this.simpleList.length })
  97. .templateId((item: Repeat006Clazz, index: number) => {
  98. return (index % 2 === 0) ? 'odd' : 'even';
  99. })
  100. .template('odd', (ri) => {
  101. Text(`[odd] index${ri.index}: ${ri.item.message}`)
  102. .fontSize(25)
  103. .fontColor(Color.Blue)
  104. .onClick(() => {
  105. this.handleExchange(ri.index);
  106. })
  107. }, { cachedCount: 3 })
  108. .template('even', (ri) => {
  109. Text(`[even] index${ri.index}: ${ri.item.message}`)
  110. .fontSize(25)
  111. .fontColor(Color.Green)
  112. .onClick(() => {
  113. this.handleExchange(ri.index);
  114. })
  115. }, { cachedCount: 1 })
  116. }
  117. .cachedCount(2)
  118. .border({ width: 1 })
  119. .width('95%')
  120. .height('40%')
  121. }
  122. .justifyContent(FlexAlign.Center)
  123. .width('100%')
  124. .height('100%')
  125. }
  126. }

Repeat嵌套

Repeat支持嵌套使用。下面是使用virtualScroll模式进行嵌套的示例代码:

收起
自动换行
深色代码主题
复制
  1. // Repeat嵌套
  2. @Entry
  3. @ComponentV2
  4. struct RepeatNest {
  5. @Local outerList: string[] = [];
  6. @Local innerList: number[] = [];
  7. aboutToAppear(): void {
  8. for (let i = 0; i < 20; i++) {
  9. this.outerList.push(i.toString());
  10. this.innerList.push(i);
  11. }
  12. }
  13. build() {
  14. Column({ space: 20 }) {
  15. Text('Repeat virtualScroll嵌套')
  16. .fontSize(15)
  17. .fontColor(Color.Gray)
  18. List() {
  19. Repeat<string>(this.outerList)
  20. .each((obj) => {
  21. ListItem() {
  22. Column() {
  23. Text('outerList item: ' + obj.item)
  24. .fontSize(30)
  25. List() {
  26. Repeat<number>(this.innerList)
  27. .each((subObj) => {
  28. ListItem() {
  29. Text('innerList item: ' + subObj.item)
  30. .fontSize(20)
  31. }
  32. })
  33. .key((item) => "innerList_" + item)
  34. .virtualScroll()
  35. }
  36. .width('80%')
  37. .border({ width: 1 })
  38. .backgroundColor(Color.Orange)
  39. }
  40. .height('30%')
  41. .backgroundColor(Color.Pink)
  42. }
  43. .border({ width: 1 })
  44. })
  45. .key((item) => "outerList_" + item)
  46. .virtualScroll()
  47. }
  48. .width('80%')
  49. .border({ width: 1 })
  50. }
  51. .justifyContent(FlexAlign.Center)
  52. .width('90%')
  53. .height('80%')
  54. }
  55. }

运行效果:

父容器组件应用场景

本节展示Repeat virtualScroll模式与容器组件的常见应用场景。

与List组合使用

在List容器组件中使用Repeat的virtualScroll模式,示例如下:

收起
自动换行
深色代码主题
复制
  1. class DemoListItemInfo {
  2. name: string;
  3. icon: Resource;
  4. constructor(name: string, icon: Resource) {
  5. this.name = name;
  6. this.icon = icon;
  7. }
  8. }
  9. @Entry
  10. @ComponentV2
  11. struct DemoList {
  12. @Local videoList: Array<DemoListItemInfo> = [];
  13. aboutToAppear(): void {
  14. for (let i = 0; i < 10; i++) {
  15. // 此处app.media.listItem0、app.media.listItem1、app.media.listItem2仅作示例,请开发者自行替换
  16. this.videoList.push(new DemoListItemInfo('视频' + i,
  17. i % 3 == 0 ? $r("app.media.listItem0") :
  18. i % 3 == 1 ? $r("app.media.listItem1") : $r("app.media.listItem2")));
  19. }
  20. }
  21. @Builder
  22. itemEnd(index: number) {
  23. Button('删除')
  24. .backgroundColor(Color.Red)
  25. .onClick(() => {
  26. this.videoList.splice(index, 1);
  27. })
  28. }
  29. build() {
  30. Column({ space: 10 }) {
  31. Text('List容器组件中包含Repeat组件')
  32. .fontSize(15)
  33. .fontColor(Color.Gray)
  34. List({ space: 5 }) {
  35. Repeat<DemoListItemInfo>(this.videoList)
  36. .each((obj: RepeatItem<DemoListItemInfo>) => {
  37. ListItem() {
  38. Column() {
  39. Image(obj.item.icon)
  40. .width('80%')
  41. .margin(10)
  42. Text(obj.item.name)
  43. .fontSize(20)
  44. }
  45. }
  46. .swipeAction({
  47. end: {
  48. builder: () => {
  49. this.itemEnd(obj.index);
  50. }
  51. }
  52. })
  53. .onAppear(() => {
  54. console.info('AceTag', obj.item.name);
  55. })
  56. })
  57. .key((item: DemoListItemInfo) => item.name)
  58. .virtualScroll()
  59. }
  60. .cachedCount(2)
  61. .height('90%')
  62. .border({ width: 1 })
  63. .listDirection(Axis.Vertical)
  64. .alignListItem(ListItemAlign.Center)
  65. .divider({
  66. strokeWidth: 1,
  67. startMargin: 60,
  68. endMargin: 60,
  69. color: '#ffe9f0f0'
  70. })
  71. Row({ space: 10 }) {
  72. Button('删除第1项')
  73. .onClick(() => {
  74. this.videoList.splice(0, 1);
  75. })
  76. Button('删除第5项')
  77. .onClick(() => {
  78. this.videoList.splice(4, 1);
  79. })
  80. }
  81. }
  82. .width('100%')
  83. .height('100%')
  84. .justifyContent(FlexAlign.Center)
  85. }
  86. }

右滑并点击按钮,或点击底部按钮,可删除视频卡片:

与Grid组合使用

在Grid容器组件中使用Repeat的virtualScroll模式,示例如下:

收起
自动换行
深色代码主题
复制
  1. class DemoGridItemInfo {
  2. name: string;
  3. icon: Resource;
  4. constructor(name: string, icon: Resource) {
  5. this.name = name;
  6. this.icon = icon;
  7. }
  8. }
  9. @Entry
  10. @ComponentV2
  11. struct DemoGrid {
  12. @Local itemList: Array<DemoGridItemInfo> = [];
  13. @Local isRefreshing: boolean = false;
  14. private layoutOptions: GridLayoutOptions = {
  15. regularSize: [1, 1],
  16. irregularIndexes: [10]
  17. }
  18. private GridScroller: Scroller = new Scroller();
  19. private num: number = 0;
  20. aboutToAppear(): void {
  21. for (let i = 0; i < 10; i++) {
  22. // 此处app.media.gridItem0、app.media.gridItem1、app.media.gridItem2仅作示例,请开发者自行替换
  23. this.itemList.push(new DemoGridItemInfo('视频' + i,
  24. i % 3 == 0 ? $r("app.media.gridItem0") :
  25. i % 3 == 1 ? $r("app.media.gridItem1") : $r("app.media.gridItem2")));
  26. }
  27. }
  28. build() {
  29. Column({ space: 10 }) {
  30. Text('Grid容器组件中包含Repeat组件')
  31. .fontSize(15)
  32. .fontColor(Color.Gray)
  33. Refresh({ refreshing: $$this.isRefreshing }) {
  34. Grid(this.GridScroller, this.layoutOptions) {
  35. Repeat<DemoGridItemInfo>(this.itemList)
  36. .each((obj: RepeatItem<DemoGridItemInfo>) => {
  37. if (obj.index === 10 ) {
  38. GridItem() {
  39. Text('先前浏览至此,点击刷新')
  40. .fontSize(20)
  41. }
  42. .height(30)
  43. .border({ width: 1 })
  44. .onClick(() => {
  45. this.GridScroller.scrollToIndex(0);
  46. this.isRefreshing = true;
  47. })
  48. .onAppear(() => {
  49. console.info('AceTag', obj.item.name);
  50. })
  51. } else {
  52. GridItem() {
  53. Column() {
  54. Image(obj.item.icon)
  55. .width('100%')
  56. .height(80)
  57. .objectFit(ImageFit.Cover)
  58. .borderRadius({ topLeft: 16, topRight: 16 })
  59. Text(obj.item.name)
  60. .fontSize(15)
  61. .height(20)
  62. }
  63. }
  64. .height(100)
  65. .borderRadius(16)
  66. .backgroundColor(Color.White)
  67. .onAppear(() => {
  68. console.info('AceTag', obj.item.name);
  69. })
  70. }
  71. })
  72. .key((item: DemoGridItemInfo) => item.name)
  73. .virtualScroll()
  74. }
  75. .columnsTemplate('repeat(auto-fit, 150)')
  76. .cachedCount(4)
  77. .rowsGap(15)
  78. .columnsGap(10)
  79. .height('100%')
  80. .padding(10)
  81. .backgroundColor('#F1F3F5')
  82. }
  83. .onRefreshing(() => {
  84. setTimeout(() => {
  85. this.itemList.splice(10, 1);
  86. this.itemList.unshift(new DemoGridItemInfo('refresh', $r('app.media.gridItem0'))); // 此处app.media.gridItem0仅作示例,请开发者自行替换
  87. for (let i = 0; i < 10; i++) {
  88. // 此处app.media.gridItem0、app.media.gridItem1、app.media.gridItem2仅作示例,请开发者自行替换
  89. this.itemList.unshift(new DemoGridItemInfo('新视频' + this.num,
  90. i % 3 == 0 ? $r("app.media.gridItem0") :
  91. i % 3 == 1 ? $r("app.media.gridItem1") : $r("app.media.gridItem2")));
  92. this.num++;
  93. }
  94. this.isRefreshing = false;
  95. }, 1000);
  96. console.info('AceTag', 'onRefreshing');
  97. })
  98. .refreshOffset(64)
  99. .pullToRefresh(true)
  100. .width('100%')
  101. .height('85%')
  102. Button('刷新')
  103. .onClick(() => {
  104. this.GridScroller.scrollToIndex(0);
  105. this.isRefreshing = true;
  106. })
  107. }
  108. .width('100%')
  109. .height('100%')
  110. .justifyContent(FlexAlign.Center)
  111. }
  112. }

下拉屏幕,或点击刷新按钮,或点击“先前浏览至此,点击刷新”,可加载新的视频内容:

与Swiper组合使用

在Swiper容器组件中使用Repeat的virtualScroll模式,示例如下:

收起
自动换行
深色代码主题
复制
  1. const remotePictures: Array<string> = [
  2. 'https://www.example.com/xxx/0001.jpg', // 请填写具体的网络图片地址
  3. 'https://www.example.com/xxx/0002.jpg',
  4. 'https://www.example.com/xxx/0003.jpg',
  5. 'https://www.example.com/xxx/0004.jpg',
  6. 'https://www.example.com/xxx/0005.jpg',
  7. 'https://www.example.com/xxx/0006.jpg',
  8. 'https://www.example.com/xxx/0007.jpg',
  9. 'https://www.example.com/xxx/0008.jpg',
  10. 'https://www.example.com/xxx/0009.jpg'
  11. ];
  12. @ObservedV2
  13. class DemoSwiperItemInfo {
  14. id: string;
  15. @Trace url: string = 'default';
  16. constructor(id: string) {
  17. this.id = id;
  18. }
  19. }
  20. @Entry
  21. @ComponentV2
  22. struct DemoSwiper {
  23. @Local pics: Array<DemoSwiperItemInfo> = [];
  24. aboutToAppear(): void {
  25. for (let i = 0; i < 9; i++) {
  26. this.pics.push(new DemoSwiperItemInfo('pic' + i));
  27. }
  28. setTimeout(() => {
  29. this.pics[0].url = remotePictures[0];
  30. }, 1000);
  31. }
  32. build() {
  33. Column() {
  34. Text('Swiper容器组件中包含Repeat组件')
  35. .fontSize(15)
  36. .fontColor(Color.Gray)
  37. Stack() {
  38. Text('图片加载中')
  39. .fontSize(15)
  40. .fontColor(Color.Gray)
  41. Swiper() {
  42. Repeat(this.pics)
  43. .each((obj: RepeatItem<DemoSwiperItemInfo>) => {
  44. Image(obj.item.url)
  45. .onAppear(() => {
  46. console.info('AceTag', obj.item.id);
  47. })
  48. })
  49. .key((item: DemoSwiperItemInfo) => item.id)
  50. .virtualScroll()
  51. }
  52. .cachedCount(9)
  53. .height('50%')
  54. .loop(false)
  55. .indicator(true)
  56. .onChange((index) => {
  57. setTimeout(() => {
  58. this.pics[index].url = remotePictures[index];
  59. }, 1000);
  60. })
  61. }
  62. .width('100%')
  63. .height('100%')
  64. .backgroundColor(Color.Black)
  65. }
  66. }
  67. }

定时1秒后加载图片,模拟网络延迟:

常见问题

屏幕外的列表数据发生变化时,保证滚动条位置不变

以下示例中,屏幕外的数据源变化将影响屏幕中List列表Scroller停留的位置:

在List组件中声明Repeat组件,实现key值生成逻辑和each逻辑(如下示例代码),点击按钮“insert”,在屏幕显示的第一个元素前面插入一个元素,屏幕出现向下滚动。

收起
自动换行
深色代码主题
复制
  1. // 定义一个类,标记为可观察的
  2. // 类中自定义一个数组,标记为可追踪的
  3. @ObservedV2
  4. class ArrayHolder {
  5. @Trace arr: Array<number> = [];
  6. // constructor,用于初始化数组个数
  7. constructor(count: number) {
  8. for (let i = 0; i < count; i++) {
  9. this.arr.push(i);
  10. }
  11. }
  12. }
  13. @Entry
  14. @ComponentV2
  15. struct RepeatTemplateSingle {
  16. @Local arrayHolder: ArrayHolder = new ArrayHolder(100);
  17. @Local totalCount: number = this.arrayHolder.arr.length;
  18. scroller: Scroller = new Scroller();
  19. build() {
  20. Column({ space: 5 }) {
  21. List({ space: 20, initialIndex: 19, scroller: this.scroller }) {
  22. Repeat(this.arrayHolder.arr)
  23. .virtualScroll({ totalCount: this.totalCount })
  24. .templateId((item, index) => {
  25. return 'number';
  26. })
  27. .template('number', (r) => {
  28. ListItem() {
  29. Text(r.index! + ":" + r.item + "Reuse");
  30. }
  31. })
  32. .each((r) => {
  33. ListItem() {
  34. Text(r.index! + ":" + r.item + "eachMessage");
  35. }
  36. })
  37. }
  38. .height('30%')
  39. Button(`insert totalCount ${this.totalCount}`)
  40. .height(60)
  41. .onClick(() => {
  42. // 插入元素,元素位置为屏幕显示的前一个元素
  43. this.arrayHolder.arr.splice(18, 0, this.totalCount);
  44. this.totalCount = this.arrayHolder.arr.length;
  45. })
  46. }
  47. .width('100%')
  48. .margin({ top: 5 })
  49. }
  50. }

运行效果:

以下为修正后的示例:

在一些场景中,我们不希望屏幕外的数据源变化影响屏幕中List列表Scroller停留的位置,可以通过List组件的onScrollIndex事件对列表滚动动作进行监听,当列表发生滚动时,获取列表滚动位置。使用Scroller组件的scrollToIndex特性,滑动到指定index位置,实现屏幕外的数据源增加/删除数据时,Scroller停留的位置不变的效果。

示例代码仅对增加数据的情况进行展示。

收起
自动换行
深色代码主题
复制
  1. // ...ArrayHolder的定义和上述demo代码一致
  2. @Entry
  3. @ComponentV2
  4. struct RepeatTemplateSingle {
  5. @Local arrayHolder: ArrayHolder = new ArrayHolder(100);
  6. @Local totalCount: number = this.arrayHolder.arr.length;
  7. scroller: Scroller = new Scroller();
  8. private start: number = 1;
  9. private end: number = 1;
  10. build() {
  11. Column({ space: 5 }) {
  12. List({ space: 20, initialIndex: 19, scroller: this.scroller }) {
  13. Repeat(this.arrayHolder.arr)
  14. .virtualScroll({ totalCount: this.totalCount })
  15. .templateId((item, index) => {
  16. return 'number';
  17. })
  18. .template('number', (r) => {
  19. ListItem() {
  20. Text(r.index! + ":" + r.item + "Reuse")
  21. }
  22. })
  23. .each((r) => {
  24. ListItem() {
  25. Text(r.index! + ":" + r.item + "eachMessage")
  26. }
  27. })
  28. }
  29. .onScrollIndex((start, end) => {
  30. this.start = start;
  31. this.end = end;
  32. })
  33. .height('30%')
  34. Button(`insert totalCount ${this.totalCount}`)
  35. .height(60)
  36. .onClick(() => {
  37. // 插入元素,元素位置为屏幕显示的前一个元素
  38. this.arrayHolder.arr.splice(18, 0, this.totalCount);
  39. let rect = this.scroller.getItemRect(this.start); // 获取子组件的大小位置
  40. this.scroller.scrollToIndex(this.start + 1); // 滑动到指定index
  41. this.scroller.scrollBy(0, -rect.y); // 滑动指定距离
  42. this.totalCount = this.arrayHolder.arr.length;
  43. })
  44. }
  45. .width('100%')
  46. .margin({ top: 5 })
  47. }
  48. }

运行效果:

totalCount值大于数据源长度

当数据源总长度很大时,会使用懒加载的方式先加载一部分数据,为了使Repeat显示正确的滚动条样式,需要将数据总长度赋值给totalCount,即数据源全部加载完成前,totalCount大于array.length。

totalCount > array.length时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,开发者需要对数据请求的错误场景(如网络延迟)进行保护操作,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。

上述规范可以通过实现父组件List/Grid的onScrollIndex属性的回调函数完成。示例代码如下:

收起
自动换行
深色代码主题
复制
  1. @ObservedV2
  2. class VehicleData {
  3. @Trace name: string;
  4. @Trace price: number;
  5. constructor(name: string, price: number) {
  6. this.name = name;
  7. this.price = price;
  8. }
  9. }
  10. @ObservedV2
  11. class VehicleDB {
  12. public vehicleItems: VehicleData[] = [];
  13. constructor() {
  14. // init data size 20
  15. for (let i = 1; i <= 20; i++) {
  16. this.vehicleItems.push(new VehicleData(`Vehicle${i}`, i));
  17. }
  18. }
  19. }
  20. @Entry
  21. @ComponentV2
  22. struct entryCompSucc {
  23. @Local vehicleItems: VehicleData[] = new VehicleDB().vehicleItems;
  24. @Local listChildrenSize: ChildrenMainSize = new ChildrenMainSize(60);
  25. @Local totalCount: number = this.vehicleItems.length;
  26. scroller: Scroller = new Scroller();
  27. build() {
  28. Column({ space: 3 }) {
  29. List({ scroller: this.scroller }) {
  30. Repeat(this.vehicleItems)
  31. .virtualScroll({ totalCount: 50 }) // total data size 50
  32. .templateId(() => 'default')
  33. .template('default', (ri) => {
  34. ListItem() {
  35. Column() {
  36. Text(`${ri.item.name} + ${ri.index}`)
  37. .width('90%')
  38. .height(this.listChildrenSize.childDefaultSize)
  39. .backgroundColor(0xFFA07A)
  40. .textAlign(TextAlign.Center)
  41. .fontSize(20)
  42. .fontWeight(FontWeight.Bold)
  43. }
  44. }.border({ width: 1 })
  45. }, { cachedCount: 5 })
  46. .each((ri) => {
  47. ListItem() {
  48. Text("Wrong: " + `${ri.item.name} + ${ri.index}`)
  49. .width('90%')
  50. .height(this.listChildrenSize.childDefaultSize)
  51. .backgroundColor(0xFFA07A)
  52. .textAlign(TextAlign.Center)
  53. .fontSize(20)
  54. .fontWeight(FontWeight.Bold)
  55. }.border({ width: 1 })
  56. })
  57. .key((item, index) => `${index}:${item}`)
  58. }
  59. .height('50%')
  60. .margin({ top: 20 })
  61. .childrenMainSize(this.listChildrenSize)
  62. .alignListItem(ListItemAlign.Center)
  63. .onScrollIndex((start, end) => {
  64. console.log('onScrollIndex', start, end);
  65. // lazy data loading
  66. if (this.vehicleItems.length < 50) {
  67. for (let i = 0; i < 10; i++) {
  68. if (this.vehicleItems.length < 50) {
  69. this.vehicleItems.push(new VehicleData("Vehicle_loaded", i));
  70. }
  71. }
  72. }
  73. })
  74. }
  75. }
  76. }

示例代码运行效果:

Repeat与@Builder混用的限制

当Repeat与@Builder混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递RepeatItem.item或RepeatItem.index,将会出现UI渲染异常。

示例代码如下:

收起
自动换行
深色代码主题
复制
  1. @Entry
  2. @ComponentV2
  3. struct RepeatBuilderPage {
  4. @Local simpleList1: Array<number> = [];
  5. @Local simpleList2: Array<number> = [];
  6. aboutToAppear(): void {
  7. for (let i = 0; i < 100; i++) {
  8. this.simpleList1.push(i);
  9. this.simpleList2.push(i);
  10. }
  11. }
  12. build() {
  13. Column({ space: 20 }) {
  14. Text('Repeat与@Builder混用,左边是异常场景,右边是正常场景,向下滑动一段距离可以看出差别')
  15. .fontSize(15)
  16. .fontColor(Color.Gray)
  17. Row({ space: 20 }) {
  18. List({ initialIndex: 5, space: 20 }) {
  19. Repeat<number>(this.simpleList1)
  20. .each((ri) => {})
  21. .virtualScroll({ totalCount: this.simpleList1.length })
  22. .templateId((item: number, index: number) => "default")
  23. .template('default', (ri) => {
  24. ListItem() {
  25. Column() {
  26. Text('Text id = ' + ri.item)
  27. .fontSize(20)
  28. this.buildItem1(ri.item) // 错误示例,为避免渲染异常,应修改为:this.buildItem1(ri)
  29. }
  30. }
  31. .border({ width: 1 })
  32. }, { cachedCount: 3 })
  33. }
  34. .cachedCount(1)
  35. .border({ width: 1 })
  36. .width('45%')
  37. .height('60%')
  38. List({ initialIndex: 5, space: 20 }) {
  39. Repeat<number>(this.simpleList2)
  40. .each((ri) => {})
  41. .virtualScroll({ totalCount: this.simpleList2.length })
  42. .templateId((item: number, index: number) => "default")
  43. .template('default', (ri) => {
  44. ListItem() {
  45. Column() {
  46. Text('Text id = ' + ri.item)
  47. .fontSize(20)
  48. this.buildItem2(ri) // 正确示例,渲染正常
  49. }
  50. }
  51. .border({ width: 1 })
  52. }, { cachedCount: 3 })
  53. }
  54. .cachedCount(1)
  55. .border({ width: 1 })
  56. .width('45%')
  57. .height('60%')
  58. }
  59. }
  60. .height('100%')
  61. .justifyContent(FlexAlign.Center)
  62. }
  63. @Builder
  64. // @Builder参数必须传RepeatItem类型才能正常渲染
  65. buildItem1(item: number) {
  66. Text('Builder1 id = ' + item)
  67. .fontSize(20)
  68. .fontColor(Color.Red)
  69. .margin({ top: 2 })
  70. }
  71. @Builder
  72. buildItem2(ri: RepeatItem<number>) {
  73. Text('Builder2 id = ' + ri.item)
  74. .fontSize(20)
  75. .fontColor(Color.Red)
  76. .margin({ top: 2 })
  77. }
  78. }

界面展示如下图,进入页面后向下滑动一段距离可以看出差别,左边是错误用法,右边是正确用法(Text组件为黑色,Builder组件为红色)。上述代码展示了开发过程中易出错的场景,即在@Builder构造函数中传参方式为值传递。

在 指南 中进行搜索
请输入您想要搜索的关键词