• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Repeat:可复用的循环渲染
2
3> **说明:**
4>
5> Repeat从API version 12开始支持。
6>
7> 本文档仅为开发者指南。API参数说明见:[Repeat API参数说明](../../reference/apis-arkui/arkui-ts/ts-rendering-control-repeat.md)。
8
9## 概述
10
11Repeat基于数组类型数据来进行循环渲染,一般与容器组件配合使用。Repeat组件包含两种模式:**non-virtualScroll模式**和**virtualScroll模式**。
12
13- **non-virtualScroll模式**:Repeat在初始化页面时加载列表中的所有子组件,适合**短数据列表/组件全部加载**的场景。详细描述见[non-virtualScroll模式](#non-virtualscroll模式)。
14- **virtualScroll模式**(开启[virtualScroll](../../reference/apis-arkui/arkui-ts/ts-rendering-control-repeat.md#virtualscroll)开关):Repeat根据容器组件的**有效加载范围(可视区域+预加载区域)** 加载子组件。当容器滑动/数组改变时,Repeat会根据父容器组件传递的参数重新计算有效加载范围,实时管理列表节点的创建与销毁。
15<br/>该模式适合**需要懒加载的长数据列表/通过组件复用优化性能表现**的场景。详细描述见[virtualScroll模式](#virtualscroll模式)。
16
17> **说明:**
18>
19> Repeat与ForEach、LazyForEach的区别:
20>
21> - 相较于[ForEach](arkts-rendering-control-foreach.md)组件,non-virtualScroll模式在以下两个维度实现了优化升级:首先,针对特定数组更新场景的渲染性能进行了优化;其次,将子组件的内容/索引管理职责转移至框架层面。
22> - 相较于[LazyForEach](arkts-rendering-control-lazyforeach.md)组件,virtualScroll模式直接监听状态变量的变化,而LazyForEach需要开发者实现[IDataSource](../../reference/apis-arkui/arkui-ts/ts-rendering-control-lazyforeach.md#idatasource10)接口,手动管理子组件内容/索引的修改。除此之外,Repeat还增强了节点复用能力,提高了长列表滑动和数据更新的渲染性能。Repeat增加了模板(template)的能力,在同一个数组中,根据开发者自定义的模板类型(template type)渲染不同的子组件。
23
24下面的示例代码使用Repeat组件的virtualScroll模式进行循环渲染。
25
26```ts
27// 在List容器组件中使用Repeat virtualScroll模式
28@Entry
29@ComponentV2 // 推荐使用V2装饰器
30struct RepeatExample {
31  @Local dataArr: Array<string> = []; // 数据源
32
33  aboutToAppear(): void {
34    for (let i = 0; i < 50; i++) {
35      this.dataArr.push(`data_${i}`); // 为数组添加一些数据
36    }
37  }
38
39  build() {
40    Column() {
41      List() {
42        Repeat<string>(this.dataArr)
43          .each((ri: RepeatItem<string>) => { // 默认模板
44            ListItem() {
45              Text('each_A_' + ri.item).fontSize(30).fontColor(Color.Red) // 文本颜色为红色
46            }
47          })
48          .key((item: string, index: number): string => item) // 键值生成函数
49          .virtualScroll({ totalCount: this.dataArr.length }) // 打开virtualScroll模式,totalCount为期望加载的数据长度
50          .templateId((item: string, index: number): string => { // 根据返回值寻找对应的模板子组件进行渲染
51            return index <= 4 ? 'A' : (index <= 10 ? 'B' : ''); // 前5个节点模板为A,接下来的5个为B,其余为默认模板
52          })
53          .template('A', (ri: RepeatItem<string>) => { // 'A'模板
54            ListItem() {
55              Text('ttype_A_' + ri.item).fontSize(30).fontColor(Color.Green) // 文本颜色为绿色
56            }
57          }, { cachedCount: 3 }) // 'A'模板的缓存列表容量为3
58          .template('B', (ri: RepeatItem<string>) => { // 'B'模板
59            ListItem() {
60              Text('ttype_B_' + ri.item).fontSize(30).fontColor(Color.Blue) // 文本颜色为蓝色
61            }
62          }, { cachedCount: 4 }) // 'B'模板的缓存列表容量为4
63      }
64      .cachedCount(2) // 容器组件的预加载区域大小
65      .height('70%')
66      .border({ width: 1 }) // 边框
67    }
68  }
69}
70```
71
72运行后界面如下图所示。
73
74![Repeat-NonVS-KeyGen](./figures/Repeat-Example.png)
75
76## 使用限制
77
78- Repeat一般与容器组件配合使用,子组件应当是允许包含在容器组件中的子组件,例如,Repeat与[List](../../reference/apis-arkui/arkui-ts/ts-container-list.md)组件配合使用时,子组件必须为[ListItem](../../reference/apis-arkui/arkui-ts/ts-container-listitem.md)组件。
79- 当Repeat与自定义组件或[@Builder函数](./arkts-builder.md)混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递`RepeatItem.item`或`RepeatItem.index`,将会出现UI渲染异常。详细见[Repeat与@Builder混用的限制](#repeat与builder混用的限制)。
80
81Repeat virtualScroll模式使用限制:
82
83- 必须在滚动类容器组件内使用,仅有[List](../../reference/apis-arkui/arkui-ts/ts-container-list.md)、[Grid](../../reference/apis-arkui/arkui-ts/ts-container-grid.md)、[Swiper](../../reference/apis-arkui/arkui-ts/ts-container-swiper.md)以及[WaterFlow](../../reference/apis-arkui/arkui-ts/ts-container-waterflow.md)组件支持Repeat virtualScroll模式。
84- virtualScroll模式不支持V1装饰器,混用V1装饰器会导致渲染异常,不建议开发者同时使用。
85- 必须创建且只允许创建一个子组件,生成的子组件必须是允许包含在Repeat父容器组件中的子组件。
86- 滚动容器组件内只能包含一个Repeat。以List为例,同时包含ListItem、ForEach、LazyForEach的场景是不推荐的;同时包含多个Repeat也是不推荐的。
87- totalCount值大于数组长度时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。解决方案见[totalCount值大于数据源长度](#totalcount值大于数据源长度)。
88
89Repeat通过键值识别数组如何改变:增加了哪些数据、删除了哪些数据,以及哪些数据改变了位置(索引)。键值生成函数`.key()`的使用建议如下:
90
91- 即使数组发生变化,开发者也必须保证键值key唯一。
92- 每次执行`.key()`函数时,使用相同的数据项作为输入,输出必须是一致的。
93- 允许在`.key()`中使用index,但不建议开发者这样做。因为在数据项移动时索引index发生变化,key值因此改变,导致Repeat认为数据发生了变化,从而触发子组件重新渲染,降低性能表现。
94- 推荐将简单类型数组转换为类对象数组,并添加一个`readonly id`属性,在构造函数中给它赋一个唯一的值。
95
96从API version 18开始,不建议开发者使用`.key()`函数。如果开发者按照上面的建议使用`.key()`,那么Repeat仍能保持兼容性,不会有性能影响。
97
98> **说明:**
99>
100> Repeat子组件节点的操作分为四种:节点创建、节点更新、节点复用、节点销毁。其中,节点更新和节点复用的区别为:
101>
102> - **节点更新**:组件节点不下树,只有状态变量刷新。
103> - **节点复用**:旧的节点下树,但不会销毁,存储在空闲节点缓存池;新节点从缓存池中获取可复用的节点,重新上树。
104
105## non-virtualScroll模式
106
107### 键值生成规则
108
109`.key()`方法为每一项数据生成一个键值。请注意键值(key)与索引(index)的区别:键值是数据项的唯一标识符,Repeat根据键值是否发生变化判断数据项是否更新;索引则只标识数据项在数据源中的位置。
110
111`.key()`的逻辑如下图所示。
112
113当`.key()`缺省时,Repeat会生成新的随机键值。当发现有重复key时,Repeat会在已有键值的基础上递归生成新的键值,直到没有重复键值。
114
115![Repeat-NonVS-KeyGen](./figures/Repeat-NonVS-KeyGen.png)
116
117### 子组件渲染逻辑
118
119在Repeat首次渲染时,子组件全部创建。数组发生改变后,Repeat的处理分为以下几个步骤:
120
121首先,遍历旧数组键值,如果新数组中没有该键值,将其加入键值集合deletedKeys。
122
123其次,遍历新数组键值,依次判断以下条件,符合条件时进行对应的操作:
124
1251. 若在旧数组中能找到相同键值,直接使用对应的子组件节点,并更新索引index;
1262. 若deletedKeys非空,按照先进后出的顺序,更新该集合中的键值所对应的节点;
1273. 若deletedKeys为空,则表示没有可以更新的节点,需要创建新节点。
128
129最后,如果新数组键值遍历结束后,deletedKeys非空,则销毁集合中的键值所对应的节点。
130
131![Repeat-NonVS-FuncGen](./figures/Repeat-NonVS-FuncGen.png)
132
133以下图中的数组变化为例。
134
135![Repeat-NonVS-Example](./figures/Repeat-NonVS-Example.png)
136
137根据上述判断逻辑,`item_0`没有变化,`item_1`和`item_2`只更新了索引,`item_n1`和`item_n2`分别由`item_4`和`item_3`进行节点更新获得,`item_n3`为新创建的节点。
138
139## virtualScroll模式
140
141### 键值生成规则
142
143`.key()`方法为每一项数据生成一个键值。请注意键值(key)与索引(index)的区别:键值是数据项的唯一标识符,Repeat根据键值是否发生变化判断数据项是否更新;索引则只标识数据项在数据源中的位置。
144
145当开发者未定义`.key()`时,Repeat会直接对比数组数据的变化来判断Repeat子节点是否改变(节点发生改变会触发页面刷新逻辑)。当存在重复key时,Repeat会重新生成随机key作为当前数据项的键值。需要注意,当每次刷新页面后,`.key()`会被重新计算(即再次出现重复key),进而生成新的随机key。随机key的构成:`___${index}_+_${key}_+_${Math.random()}`,其中的变量依次为:索引、旧键值、随机数。
146
147### 子组件渲染逻辑
148
149在Repeat首次渲染时,根据容器组件的有效加载范围(可视区域+预加载区域)创建当前需要的子组件。
150
151在容器滑动/数组改变时,将失效的子组件节点(离开有效加载范围)加入空闲节点缓存列表中(断开与组件树的关系,但不销毁),在需要生成新的组件时,对缓存里的组件进行复用(更新被复用子组件的变量值,重新上树)。从API version 18开始,Repeat支持[VirtualScroll模式缓存池自定义组件冻结](./arkts-custom-components-freezeV2.md#repeat-virtualscroll)。
152
153Repeat组件在virtualScroll模式下默认启用复用功能。从API version 18开始,可以通过配置`reusable`字段选择是否启用复用功能。为提高渲染性能,建议启用复用功能。代码示例见[VirtualScrollOptions对象说明](../../reference/apis-arkui/arkui-ts/ts-rendering-control-repeat.md#virtualscrolloptions对象说明)。
154
155通过典型的<!--Del-->[<!--DelEnd-->滑动场景<!--Del-->](#滑动场景)<!--DelEnd-->和<!--Del-->[<!--DelEnd-->数据更新场景<!--Del-->](#数据更新场景)<!--DelEnd-->示例来展示virtualScroll模式下子组件的渲染逻辑。定义长度为20的数组,数组前5项的template type为`aa`,其余项为`bb`。`aa`缓存池容量为3,`bb`缓存池容量为4。容器组件的预加载区域大小为2。为了便于理解,在`aa`和`bb`缓存池中分别加入一个和两个空闲节点。
156
157首次渲染,列表的节点状态如下图所示。
158
159![Repeat-Start](./figures/Repeat-Start.PNG)
160
161#### 滑动场景
162
163将屏幕向右滑动(屏幕内容右移)一个节点的距离,Repeat将开始复用缓存池中的节点。index=10的节点进入有效加载范围,计算出其template type为`bb`。由于`bb`缓存池非空,Repeat会从`bb`缓存池中取出一个空闲节点进行复用,更新其节点属性,该子组件中涉及数据item和索引index的其他孙子组件会根据V2状态管理的规则做同步更新。其他节点仍在有效加载范围,均只更新索引index。
164
165index=0的节点滑出了有效加载范围。当UI主线程空闲时,会检查`aa`缓存池是否已满,此时`aa`缓存池未满,将该节点加入到对应的缓存池中。
166
167如果此时对应template type的缓存池已满,Repeat会销毁掉多余的节点。
168
169![Repeat-Slide](./figures/Repeat-Slide.PNG)
170
171#### 数据更新场景
172
173在上一小节的基础上做如下的数组更新操作,删除index=4的节点,修改节点数据`item_7`为`new_7`。
174
175首先,删除index=4的节点后,失效节点加入`aa`缓存池。后面的列表节点前移,新进入有效加载区域的节点`item_11`会复用`bb`缓存池中的空闲节点,其他节点均只更新索引index。如下图所示。
176
177![Repeat-Update1](./figures/Repeat-Update1.PNG)
178
179其次,节点`item_5`前移,索引index更新为4。根据template type的计算规则,节点`item_5`的template type变为`aa`,需要从`aa`缓存池中复用空闲节点,并且将旧节点加入`bb`缓存池。如下图所示。
180
181![Repeat-Update2](./figures/Repeat-Update2.PNG)
182
183### template:子组件渲染模板
184
185template模板目前只支持在virtualScroll模式下使用。
186
187- 每个节点会根据`.templateId()`得到template type,从而渲染对应的`.template()`中的子组件。
188- 当多个template type相同时,Repeat会覆盖先定义的`.template()`函数,仅生效最后定义的`.template()`。
189- 如果找不到对应的template type,Repeat会优先渲染type为空的`.template()`中的子组件,如果没有,则渲染`.each()`中的子组件。
190
191### cachedCount:空闲节点缓存列表大小
192
193cachedCount是相应的template type的缓存池中可缓存子组件节点的最大数量,仅在virtualScroll模式下生效。
194
195> **说明:**
196>
197> 滚动容器组件属性`.cachedCount()`和Repeat组件属性`.template()`的参数`cachedCount`都是为了平衡性能和内存,但是含义是不同的。
198> - 滚动类容器组件`.cachedCount()`:是指在可见范围外预加载的节点,这些节点会位于组件树上,但不是可见范围内,List/Grid等容器组件会额外渲染这些可见范围外的节点,从而达到其性能收益。Repeat会将这些节点视为“可见”的。
199> - `.template()`中的`cachedCount`: 是指Repeat视为“不可见”的节点,这些节点是空闲的,框架会暂时保存,在需要使用的时候更新这些节点,从而实现复用。
200
201将cachedCount设置为当前模板的节点在屏上可能出现的最大数量时,Repeat可以做到尽可能多的复用。但后果是当屏上没有当前模板的节点时,缓存池也不会释放,应用内存会增大。需要开发者根据具体情况自行把控。
202
203- cachedCount缺省时,框架会分别对不同template,根据屏上节点+预加载的节点个数来计算cachedCount。当屏上节点+预加载的节点个数变多时,cachedCount也会对应增长。需要注意cachedCount数量不会减少。
204- 显式指定cachedCount,推荐设置成和屏幕上节点个数一致。需要注意,不推荐设置cachedCount小于2,因为这会导致在快速滑动场景下创建新的节点,从而导致性能劣化。
205
206### totalCount:期望加载的数据长度
207
208totalCount表示期望加载的数据长度,默认为原数组长度,可以大于已加载数据项的数量。令arr.length表示数据源长度,以下为totalCount的处理规则:
209
210- totalCount缺省/非自然数时,totalCount默认为arr.length,列表正常滚动。
211- 0 <= totalCount < arr.length时,界面中只渲染区间[0, totalCount - 1]范围内的数据。
212- totalCount > arr.length时,代表Repeat将渲染区间[0, totalCount - 1]范围内的数据,滚动条样式根据totalCount值变化。
213
214> **注意:**
215>
216> 当totalCount > arr.length时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,开发者需要对数据请求的错误场景(如网络延迟)进行保护操作,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。
217
218### onTotalCount:计算期望的数据长度
219
220onTotalCount?(): number;
221
222onTotalCount从API version 18开始支持,需在virtualScroll模式下使用。开发者可设置自定义方法,用于计算期望的数组长度。其返回值应当为自然数,可以不等于实际数据源长度arr.length。以下为onTotalCount的处理规则:
223
224- onTotalCount返回值为非自然数时,使用arr.length代替返回值,列表正常滚动。
225- 0 <= onTotalCount返回值 < arr.length时,界面中只渲染区间[0, onTotalCount返回值 - 1]范围内的数据。
226- onTotalCount返回值 > arr.length时,代表Repeat将渲染区间[0, onTotalCount返回值 - 1]范围内的数据,滚动条样式根据onTotalCount返回值变化。
227
228> **注意:**
229>
230> - 相较于totalCount,Repeat可在需要时主动调用onTotalCount方法,更新期望数据长度。
231> - totalCount与onTotalCount至多设置一个。均未设置,则采用默认值arr.length;同时设置,则忽略totalCount。
232> - 当onTotalCount返回值 > arr.length时,建议配合使用onLazyLoading实现数据懒加载。
233
234### onLazyLoading:数据精准懒加载
235
236onLazyLoading?(index: number): void;
237
238onLazyLoading从API version 18开始支持,需在virtualScroll模式下使用。开发者可设置自定义方法,用于向指定的数据源index中写入数据。以下为onLazyLoading的处理规则:
239
240- Repeat读取数据源中某一index处对应数据前,会先检查此index处是否存在数据。
241- 若不存在数据,且开发者提供了onLazyLoading方法,则Repeat将调用此方法。
242- 在onLazyLoading方法中,开发者需要向Repeat指定的index中写入数据,方式如下:`arr[index] = ...`。在onLazyLoading方法中,不允许使用除`[]`以外的数组操作,且不允许写入指定index以外的元素,否则系统将抛出异常。
243- onLazyLoading方法执行完成后,若指定index中仍无数据,将导致渲染异常。
244
245> **注意:**
246>
247> - 当使用onLazyLoading时,建议与onTotalCount配合使用,而非totalCount。
248> - 若期望数据源长度大于实际数据源长度,推荐使用onLazyLoading。
249> - onLazyLoading方法中应避免高耗时操作。若数据加载耗时较长,可在onLazyLoading方法中先为此数据创建占位符,再创建异步任务加载数据。
250> - 当使用onLazyLoading,并设置onTotalCount为`arr.length + 1`时,可实现数据的无限加载。需要注意,在此场景下,开发者需要提供首屏显示所需的初始数据,并建议设置父容器组件`cachedCount > 0`,否则将会导致渲染异常。若与Swiper-Loop模式同时使用,停留在`index = 0`处时将导致onLazyLoading方法被持续触发,建议避免与Swiper-Loop模式同时使用。此外,开发者需要关注内存消耗情况,避免因数据持续加载而导致内存过量消耗。
251
252## 使用场景
253
254### non-virtualScroll数据展示&操作
255
256#### 数据源变化
257
258```ts
259@Entry
260@ComponentV2
261struct Parent {
262  @Local simpleList: Array<string> = ['one', 'two', 'three'];
263
264  build() {
265    Row() {
266      Column() {
267        Text('点击修改第3个数组项的值')
268          .fontSize(24)
269          .fontColor(Color.Red)
270          .onClick(() => {
271            this.simpleList[2] = 'new three';
272          })
273
274        Repeat<string>(this.simpleList)
275            .each((obj: RepeatItem<string>)=>{
276              ChildItem({ item: obj.item })
277                .margin({top: 20})
278            })
279            .key((item: string) => item)
280      }
281      .justifyContent(FlexAlign.Center)
282      .width('100%')
283      .height('100%')
284    }
285    .height('100%')
286    .backgroundColor(0xF1F3F5)
287  }
288}
289
290@ComponentV2
291struct ChildItem {
292  @Param @Require item: string;
293
294  build() {
295    Text(this.item)
296      .fontSize(30)
297  }
298}
299```
300
301![ForEach-Non-Initial-Render-Case-Effect](./figures/ForEach-Non-Initial-Render-Case-Effect.gif)
302
303第三个数组项重新渲染时会复用之前的第三项的组件,仅对数据做了刷新。
304
305#### 索引值变化
306
307下方例子当交换数组项1和2时,若键值和上次保持一致,Repeat会复用之前的组件,仅对使用了index索引值的组件做数据刷新。
308
309```ts
310@Entry
311@ComponentV2
312struct Parent {
313  @Local simpleList: Array<string> = ['one', 'two', 'three'];
314
315  build() {
316    Row() {
317      Column() {
318        Text('交换数组项1,2')
319          .fontSize(24)
320          .fontColor(Color.Red)
321          .onClick(() => {
322            let temp: string = this.simpleList[2];
323            this.simpleList[2] = this.simpleList[1];
324            this.simpleList[1] = temp;
325          })
326          .margin({bottom: 20})
327
328        Repeat<string>(this.simpleList)
329          .each((obj: RepeatItem<string>)=>{
330            Text("index: " + obj.index)
331              .fontSize(30)
332            ChildItem({ item: obj.item })
333              .margin({bottom: 20})
334          })
335          .key((item: string) => item)
336      }
337      .justifyContent(FlexAlign.Center)
338      .width('100%')
339      .height('100%')
340    }
341    .height('100%')
342    .backgroundColor(0xF1F3F5)
343  }
344}
345
346@ComponentV2
347struct ChildItem {
348  @Param @Require item: string;
349
350  build() {
351    Text(this.item)
352      .fontSize(30)
353  }
354}
355```
356
357![Repeat-Non-Initial-Render-Case-Exchange-Effect](./figures/Repeat-Non-Initial-Render-Case-Exchange-Effect.gif)
358
359### virtualScroll数据展示&操作
360
361本小节将展示virtualScroll模式下,Repeat的实际使用场景和组件节点的复用情况。根据复用规则可以衍生出大量的测试场景,篇幅原因,只对典型的数据变化进行解释。
362
363#### 一个template
364
365下面的代码示例展示了Repeat virtualScroll模式下修改数组的常见操作,包括**插入数据、修改数据、删除数据、交换数据**。点击下拉框选择索引index值,点击相应的按钮即可进行数据修改操作。依次点击数据项可以交换被点击的两个数据项。
366
367```ts
368@ObservedV2
369class Repeat005Clazz {
370  @Trace message: string = '';
371
372  constructor(message: string) {
373    this.message = message;
374  }
375}
376
377@Entry
378@ComponentV2
379struct RepeatVirtualScroll {
380  @Local simpleList: Array<Repeat005Clazz> = [];
381  private exchange: number[] = [];
382  private counter: number = 0;
383  @Local selectOptions: SelectOption[] = [];
384  @Local selectIdx: number = 0;
385
386  @Monitor("simpleList")
387  reloadSelectOptions(): void {
388    this.selectOptions = [];
389    for (let i = 0; i < this.simpleList.length; ++i) {
390      this.selectOptions.push({ value: i.toString() });
391    }
392    if (this.selectIdx >= this.simpleList.length) {
393      this.selectIdx = this.simpleList.length - 1;
394    }
395  }
396
397  aboutToAppear(): void {
398    for (let i = 0; i < 100; i++) {
399      this.simpleList.push(new Repeat005Clazz(`item_${i}`));
400    }
401    this.reloadSelectOptions();
402  }
403
404  handleExchange(idx: number): void { // 点击交换子组件
405    this.exchange.push(idx);
406    if (this.exchange.length === 2) {
407      let _a = this.exchange[0];
408      let _b = this.exchange[1];
409      let temp: Repeat005Clazz = this.simpleList[_a];
410      this.simpleList[_a] = this.simpleList[_b];
411      this.simpleList[_b] = temp;
412      this.exchange = [];
413    }
414  }
415
416  build() {
417    Column({ space: 10 }) {
418      Text('virtualScroll each()&template() 1t')
419        .fontSize(15)
420        .fontColor(Color.Gray)
421      Text('Select an index and press the button to update data.')
422        .fontSize(15)
423        .fontColor(Color.Gray)
424
425      Select(this.selectOptions)
426        .selected(this.selectIdx)
427        .value(this.selectIdx.toString())
428        .key('selectIdx')
429        .onSelect((index: number) => {
430          this.selectIdx = index;
431        })
432      Row({ space: 5 }) {
433        Button('Add No.' + this.selectIdx)
434          .onClick(() => {
435            this.simpleList.splice(this.selectIdx, 0, new Repeat005Clazz(`${this.counter++}_add_item`));
436            this.reloadSelectOptions();
437          })
438        Button('Modify No.' + this.selectIdx)
439          .onClick(() => {
440            this.simpleList.splice(this.selectIdx, 1, new Repeat005Clazz(`${this.counter++}_modify_item`));
441          })
442        Button('Del No.' + this.selectIdx)
443          .onClick(() => {
444            this.simpleList.splice(this.selectIdx, 1);
445            this.reloadSelectOptions();
446          })
447      }
448      Button('Update array length to 5.')
449        .onClick(() => {
450          this.simpleList = this.simpleList.slice(0, 5);
451          this.reloadSelectOptions();
452        })
453
454      Text('Click on two items to exchange.')
455        .fontSize(15)
456        .fontColor(Color.Gray)
457
458      List({ space: 10 }) {
459        Repeat<Repeat005Clazz>(this.simpleList)
460          .each((obj: RepeatItem<Repeat005Clazz>) => {
461            ListItem() {
462              Text(`[each] index${obj.index}: ${obj.item.message}`)
463                .fontSize(25)
464                .onClick(() => {
465                  this.handleExchange(obj.index);
466                })
467            }
468          })
469          .key((item: Repeat005Clazz, index: number) => {
470            return item.message;
471          })
472          .virtualScroll({ totalCount: this.simpleList.length })
473          .templateId(() => "a")
474          .template('a', (ri) => {
475            Text(`[a] index${ri.index}: ${ri.item.message}`)
476              .fontSize(25)
477              .onClick(() => {
478                this.handleExchange(ri.index);
479              })
480          }, { cachedCount: 3 })
481      }
482      .cachedCount(2)
483      .border({ width: 1 })
484      .width('95%')
485      .height('40%')
486    }
487    .justifyContent(FlexAlign.Center)
488    .width('100%')
489    .height('100%')
490  }
491}
492```
493该应用列表内容为100项自定义类`RepeatClazz`的`message`字符串属性,List组件的cachedCount设为2,模板'a'的缓存池大小设为3。应用界面如下图所示:
494
495![Repeat-VirtualScroll-Demo](./figures/Repeat-VirtualScroll-Demo.gif)
496
497#### 多个template
498
499```ts
500@ObservedV2
501class Repeat006Clazz {
502  @Trace message: string = '';
503
504  constructor(message: string) {
505    this.message = message;
506  }
507}
508
509@Entry
510@ComponentV2
511struct RepeatVirtualScroll2T {
512  @Local simpleList: Array<Repeat006Clazz> = [];
513  private exchange: number[] = [];
514  private counter: number = 0;
515  @Local selectOptions: SelectOption[] = [];
516  @Local selectIdx: number = 0;
517
518  @Monitor("simpleList")
519  reloadSelectOptions(): void {
520    this.selectOptions = [];
521    for (let i = 0; i < this.simpleList.length; ++i) {
522      this.selectOptions.push({ value: i.toString() });
523    }
524    if (this.selectIdx >= this.simpleList.length) {
525      this.selectIdx = this.simpleList.length - 1;
526    }
527  }
528
529  aboutToAppear(): void {
530    for (let i = 0; i < 100; i++) {
531      this.simpleList.push(new Repeat006Clazz(`item_${i}`));
532    }
533    this.reloadSelectOptions();
534  }
535
536  handleExchange(idx: number): void { // 点击交换子组件
537    this.exchange.push(idx);
538    if (this.exchange.length === 2) {
539      let _a = this.exchange[0];
540      let _b = this.exchange[1];
541      let temp: Repeat006Clazz = this.simpleList[_a];
542      this.simpleList[_a] = this.simpleList[_b];
543      this.simpleList[_b] = temp;
544      this.exchange = [];
545    }
546  }
547
548  build() {
549    Column({ space: 10 }) {
550      Text('virtualScroll each()&template() 2t')
551        .fontSize(15)
552        .fontColor(Color.Gray)
553      Text('Select an index and press the button to update data.')
554        .fontSize(15)
555        .fontColor(Color.Gray)
556
557      Select(this.selectOptions)
558        .selected(this.selectIdx)
559        .value(this.selectIdx.toString())
560        .key('selectIdx')
561        .onSelect((index: number) => {
562          this.selectIdx = index;
563        })
564      Row({ space: 5 }) {
565        Button('Add No.' + this.selectIdx)
566          .onClick(() => {
567            this.simpleList.splice(this.selectIdx, 0, new Repeat006Clazz(`${this.counter++}_add_item`));
568            this.reloadSelectOptions();
569          })
570        Button('Modify No.' + this.selectIdx)
571          .onClick(() => {
572            this.simpleList.splice(this.selectIdx, 1, new Repeat006Clazz(`${this.counter++}_modify_item`));
573          })
574        Button('Del No.' + this.selectIdx)
575          .onClick(() => {
576            this.simpleList.splice(this.selectIdx, 1);
577            this.reloadSelectOptions();
578          })
579      }
580      Button('Update array length to 5.')
581        .onClick(() => {
582          this.simpleList = this.simpleList.slice(0, 5);
583          this.reloadSelectOptions();
584        })
585
586      Text('Click on two items to exchange.')
587        .fontSize(15)
588        .fontColor(Color.Gray)
589
590      List({ space: 10 }) {
591        Repeat<Repeat006Clazz>(this.simpleList)
592          .each((obj: RepeatItem<Repeat006Clazz>) => {
593            ListItem() {
594              Text(`[each] index${obj.index}: ${obj.item.message}`)
595                .fontSize(25)
596                .onClick(() => {
597                  this.handleExchange(obj.index);
598                })
599            }
600          })
601          .key((item: Repeat006Clazz, index: number) => {
602            return item.message;
603          })
604          .virtualScroll({ totalCount: this.simpleList.length })
605          .templateId((item: Repeat006Clazz, index: number) => {
606            return (index % 2 === 0) ? 'odd' : 'even';
607          })
608          .template('odd', (ri) => {
609            Text(`[odd] index${ri.index}: ${ri.item.message}`)
610              .fontSize(25)
611              .fontColor(Color.Blue)
612              .onClick(() => {
613                this.handleExchange(ri.index);
614              })
615          }, { cachedCount: 3 })
616          .template('even', (ri) => {
617            Text(`[even] index${ri.index}: ${ri.item.message}`)
618              .fontSize(25)
619              .fontColor(Color.Green)
620              .onClick(() => {
621                this.handleExchange(ri.index);
622              })
623          }, { cachedCount: 1 })
624      }
625      .cachedCount(2)
626      .border({ width: 1 })
627      .width('95%')
628      .height('40%')
629    }
630    .justifyContent(FlexAlign.Center)
631    .width('100%')
632    .height('100%')
633  }
634}
635```
636
637![Repeat-VirtualScroll-2T-Demo](./figures/Repeat-VirtualScroll-2T-Demo.gif)
638
639#### 数据精准懒加载
640
641当数据源总长度较长,或数据项加载耗时较长时,可使用数据懒加载功能,避免在初始化时加载所有数据。
642
643**示例一**
644
645数据源总长度较长,在首次渲染、滑动屏幕、跳转显示区域时,动态加载对应区域内的数据。
646
647```ts
648@Entry
649@ComponentV2
650struct RepeatLazyLoading {
651  // 假设数据源总长度较长,为1000。初始数组未提供数据。
652  @Local arr: Array<string> = [];
653  scroller: Scroller = new Scroller();
654  build() {
655    Column({ space: 5 }) {
656      // 初始显示位置为index = 100, 数据可通过懒加载自动获取。
657      List({ scroller: this.scroller, space: 5, initialIndex: 100 }) {
658        Repeat(this.arr)
659          .virtualScroll({
660            // 期望的数据源总长度为1000。
661            onTotalCount: () => { return 1000; },
662            // 实现数据懒加载。
663            onLazyLoading: (index: number) => { this.arr[index] = index.toString(); }
664          })
665          .each((obj: RepeatItem<string>) => {
666            ListItem() {
667              Row({ space: 5 }) {
668                Text(`${obj.index}: Item_${obj.item}`)
669              }
670            }
671            .height(50)
672          })
673      }
674      .height('80%')
675      .border({ width: 1})
676      // 显示位置跳转至index = 500, 数据可通过懒加载自动获取。
677      Button('ScrollToIndex 500')
678        .onClick(() => { this.scroller.scrollToIndex(500); })
679    }
680  }
681}
682```
683
684运行效果:
685
686![Repeat-Lazyloading-1](./figures/repeat-lazyloading-demo1.gif)
687
688**示例二**
689
690数据加载耗时长,在onLazyLoading方法中,首先为数据项创建占位符,再通过异步任务加载数据。
691
692```ts
693@Entry
694@ComponentV2
695struct RepeatLazyLoading {
696  @Local arr: Array<string> = [];
697  build() {
698    Column({ space: 5 }) {
699      List({ space: 5 }) {
700        Repeat(this.arr)
701          .virtualScroll({
702            onTotalCount: () => { return 100; },
703            // 实现数据懒加载。
704            onLazyLoading: (index: number) => {
705              // 创建占位符。
706              this.arr[index] = '';
707              // 模拟高耗时加载过程,通过异步任务加载数据。
708              setTimeout(() => { this.arr[index] = index.toString(); }, 1000);
709            }
710          })
711          .each((obj: RepeatItem<string>) => {
712            ListItem() {
713              Row({ space: 5 }) {
714                Text(`${obj.index}: Item_${obj.item}`)
715              }
716            }
717            .height(50)
718          })
719      }
720      .height('100%')
721      .border({ width: 1})
722    }
723  }
724}
725```
726
727运行效果:
728
729![Repeat-Lazyloading-2](./figures/repeat-lazyloading-demo2.gif)
730
731**示例三**
732
733使用数据懒加载,并配合设置`onTotalCount: () => { return this.arr.length + 1; }`,可实现数据无限懒加载。
734
735> **注意:**
736>
737> - 此场景下,开发者需要提供首屏显示所需的初始数据,并建议设置父容器组件`cachedCount > 0`,否则将会导致渲染异常。
738> - 若与Swiper-Loop模式同时使用,停留在`index = 0`处时将导致onLazyLoading方法被持续触发,建议避免与Swiper-Loop模式同时使用。
739> - 开发者需要关注内存消耗情况,避免因数据持续加载而导致内存过量消耗。
740
741```ts
742@Entry
743@ComponentV2
744struct RepeatLazyLoading {
745  @Local arr: Array<string> = [];
746  // 提供首屏显示所需的初始数据。
747  aboutToAppear(): void {
748    for (let i = 0; i < 15; i++) {
749      this.arr.push(i.toString());
750    }
751  }
752  build() {
753    Column({ space: 5 }) {
754      List({ space: 5 }) {
755        Repeat(this.arr)
756          .virtualScroll({
757            // 数据无限懒加载。
758            onTotalCount: () => { return this.arr.length + 1; },
759            onLazyLoading: (index: number) => { this.arr[index] = index.toString(); }
760          })
761          .each((obj: RepeatItem<string>) => {
762            ListItem() {
763              Row({ space: 5 }) {
764                Text(`${obj.index}: Item_${obj.item}`)
765              }
766            }
767            .height(50)
768          })
769      }
770      .height('100%')
771      .border({ width: 1})
772      // 建议设置cachedCount > 0
773      .cachedCount(1)
774    }
775  }
776}
777```
778
779运行效果:
780
781![Repeat-Lazyloading-3](./figures/repeat-lazyloading-demo3.gif)
782
783### Repeat嵌套
784
785Repeat支持嵌套使用。下面是使用virtualScroll模式进行嵌套的示例代码:
786
787```ts
788// Repeat嵌套
789@Entry
790@ComponentV2
791struct RepeatNest {
792  @Local outerList: string[] = [];
793  @Local innerList: number[] = [];
794
795  aboutToAppear(): void {
796    for (let i = 0; i < 20; i++) {
797      this.outerList.push(i.toString());
798      this.innerList.push(i);
799    }
800  }
801
802  build() {
803    Column({ space: 20 }) {
804      Text('Repeat virtualScroll嵌套')
805        .fontSize(15)
806        .fontColor(Color.Gray)
807      List() {
808        Repeat<string>(this.outerList)
809          .each((obj) => {
810            ListItem() {
811              Column() {
812                Text('outerList item: ' + obj.item)
813                  .fontSize(30)
814                List() {
815                  Repeat<number>(this.innerList)
816                    .each((subObj) => {
817                      ListItem() {
818                        Text('innerList item: ' + subObj.item)
819                          .fontSize(20)
820                      }
821                    })
822                    .key((item) => "innerList_" + item)
823                    .virtualScroll()
824                }
825                .width('80%')
826                .border({ width: 1 })
827                .backgroundColor(Color.Orange)
828              }
829              .height('30%')
830              .backgroundColor(Color.Pink)
831            }
832            .border({ width: 1 })
833          })
834          .key((item) => "outerList_" + item)
835          .virtualScroll()
836      }
837      .width('80%')
838      .border({ width: 1 })
839    }
840    .justifyContent(FlexAlign.Center)
841    .width('90%')
842    .height('80%')
843  }
844}
845```
846
847运行效果:
848
849![Repeat-Nest](./figures/Repeat-Nest.png)
850
851### 父容器组件应用场景
852
853本节展示Repeat virtualScroll模式与容器组件的常见应用场景。
854
855#### 与List组合使用
856
857在List容器组件中使用Repeat的virtualScroll模式,示例如下:
858
859```ts
860class DemoListItemInfo {
861  name: string;
862  icon: Resource;
863
864  constructor(name: string, icon: Resource) {
865    this.name = name;
866    this.icon = icon;
867  }
868}
869
870@Entry
871@ComponentV2
872struct DemoList {
873  @Local videoList: Array<DemoListItemInfo> = [];
874
875  aboutToAppear(): void {
876    for (let i = 0; i < 10; i++) {
877      // 此处app.media.listItem0app.media.listItem1app.media.listItem2仅作示例,请开发者自行替换
878      this.videoList.push(new DemoListItemInfo('视频' + i,
879        i % 3 == 0 ? $r("app.media.listItem0") :
880        i % 3 == 1 ? $r("app.media.listItem1") : $r("app.media.listItem2")));
881    }
882  }
883
884  @Builder
885  itemEnd(index: number) {
886    Button('删除')
887      .backgroundColor(Color.Red)
888      .onClick(() => {
889        this.videoList.splice(index, 1);
890      })
891  }
892
893  build() {
894    Column({ space: 10 }) {
895      Text('List容器组件中包含Repeat组件')
896        .fontSize(15)
897        .fontColor(Color.Gray)
898
899      List({ space: 5 }) {
900        Repeat<DemoListItemInfo>(this.videoList)
901          .each((obj: RepeatItem<DemoListItemInfo>) => {
902            ListItem() {
903              Column() {
904                Image(obj.item.icon)
905                  .width('80%')
906                  .margin(10)
907                Text(obj.item.name)
908                  .fontSize(20)
909              }
910            }
911            .swipeAction({
912              end: {
913                builder: () => {
914                  this.itemEnd(obj.index);
915                }
916              }
917            })
918            .onAppear(() => {
919              console.info('AceTag', obj.item.name);
920            })
921          })
922          .key((item: DemoListItemInfo) => item.name)
923          .virtualScroll()
924      }
925      .cachedCount(2)
926      .height('90%')
927      .border({ width: 1 })
928      .listDirection(Axis.Vertical)
929      .alignListItem(ListItemAlign.Center)
930      .divider({
931        strokeWidth: 1,
932        startMargin: 60,
933        endMargin: 60,
934        color: '#ffe9f0f0'
935      })
936
937      Row({ space: 10 }) {
938        Button('删除第1项')
939          .onClick(() => {
940            this.videoList.splice(0, 1);
941          })
942        Button('删除第5项')
943          .onClick(() => {
944            this.videoList.splice(4, 1);
945          })
946      }
947    }
948    .width('100%')
949    .height('100%')
950    .justifyContent(FlexAlign.Center)
951  }
952}
953```
954
955右滑并点击按钮,或点击底部按钮,可删除视频卡片:
956
957![Repeat-Demo-List](./figures/Repeat-Demo-List.gif)
958
959#### 与Grid组合使用
960
961在Grid容器组件中使用Repeat的virtualScroll模式,示例如下:
962
963```ts
964class DemoGridItemInfo {
965  name: string;
966  icon: Resource;
967
968  constructor(name: string, icon: Resource) {
969    this.name = name;
970    this.icon = icon;
971  }
972}
973
974@Entry
975@ComponentV2
976struct DemoGrid {
977  @Local itemList: Array<DemoGridItemInfo> = [];
978  @Local isRefreshing: boolean = false;
979  private layoutOptions: GridLayoutOptions = {
980    regularSize: [1, 1],
981    irregularIndexes: [10]
982  }
983  private GridScroller: Scroller = new Scroller();
984  private num: number = 0;
985
986  aboutToAppear(): void {
987    for (let i = 0; i < 10; i++) {
988      // 此处app.media.gridItem0app.media.gridItem1app.media.gridItem2仅作示例,请开发者自行替换
989      this.itemList.push(new DemoGridItemInfo('视频' + i,
990        i % 3 == 0 ? $r("app.media.gridItem0") :
991        i % 3 == 1 ? $r("app.media.gridItem1") : $r("app.media.gridItem2")));
992    }
993  }
994
995  build() {
996    Column({ space: 10 }) {
997      Text('Grid容器组件中包含Repeat组件')
998        .fontSize(15)
999        .fontColor(Color.Gray)
1000
1001      Refresh({ refreshing: $$this.isRefreshing }) {
1002        Grid(this.GridScroller, this.layoutOptions) {
1003          Repeat<DemoGridItemInfo>(this.itemList)
1004            .each((obj: RepeatItem<DemoGridItemInfo>) => {
1005              if (obj.index === 10 ) {
1006                GridItem() {
1007                  Text('先前浏览至此,点击刷新')
1008                    .fontSize(20)
1009                }
1010                .height(30)
1011                .border({ width: 1 })
1012                .onClick(() => {
1013                  this.GridScroller.scrollToIndex(0);
1014                  this.isRefreshing = true;
1015                })
1016                .onAppear(() => {
1017                  console.info('AceTag', obj.item.name);
1018                })
1019              } else {
1020                GridItem() {
1021                  Column() {
1022                    Image(obj.item.icon)
1023                      .width('100%')
1024                      .height(80)
1025                      .objectFit(ImageFit.Cover)
1026                      .borderRadius({ topLeft: 16, topRight: 16 })
1027                    Text(obj.item.name)
1028                      .fontSize(15)
1029                      .height(20)
1030                  }
1031                }
1032                .height(100)
1033                .borderRadius(16)
1034                .backgroundColor(Color.White)
1035                .onAppear(() => {
1036                  console.info('AceTag', obj.item.name);
1037                })
1038              }
1039            })
1040            .key((item: DemoGridItemInfo) => item.name)
1041            .virtualScroll()
1042        }
1043        .columnsTemplate('repeat(auto-fit, 150)')
1044        .cachedCount(4)
1045        .rowsGap(15)
1046        .columnsGap(10)
1047        .height('100%')
1048        .padding(10)
1049        .backgroundColor('#F1F3F5')
1050      }
1051      .onRefreshing(() => {
1052        setTimeout(() => {
1053          this.itemList.splice(10, 1);
1054          this.itemList.unshift(new DemoGridItemInfo('refresh', $r('app.media.gridItem0'))); // 此处app.media.gridItem0仅作示例,请开发者自行替换
1055          for (let i = 0; i < 10; i++) {
1056            // 此处app.media.gridItem0app.media.gridItem1app.media.gridItem2仅作示例,请开发者自行替换
1057            this.itemList.unshift(new DemoGridItemInfo('新视频' + this.num,
1058              i % 3 == 0 ? $r("app.media.gridItem0") :
1059              i % 3 == 1 ? $r("app.media.gridItem1") : $r("app.media.gridItem2")));
1060            this.num++;
1061          }
1062          this.isRefreshing = false;
1063        }, 1000);
1064        console.info('AceTag', 'onRefreshing');
1065      })
1066      .refreshOffset(64)
1067      .pullToRefresh(true)
1068      .width('100%')
1069      .height('85%')
1070
1071      Button('刷新')
1072        .onClick(() => {
1073          this.GridScroller.scrollToIndex(0);
1074          this.isRefreshing = true;
1075        })
1076    }
1077    .width('100%')
1078    .height('100%')
1079    .justifyContent(FlexAlign.Center)
1080  }
1081}
1082```
1083
1084下拉屏幕,或点击刷新按钮,或点击“先前浏览至此,点击刷新”,可加载新的视频内容:
1085
1086![Repeat-Demo-Grid](./figures/Repeat-Demo-Grid.gif)
1087
1088#### 与Swiper组合使用
1089
1090在Swiper容器组件中使用Repeat的virtualScroll模式,示例如下:
1091
1092```ts
1093const remotePictures: Array<string> = [
1094  'https://www.example.com/xxx/0001.jpg', // 请填写具体的网络图片地址
1095  'https://www.example.com/xxx/0002.jpg',
1096  'https://www.example.com/xxx/0003.jpg',
1097  'https://www.example.com/xxx/0004.jpg',
1098  'https://www.example.com/xxx/0005.jpg',
1099  'https://www.example.com/xxx/0006.jpg',
1100  'https://www.example.com/xxx/0007.jpg',
1101  'https://www.example.com/xxx/0008.jpg',
1102  'https://www.example.com/xxx/0009.jpg'
1103];
1104
1105@ObservedV2
1106class DemoSwiperItemInfo {
1107  id: string;
1108  @Trace url: string = 'default';
1109
1110  constructor(id: string) {
1111    this.id = id;
1112  }
1113}
1114
1115@Entry
1116@ComponentV2
1117struct DemoSwiper {
1118  @Local pics: Array<DemoSwiperItemInfo> = [];
1119
1120  aboutToAppear(): void {
1121    for (let i = 0; i < 9; i++) {
1122      this.pics.push(new DemoSwiperItemInfo('pic' + i));
1123    }
1124    setTimeout(() => {
1125      this.pics[0].url = remotePictures[0];
1126    }, 1000);
1127  }
1128
1129  build() {
1130    Column() {
1131      Text('Swiper容器组件中包含Repeat组件')
1132        .fontSize(15)
1133        .fontColor(Color.Gray)
1134
1135      Stack() {
1136        Text('图片加载中')
1137          .fontSize(15)
1138          .fontColor(Color.Gray)
1139        Swiper() {
1140          Repeat(this.pics)
1141            .each((obj: RepeatItem<DemoSwiperItemInfo>) => {
1142              Image(obj.item.url)
1143                .onAppear(() => {
1144                  console.info('AceTag', obj.item.id);
1145                })
1146            })
1147            .key((item: DemoSwiperItemInfo) => item.id)
1148            .virtualScroll()
1149        }
1150        .cachedCount(9)
1151        .height('50%')
1152        .loop(false)
1153        .indicator(true)
1154        .onChange((index) => {
1155          setTimeout(() => {
1156            this.pics[index].url = remotePictures[index];
1157          }, 1000);
1158        })
1159      }
1160      .width('100%')
1161      .height('100%')
1162      .backgroundColor(Color.Black)
1163    }
1164  }
1165}
1166```
1167
1168定时1秒后加载图片,模拟网络延迟:
1169
1170![Repeat-Demo-Swiper](./figures/Repeat-Demo-Swiper.gif)
1171
1172### 拖拽排序
1173
1174当Repeat在List组件下使用,并且设置了onMove事件,Repeat每次迭代都生成一个ListItem时,可以使能拖拽排序。non-virtualScroll模式和virtualScroll模式都支持设置拖拽排序。
1175
1176#### 使用限制
1177- 拖拽排序离手后,如果数据位置发生变化,则会触发onMove事件,上报数据移动原始索引号和目标索引号。在onMove事件中,需要根据上报的起始索引号和目标索引号修改数据源。数据源修改前后,要保持每个数据的键值不变,只是顺序发生变化,才能保证落位动画正常执行。
1178- 拖拽排序过程中,在离手之前,不允许修改数据源。
1179
1180#### 示例代码
1181```ts
1182@Entry
1183@ComponentV2
1184struct RepeatVirtualScrollOnMove {
1185  @Local simpleList: Array<string> = [];
1186
1187  aboutToAppear(): void {
1188    for (let i = 0; i < 100; i++) {
1189      this.simpleList.push(`${i}`);
1190    }
1191  }
1192
1193  build() {
1194    Column() {
1195      List() {
1196        Repeat<string>(this.simpleList)
1197          // 通过设置onMove,使能拖拽排序。
1198          .onMove((from: number, to: number) => {
1199            let temp = this.simpleList.splice(from, 1);
1200            this.simpleList.splice(to, 0, temp[0]);
1201          })
1202          .each((obj: RepeatItem<string>) => {
1203            ListItem() {
1204              Text(obj.item)
1205                .fontSize(16)
1206                .textAlign(TextAlign.Center)
1207                .size({height: 100, width: "100%"})
1208            }.margin(10)
1209            .borderRadius(10)
1210            .backgroundColor("#FFFFFFFF")
1211          })
1212          .key((item: string, index: number) => {
1213            return item;
1214          })
1215          .virtualScroll({ totalCount: this.simpleList.length })
1216      }
1217      .border({ width: 1 })
1218      .backgroundColor("#FFDCDCDC")
1219      .width('100%')
1220      .height('100%')
1221    }
1222  }
1223}
1224```
1225
1226运行效果:
1227
1228![Repeat-Drag-Sort](figures/ForEach-Drag-Sort.gif)
1229
1230### 通过`.key()`函数控制列表节点刷新范围
1231
1232从API version 18开始,开发者自定义`.key()`函数时,Repeat子节点会根据key的变化来判断节点是否需要更新。修改数组后,1)如果key发生改变,则页面立即刷新,数据更新为修改后的值;2)key没有改变,页面不会立即刷新。
1233
1234前置条件:使用Repeat virtualScroll模式渲染数组,数据项为被`@ObservedV2`装饰的类,两个类属性均被`@Trace`装饰。其中`msg`属性值作为列表渲染的节点内容,点击“click”按钮修改列表第一个节点的内容。
1235
1236第一种预期场景:当列表节点数据的属性值发生改变时,触发页面刷新,第一个列表节点数据更新为修改后的值。
1237
1238这种场景有两种实现方式:1)定义`.key()`函数,并且对应节点的键值发生变化;2)不定义`.key()`函数,Repeat会直接对比数据对象是否改变。代码示例如下:
1239
1240```ts
1241@ObservedV2
1242class RepeatData {
1243  @Trace id: string;
1244  @Trace msg: string;
1245
1246  constructor(id: string, msg: string) {
1247    this.id = id;
1248    this.msg = msg;
1249  }
1250}
1251
1252@Entry
1253@ComponentV2
1254struct RepeatRerender {
1255  @Local dataArr: Array<RepeatData> = [];
1256
1257  aboutToAppear(): void {
1258    for (let i = 0; i < 10; i++) {
1259      this.dataArr.push(new RepeatData(`key${i}`, `data${i}`));
1260    }
1261  }
1262
1263  build() {
1264    Column({ space: 20 }) {
1265      List() {
1266        Repeat<RepeatData>(this.dataArr)
1267          .each((ri: RepeatItem<RepeatData>) => {
1268            ListItem() {
1269              Text(ri.item.msg).fontSize(30)
1270            }
1271          })
1272          .key((item: RepeatData, index: number) => item.msg) // 第一种方式:将.key()函数返回值设为与节点数据的改变保持一致的值,如msg属性值
1273          // 第二种方式:删除.key()函数
1274          .virtualScroll()
1275      }
1276      .cachedCount(2)
1277      .width('100%')
1278      .height('40%')
1279      .border({ width: 1 })
1280      .backgroundColor(0xFAEEE0)
1281
1282      Button('click').onClick(() => {
1283        this.dataArr.splice(0, 1, new RepeatData('key0', 'new msg')); // 改变列表第一个节点数据的msg属性值
1284      })
1285    }
1286  }
1287}
1288```
1289
1290运行效果如下,点击按钮后数据发生改变。
1291
1292![Repeat-Rerender-Wrong](./figures/Repeat-Rerender-Wrong.gif)
1293
1294第二种预期场景:当列表节点数据的属性值发生改变但是key不变时,不会立即触发页面刷新,从而控制节点的刷新频率,提高页面整体的渲染性能。
1295
1296实现方式:定义`.key()`函数,返回值为节点数据对象的`id`属性。点击按钮后,保持`id`属性值不变(即key不变),修改`msg`属性值,页面不会刷新。代码示例如下:
1297
1298需要注意的是,直接修改`msg`属性(即`this.dataArr[0].msg = 'new msg'`),页面仍然会触发刷新。原因是`msg`属性值被`@Trace`装饰,如果被直接修改,会触发状态变量变化逻辑,页面立即刷新。
1299
1300```ts
1301@ObservedV2
1302class RepeatData {
1303  @Trace id: string;
1304  @Trace msg: string;
1305
1306  constructor(id: string, msg: string) {
1307    this.id = id;
1308    this.msg = msg;
1309  }
1310}
1311
1312@Entry
1313@ComponentV2
1314struct RepeatRerender {
1315  @Local dataArr: Array<RepeatData> = [];
1316
1317  aboutToAppear(): void {
1318    for (let i = 0; i < 10; i++) {
1319      this.dataArr.push(new RepeatData(`key${i}`, `data${i}`));
1320    }
1321  }
1322
1323  build() {
1324    Column({ space: 20 }) {
1325      List() {
1326        Repeat<RepeatData>(this.dataArr)
1327          .each((ri: RepeatItem<RepeatData>) => {
1328            ListItem() {
1329              Text(ri.item.msg).fontSize(30)
1330            }
1331          })
1332          .key((item: RepeatData, index: number) => item.id) // 将.key()函数返回值设为不受子节点数据改变影响的值,如id属性值
1333          .virtualScroll()
1334      }
1335      .cachedCount(2)
1336      .width('100%')
1337      .height('40%')
1338      .border({ width: 1 })
1339      .backgroundColor(0xFAEEE0)
1340
1341      Button('click').onClick(() => {
1342        this.dataArr.splice(0, 1, new RepeatData('key0', 'new msg')); // 改变列表第一个节点数据的msg属性值,保持id属性值不变
1343      })
1344    }
1345  }
1346}
1347```
1348
1349运行效果如下,点击按钮后数据不发生改变。
1350
1351![Repeat-Rerender-Correct](./figures/Repeat-Rerender-Correct.gif)
1352
1353## 常见问题
1354
1355### 屏幕外的列表数据发生变化时,保证滚动条位置不变
1356
1357以下示例中,屏幕外的数据源变化将影响屏幕中List列表Scroller停留的位置:
1358在List组件中声明Repeat组件,实现key值生成逻辑和each逻辑(如下示例代码),点击按钮“insert”,在屏幕显示的第一个元素前面插入一个元素,屏幕出现向下滚动。
1359
1360```ts
1361// 定义一个类,标记为可观察的
1362// 类中自定义一个数组,标记为可追踪的
1363@ObservedV2
1364class ArrayHolder {
1365  @Trace arr: Array<number> = [];
1366
1367  // constructor,用于初始化数组个数
1368  constructor(count: number) {
1369    for (let i = 0; i < count; i++) {
1370      this.arr.push(i);
1371    }
1372  }
1373}
1374
1375@Entry
1376@ComponentV2
1377struct RepeatTemplateSingle {
1378  @Local arrayHolder: ArrayHolder = new ArrayHolder(100);
1379  @Local totalCount: number = this.arrayHolder.arr.length;
1380  scroller: Scroller = new Scroller();
1381
1382  build() {
1383    Column({ space: 5 }) {
1384      List({ space: 20, initialIndex: 19, scroller: this.scroller }) {
1385        Repeat(this.arrayHolder.arr)
1386          .virtualScroll({ totalCount: this.totalCount })
1387          .templateId((item, index) => {
1388            return 'number';
1389          })
1390          .template('number', (r) => {
1391            ListItem() {
1392              Text(r.index! + ":" + r.item + "Reuse");
1393            }
1394          })
1395          .each((r) => {
1396            ListItem() {
1397              Text(r.index! + ":" + r.item + "eachMessage");
1398            }
1399          })
1400      }
1401      .height('30%')
1402
1403      Button(`insert totalCount ${this.totalCount}`)
1404        .height(60)
1405        .onClick(() => {
1406          // 插入元素,元素位置为屏幕显示的前一个元素
1407          this.arrayHolder.arr.splice(18, 0, this.totalCount);
1408          this.totalCount = this.arrayHolder.arr.length;
1409        })
1410    }
1411    .width('100%')
1412    .margin({ top: 5 })
1413  }
1414}
1415```
1416
1417运行效果:
1418
1419![Repeat-case1-Error](./figures/Repeat-Case1-Error.gif)
1420
1421以下为修正后的示例:
1422在一些场景中,我们不希望屏幕外的数据源变化影响屏幕中List列表Scroller停留的位置,可以通过List组件的[onScrollIndex](../arkts-layout-development-create-list.md#响应滚动位置)事件对列表滚动动作进行监听,当列表发生滚动时,获取列表滚动位置。使用Scroller组件的[scrollToIndex](../../reference/apis-arkui/arkui-ts/ts-container-scroll.md#scrolltoindex)特性,滑动到指定index位置,实现屏幕外的数据源增加/删除数据时,Scroller停留的位置不变的效果。
1423
1424示例代码仅对增加数据的情况进行展示。
1425
1426```ts
1427// ...ArrayHolder的定义和上述demo代码一致
1428
1429@Entry
1430@ComponentV2
1431struct RepeatTemplateSingle {
1432  @Local arrayHolder: ArrayHolder = new ArrayHolder(100);
1433  @Local totalCount: number = this.arrayHolder.arr.length;
1434  scroller: Scroller = new Scroller();
1435
1436  private start: number = 1;
1437  private end: number = 1;
1438
1439  build() {
1440    Column({ space: 5 }) {
1441      List({ space: 20, initialIndex: 19, scroller: this.scroller }) {
1442        Repeat(this.arrayHolder.arr)
1443          .virtualScroll({ totalCount: this.totalCount })
1444          .templateId((item, index) => {
1445            return 'number';
1446          })
1447          .template('number', (r) => {
1448            ListItem() {
1449              Text(r.index! + ":" + r.item + "Reuse")
1450            }
1451          })
1452          .each((r) => {
1453            ListItem() {
1454              Text(r.index! + ":" + r.item + "eachMessage")
1455            }
1456          })
1457      }
1458      .onScrollIndex((start, end) => {
1459        this.start = start;
1460        this.end = end;
1461      })
1462      .height('30%')
1463
1464      Button(`insert totalCount ${this.totalCount}`)
1465        .height(60)
1466        .onClick(() => {
1467          // 插入元素,元素位置为屏幕显示的前一个元素
1468          this.arrayHolder.arr.splice(18, 0, this.totalCount);
1469          let rect = this.scroller.getItemRect(this.start); // 获取子组件的大小位置
1470          this.scroller.scrollToIndex(this.start + 1); // 滑动到指定index
1471          this.scroller.scrollBy(0, -rect.y); // 滑动指定距离
1472          this.totalCount = this.arrayHolder.arr.length;
1473        })
1474    }
1475    .width('100%')
1476    .margin({ top: 5 })
1477  }
1478}
1479```
1480
1481运行效果:
1482
1483![Repeat-case1-Succ](./figures/Repeat-Case1-Succ.gif)
1484
1485### totalCount值大于数据源长度
1486
1487当数据源总长度很大时,会使用懒加载的方式先加载一部分数据,为了使Repeat显示正确的滚动条样式,需要将数据总长度赋值给totalCount,即数据源全部加载完成前,totalCount大于array.length1488
1489totalCount > array.length时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,开发者需要对数据请求的错误场景(如网络延迟)进行保护操作,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。
1490
1491上述规范可以通过实现父组件List/Grid的[onScrollIndex](../arkts-layout-development-create-list.md#响应滚动位置)属性的回调函数完成。示例代码如下:
1492
1493```ts
1494@ObservedV2
1495class VehicleData {
1496  @Trace name: string;
1497  @Trace price: number;
1498
1499  constructor(name: string, price: number) {
1500    this.name = name;
1501    this.price = price;
1502  }
1503}
1504
1505@ObservedV2
1506class VehicleDB {
1507  public vehicleItems: VehicleData[] = [];
1508
1509  constructor() {
1510    // 数组初始化大小 20
1511    for (let i = 1; i <= 20; i++) {
1512      this.vehicleItems.push(new VehicleData(`Vehicle${i}`, i));
1513    }
1514  }
1515}
1516
1517@Entry
1518@ComponentV2
1519struct entryCompSucc {
1520  @Local vehicleItems: VehicleData[] = new VehicleDB().vehicleItems;
1521  @Local listChildrenSize: ChildrenMainSize = new ChildrenMainSize(60);
1522  @Local totalCount: number = this.vehicleItems.length;
1523  scroller: Scroller = new Scroller();
1524
1525  build() {
1526    Column({ space: 3 }) {
1527      List({ scroller: this.scroller }) {
1528        Repeat(this.vehicleItems)
1529          .virtualScroll({ totalCount: 50 }) // 数组预期长度 50
1530          .templateId(() => 'default')
1531          .template('default', (ri) => {
1532            ListItem() {
1533              Column() {
1534                Text(`${ri.item.name} + ${ri.index}`)
1535                  .width('90%')
1536                  .height(this.listChildrenSize.childDefaultSize)
1537                  .backgroundColor(0xFFA07A)
1538                  .textAlign(TextAlign.Center)
1539                  .fontSize(20)
1540                  .fontWeight(FontWeight.Bold)
1541              }
1542            }.border({ width: 1 })
1543          }, { cachedCount: 5 })
1544          .each((ri) => {
1545            ListItem() {
1546              Text("Wrong: " + `${ri.item.name} + ${ri.index}`)
1547                .width('90%')
1548                .height(this.listChildrenSize.childDefaultSize)
1549                .backgroundColor(0xFFA07A)
1550                .textAlign(TextAlign.Center)
1551                .fontSize(20)
1552                .fontWeight(FontWeight.Bold)
1553            }.border({ width: 1 })
1554          })
1555          .key((item, index) => `${index}:${item}`)
1556      }
1557      .height('50%')
1558      .margin({ top: 20 })
1559      .childrenMainSize(this.listChildrenSize)
1560      .alignListItem(ListItemAlign.Center)
1561      .onScrollIndex((start, end) => {
1562        console.log('onScrollIndex', start, end);
1563        // 数据懒加载
1564        if (this.vehicleItems.length < 50) {
1565          for (let i = 0; i < 10; i++) {
1566            if (this.vehicleItems.length < 50) {
1567              this.vehicleItems.push(new VehicleData("Vehicle_loaded", i));
1568            }
1569          }
1570        }
1571      })
1572    }
1573  }
1574}
1575```
1576
1577示例代码运行效果:
1578
1579![Repeat-Case2-Succ](./figures/Repeat-Case2-Succ.gif)
1580
1581### Repeat与@Builder混用的限制
1582
1583当Repeat与@Builder混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递`RepeatItem.item`或`RepeatItem.index`,将会出现UI渲染异常。
1584
1585示例代码如下:
1586
1587```ts
1588@Entry
1589@ComponentV2
1590struct RepeatBuilderPage {
1591  @Local simpleList1: Array<number> = [];
1592  @Local simpleList2: Array<number> = [];
1593
1594  aboutToAppear(): void {
1595    for (let i = 0; i < 100; i++) {
1596      this.simpleList1.push(i);
1597      this.simpleList2.push(i);
1598    }
1599  }
1600
1601  build() {
1602    Column({ space: 20 }) {
1603      Text('Repeat与@Builder混用,左边是异常场景,右边是正常场景,向下滑动一段距离可以看出差别')
1604        .fontSize(15)
1605        .fontColor(Color.Gray)
1606
1607      Row({ space: 20 }) {
1608        List({ initialIndex: 5, space: 20 }) {
1609          Repeat<number>(this.simpleList1)
1610            .each((ri) => {})
1611            .virtualScroll({ totalCount: this.simpleList1.length })
1612            .templateId((item: number, index: number) => "default")
1613            .template('default', (ri) => {
1614              ListItem() {
1615                Column() {
1616                  Text('Text id = ' + ri.item)
1617                    .fontSize(20)
1618                  this.buildItem1(ri.item) // 错误示例,为避免渲染异常,应修改为:this.buildItem1(ri)
1619                }
1620              }
1621              .border({ width: 1 })
1622            }, { cachedCount: 3 })
1623        }
1624        .cachedCount(1)
1625        .border({ width: 1 })
1626        .width('45%')
1627        .height('60%')
1628
1629        List({ initialIndex: 5, space: 20 }) {
1630          Repeat<number>(this.simpleList2)
1631            .each((ri) => {})
1632            .virtualScroll({ totalCount: this.simpleList2.length })
1633            .templateId((item: number, index: number) => "default")
1634            .template('default', (ri) => {
1635              ListItem() {
1636                Column() {
1637                  Text('Text id = ' + ri.item)
1638                    .fontSize(20)
1639                  this.buildItem2(ri) // 正确示例,渲染正常
1640                }
1641              }
1642              .border({ width: 1 })
1643            }, { cachedCount: 3 })
1644        }
1645        .cachedCount(1)
1646        .border({ width: 1 })
1647        .width('45%')
1648        .height('60%')
1649      }
1650    }
1651    .height('100%')
1652    .justifyContent(FlexAlign.Center)
1653  }
1654
1655  @Builder
1656  // @Builder参数必须传RepeatItem类型才能正常渲染
1657  buildItem1(item: number) {
1658    Text('Builder1 id = ' + item)
1659      .fontSize(20)
1660      .fontColor(Color.Red)
1661      .margin({ top: 2 })
1662  }
1663
1664  @Builder
1665  buildItem2(ri: RepeatItem<number>) {
1666    Text('Builder2 id = ' + ri.item)
1667      .fontSize(20)
1668      .fontColor(Color.Red)
1669      .margin({ top: 2 })
1670  }
1671}
1672```
1673
1674界面展示如下图,进入页面后向下滑动一段距离可以看出差别,左边是错误用法,右边是正确用法(Text组件为黑色,Builder组件为红色)。上述代码展示了开发过程中易出错的场景,即在@Builder构造函数中传参方式为值传递。
1675
1676![Repeat-Builder](./figures/Repeat-Builder.png)