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
9Repeat组件non-virtualScroll场景(不开启virtualScroll开关)中,Repeat基于数据源进行循环渲染,需要与容器组件配合使用,且接口返回的组件应当是允许包含在Repeat父容器组件中的子组件。Repeat循环渲染和ForEach相比有两个区别,一是优化了部分更新场景下的渲染性能,二是组件生成函数的索引index由框架侧来维护。
10
11Repeat组件virtualScroll场景中,Repeat将从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件,必须与滚动类容器组件配合使用。当在滚动类容器组件中使用了Repeat,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会缓存组件,并在下一次迭代中使用。
12
13## 使用限制
14
15- Repeat使用键值作为标识,因此键值生成函数`key()`必须针对每个数据生成唯一的值。
16- Repeat virtualScroll场景必须在滚动类容器组件内使用,仅有[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)组件支持virtualScroll场景(此时配置cachedCount会生效)。其它容器组件只适用于non-virtualScroll场景。
17- Repeat开启virtualScroll后,在每次迭代中,必须创建且只允许创建一个子组件。不开启virtualScroll没有该限制。生成的子组件必须是允许包含在Repeat父容器组件中的子组件。
18- 当Repeat与自定义组件/@Builder函数混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递`RepeatItem.item`或`RepeatItem.index`,将会出现UI渲染异常。
19- template模板目前只支持virtualScroll场景。当多个template type相同时,Repeat会覆盖旧的`template()`函数,仅生效最新的`template()`。
20- totalCount > array.length时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。解决方案见[totalCount值大于数据源长度](#totalcount值大于数据源长度)。
21- 在容器组件内使用Repeat的时候,只能包含一个Repeat。以List为例,同时包含ListItem、ForEach、LazyForEach的场景是不推荐的;同时包含多个Repeat也是不推荐的。
22- Repeat组件的virtualScroll场景不支持V1装饰器,使用V1装饰器存在渲染异常,不建议开发者同时使用。
23
24## 键值生成规则
25
26键值生成函数`key()`的目的是允许Repeat识别数组更改的细节:添加了哪些数据、删除了哪些数据,以及哪些数据改变了位置(索引)。
27
28开发者使用建议:
29
30- 即使数据项有重复,开发者也必须保证键值key唯一(即使数据源发生变化);
31- 每次执行`key()`函数时,使用相同的数据项作为输入,输出必须是一致的;
32- `key()`中使用index是允许的,但不建议这样使用。原因是数据项移动时索引发生变化,即键值发生变化。因此Repeat会认为数据项发生了变化,并触发UI重新渲染,会降低性能表现;
33- 推荐将简单类型数组转换为类对象数组,并添加一个`readonly id`属性,在构造函数中给它赋一个唯一的值。
34
35### non-virtualScroll规则
36
37`key()`可以缺省,Repeat会生成默认key值。
38
39![Repeat-NonVS-KeyGen](./figures/Repeat-NonVS-KeyGen.png)
40
41### virtualScroll规则
42
43和non-virtualScroll的键值生成规则基本一致,`key()`可以缺省。
44
45![Repeat-VS-KeyGen](./figures/Repeat-VS-KeyGen.png)
46
47## 组件生成及复用规则
48
49### non-virtualScroll规则
50
51子组件在Repeat首次渲染时全部创建,在数据更新时会对原组件进行复用。
52
53在Repeat组件进行数据更新时,它会依次对比上次的所有键值和本次更新之后的区别。若当前键值和上次的某一项键值相同,Repeat会直接复用子组件并对RepeatItem.index索引做对应的更新。
54
55当Repeat将所有重复的键值对比完并做了相应的复用后,若上次的键值有不重复的且本次更新之后有新的键值生成需要新建子组件时,Repeat会复用上次多余的子组件并更新RepeatItem.item数据源和RepeatItem.index索引并刷新UI。
56
57若上次的剩余>=本次新更新的数量,则组件完全复用并释放多余的未被复用的组件。若上次的剩余小于本次新更新的数量,将剩余的组件复用完后,Repeat会新建多出来的数据项对应的组件。
58
59![Repeat-NonVS-FuncGen](./figures/Repeat-NonVS-FuncGen.png)
60
61### virtualScroll规则
62
63子组件在Repeat首次渲染只生成当前需要的组件,在滑动和数据更新时会缓存下屏的节点,在需要生成新的组件时,对缓存里的组件进行复用。
64
65#### 滑动场景
66
67滑动前节点现状如下图所示
68
69![Repeat-Start](./figures/Repeat-Start.PNG)
70
71当前Repeat组件template type有a和b两种,template type等于a对应的缓存池,其最大缓存值为3,template type等于b对应的缓存池,其最大缓存值为4,其父组件默认预加载节点1个。这时,我们将屏幕向右滑动,Repeat将开始复用缓存池中的节点。
72
73![Repeat-Slide](./figures/Repeat-Slide.PNG)
74
75index=18的数据进入屏幕及父组件预加载的范围内,此时计算出其template type等于b,这时Repeat会从template type等于b的缓存池中取出一个节点进行复用,更新它的key&index&data,该子节点内部使用了该项数据及索引的其他孙子节点会根据V2状态管理的规则做同步更新。
76
77index=10的节点划出了屏幕及父组件预加载的范围。当UI主线程空闲时,会去检测template type等于a的缓存池是否还有空间,此时缓存池中有四个节点,超过了额定的3个,Repeat会释放掉最后一个节点。
78
79![Repeat-Slide-Done](./figures/Repeat-Slide-Done.PNG)
80
81#### 数据更新场景
82
83![Repeat-Start](./figures/Repeat-Start.PNG)
84
85此时我们做如下更新操作,删除index=12节点,更新index=13节点的数据,更新index=14节点的template type为a,更新index=15节点的key。
86
87![Repeat-Update1](./figures/Repeat-Update1.PNG)
88
89此时Repeat会通知父组件重新布局,逐一对比template type值,若和原节点template type值相同,则复用该节点,更新key、index和data,若template type值发生变化,则复用相应template type的缓存池中的节点,并更新key、index和data。
90
91![Repeat-Update2](./figures/Repeat-Update2.PNG)
92
93上图显示node13节点更新了数据data和index;node14更新了template type和index,于是从缓存池中取走一个复用;node15由于key值发生变化并且template type不变,复用自身节点并同步更新key、index、data;node16和node17均只更新index。index=17的节点是新的,从缓存池中复用。
94
95![Repeat-Update-Done](./figures/Repeat-Update-Done.PNG)
96
97## totalCount规则
98
99数据源的总长度,可以大于已加载数据项的数量。令arr.length表示数据源长度,以下为totalCount的处理规则:
100
101- totalCount缺省/非自然数时,totalCount默认为arr.length,列表正常滚动;
102- 0 <= totalCount < arr.length时,界面中只渲染“totalCount”个数据;
103- totalCount > arr.length时,代表Repeat将渲染totalCount个数据,滚动条样式根据totalCount值变化。
104
105> **注意:**
106>
107> 当totalCount < arr.length时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,开发者需要对数据请求的错误场景(如网络延迟)进行保护操作,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。
108
109## cachedCount规则
110
111cachedCount是当前模板在Repeat的缓存池中可缓存子节点的最大数量,仅在virtualScroll场景下生效。
112
113首先需要明确滚动类容器组件 `.cachedCount()`属性方法和Repeat `cachedCount`的区别。这两者都是为了平衡性能和内存,但是其含义是不同的。
114
115- 滚动类容器组件 `.cachedCount()`:是指在可见范围外预加载的节点,这些节点会位于组件树上,但不是可见范围内,List/Grid等容器组件会额外渲染这些可见范围外的节点,从而达到其性能收益。Repeat会将这些节点视为“可见”的。
116- Repeat `cachedCount`: 是指Repeat视为“不可见”的节点,这些节点是空闲的,框架会暂时保存,在需要使用的时候更新这些节点,从而实现复用。
117
118将cachedCount设置为当前模板的节点在屏上可能出现的最大数量时,Repeat可以做到尽可能多的复用。但后果是当屏上没有当前模板的节点时,缓存池也不会释放,应用内存会增大。需要开发者依据具体情况自行把控。
119
120- cachedCount缺省时,框架会分别对不同template,根据屏上节点+预加载的节点个数来计算cachedCount。当屏上节点+预加载的节点个数变多时,cachedCount也会对应增长。需要注意cachedCount数量不会减少。
121- 显式指定cachedCount,推荐设置成和屏幕上节点个数一致。需要注意,不推荐设置cachedCount小于2,因为这会导致在快速滑动场景下创建新的节点,从而导致性能劣化。
122
123## 使用场景
124
125### non-virtualScroll数据展示&操作
126
127#### 数据源变化
128
129```ts
130@Entry
131@ComponentV2
132struct Parent {
133  @Local simpleList: Array<string> = ['one', 'two', 'three'];
134
135  build() {
136    Row() {
137      Column() {
138        Text('点击修改第3个数组项的值')
139          .fontSize(24)
140          .fontColor(Color.Red)
141          .onClick(() => {
142            this.simpleList[2] = 'new three';
143          })
144
145        Repeat<string>(this.simpleList)
146            .each((obj: RepeatItem<string>)=>{
147              ChildItem({ item: obj.item })
148                .margin({top: 20})
149            })
150            .key((item: string) => item)
151      }
152      .justifyContent(FlexAlign.Center)
153      .width('100%')
154      .height('100%')
155    }
156    .height('100%')
157    .backgroundColor(0xF1F3F5)
158  }
159}
160
161@ComponentV2
162struct ChildItem {
163  @Param @Require item: string;
164
165  build() {
166    Text(this.item)
167      .fontSize(30)
168  }
169}
170```
171
172![ForEach-Non-Initial-Render-Case-Effect](./figures/ForEach-Non-Initial-Render-Case-Effect.gif)
173
174第三个数组项重新渲染时会复用之前的第三项的组件,仅对数据做了刷新。
175
176#### 索引值变化
177
178下方例子当交换数组项1和2时,若键值和上次保持一致,Repeat会复用之前的组件,仅对使用了index索引值的组件做数据刷新。
179
180```ts
181@Entry
182@ComponentV2
183struct Parent {
184  @Local simpleList: Array<string> = ['one', 'two', 'three'];
185
186  build() {
187    Row() {
188      Column() {
189        Text('交换数组项1,2')
190          .fontSize(24)
191          .fontColor(Color.Red)
192          .onClick(() => {
193            let temp: string = this.simpleList[2]
194            this.simpleList[2] = this.simpleList[1]
195            this.simpleList[1] = temp
196          })
197          .margin({bottom: 20})
198
199        Repeat<string>(this.simpleList)
200          .each((obj: RepeatItem<string>)=>{
201            Text("index: " + obj.index)
202              .fontSize(30)
203            ChildItem({ item: obj.item })
204              .margin({bottom: 20})
205          })
206          .key((item: string) => item)
207      }
208      .justifyContent(FlexAlign.Center)
209      .width('100%')
210      .height('100%')
211    }
212    .height('100%')
213    .backgroundColor(0xF1F3F5)
214  }
215}
216
217@ComponentV2
218struct ChildItem {
219  @Param @Require item: string;
220
221  build() {
222    Text(this.item)
223      .fontSize(30)
224  }
225}
226```
227
228![Repeat-Non-Initial-Render-Case-Exchange-Effect](./figures/Repeat-Non-Initial-Render-Case-Exchange-Effect.gif)
229
230### virtualScroll数据展示&操作
231
232本小节将展示virtualScroll场景下,Repeat的实际使用场景和组件节点的复用情况。根据复用规则可以衍生出大量的测试场景,篇幅原因,只对典型的数据变化进行解释。
233
234#### 一个template
235
236下面的代码设计了Repeat组件的virtualScroll场景典型数据源操作,包括**插入数据、修改数据、删除数据、交换数据**。点击下拉框选择index值,点击相应的按钮即可进行数据修改操作。依次点击数据项可以交换被点击的两个数据项。
237
238```ts
239@ObservedV2
240class Repeat005Clazz {
241  @Trace message: string = '';
242
243  constructor(message: string) {
244    this.message = message;
245  }
246}
247
248@Entry
249@ComponentV2
250struct RepeatVirtualScroll {
251  @Local simpleList: Array<Repeat005Clazz> = [];
252  private exchange: number[] = [];
253  private counter: number = 0;
254  @Local selectOptions: SelectOption[] = [];
255  @Local selectIdx: number = 0;
256
257  @Monitor("simpleList")
258  reloadSelectOptions(): void {
259    this.selectOptions = [];
260    for (let i = 0; i < this.simpleList.length; ++i) {
261      this.selectOptions.push({ value: i.toString() });
262    }
263    if (this.selectIdx >= this.simpleList.length) {
264      this.selectIdx = this.simpleList.length - 1;
265    }
266  }
267
268  aboutToAppear(): void {
269    for (let i = 0; i < 100; i++) {
270      this.simpleList.push(new Repeat005Clazz(`item_${i}`));
271    }
272    this.reloadSelectOptions();
273  }
274
275  handleExchange(idx: number): void { // 点击交换子组件
276    this.exchange.push(idx);
277    if (this.exchange.length === 2) {
278      let _a = this.exchange[0];
279      let _b = this.exchange[1];
280      let temp: Repeat005Clazz = this.simpleList[_a];
281      this.simpleList[_a] = this.simpleList[_b];
282      this.simpleList[_b] = temp;
283      this.exchange = [];
284    }
285  }
286
287  build() {
288    Column({ space: 10 }) {
289      Text('virtualScroll each()&template() 1t')
290        .fontSize(15)
291        .fontColor(Color.Gray)
292      Text('Select an index and press the button to update data.')
293        .fontSize(15)
294        .fontColor(Color.Gray)
295
296      Select(this.selectOptions)
297        .selected(this.selectIdx)
298        .value(this.selectIdx.toString())
299        .key('selectIdx')
300        .onSelect((index: number) => {
301          this.selectIdx = index;
302        })
303      Row({ space: 5 }) {
304        Button('Add No.' + this.selectIdx)
305          .onClick(() => {
306            this.simpleList.splice(this.selectIdx, 0, new Repeat005Clazz(`${this.counter++}_add_item`));
307            this.reloadSelectOptions();
308          })
309        Button('Modify No.' + this.selectIdx)
310          .onClick(() => {
311            this.simpleList.splice(this.selectIdx, 1, new Repeat005Clazz(`${this.counter++}_modify_item`));
312          })
313        Button('Del No.' + this.selectIdx)
314          .onClick(() => {
315            this.simpleList.splice(this.selectIdx, 1);
316            this.reloadSelectOptions();
317          })
318      }
319      Button('Update array length to 5.')
320        .onClick(() => {
321          this.simpleList = this.simpleList.slice(0, 5);
322          this.reloadSelectOptions();
323        })
324
325      Text('Click on two items to exchange.')
326        .fontSize(15)
327        .fontColor(Color.Gray)
328
329      List({ space: 10 }) {
330        Repeat<Repeat005Clazz>(this.simpleList)
331          .each((obj: RepeatItem<Repeat005Clazz>) => {
332            ListItem() {
333              Text(`[each] index${obj.index}: ${obj.item.message}`)
334                .fontSize(25)
335                .onClick(() => {
336                  this.handleExchange(obj.index);
337                })
338            }
339          })
340          .key((item: Repeat005Clazz, index: number) => {
341            return item.message;
342          })
343          .virtualScroll({ totalCount: this.simpleList.length })
344          .templateId(() => "a")
345          .template('a', (ri) => {
346            Text(`[a] index${ri.index}: ${ri.item.message}`)
347              .fontSize(25)
348              .onClick(() => {
349                this.handleExchange(ri.index);
350              })
351          }, { cachedCount: 3 })
352      }
353      .cachedCount(2)
354      .border({ width: 1 })
355      .width('95%')
356      .height('40%')
357    }
358    .justifyContent(FlexAlign.Center)
359    .width('100%')
360    .height('100%')
361  }
362}
363```
364该应用列表内容为100项自定义类`RepeatClazz`的`message`字符串属性,List组件的cachedCount设为2,模板'a'的缓存池大小设为3。应用界面如下图所示:
365
366![Repeat-VirtualScroll-Demo](./figures/Repeat-VirtualScroll-Demo.gif)
367
368#### 多个template
369
370```
371@ObservedV2
372class Repeat006Clazz {
373  @Trace message: string = '';
374
375  constructor(message: string) {
376    this.message = message;
377  }
378}
379
380@Entry
381@ComponentV2
382struct RepeatVirtualScroll2T {
383  @Local simpleList: Array<Repeat006Clazz> = [];
384  private exchange: number[] = [];
385  private counter: number = 0;
386  @Local selectOptions: SelectOption[] = [];
387  @Local selectIdx: number = 0;
388
389  @Monitor("simpleList")
390  reloadSelectOptions(): void {
391    this.selectOptions = [];
392    for (let i = 0; i < this.simpleList.length; ++i) {
393      this.selectOptions.push({ value: i.toString() });
394    }
395    if (this.selectIdx >= this.simpleList.length) {
396      this.selectIdx = this.simpleList.length - 1;
397    }
398  }
399
400  aboutToAppear(): void {
401    for (let i = 0; i < 100; i++) {
402      this.simpleList.push(new Repeat006Clazz(`item_${i}`));
403    }
404    this.reloadSelectOptions();
405  }
406
407  handleExchange(idx: number): void { // 点击交换子组件
408    this.exchange.push(idx);
409    if (this.exchange.length === 2) {
410      let _a = this.exchange[0];
411      let _b = this.exchange[1];
412      let temp: Repeat006Clazz = this.simpleList[_a];
413      this.simpleList[_a] = this.simpleList[_b];
414      this.simpleList[_b] = temp;
415      this.exchange = [];
416    }
417  }
418
419  build() {
420    Column({ space: 10 }) {
421      Text('virtualScroll each()&template() 2t')
422        .fontSize(15)
423        .fontColor(Color.Gray)
424      Text('Select an index and press the button to update data.')
425        .fontSize(15)
426        .fontColor(Color.Gray)
427
428      Select(this.selectOptions)
429        .selected(this.selectIdx)
430        .value(this.selectIdx.toString())
431        .key('selectIdx')
432        .onSelect((index: number) => {
433          this.selectIdx = index;
434        })
435      Row({ space: 5 }) {
436        Button('Add No.' + this.selectIdx)
437          .onClick(() => {
438            this.simpleList.splice(this.selectIdx, 0, new Repeat006Clazz(`${this.counter++}_add_item`));
439            this.reloadSelectOptions();
440          })
441        Button('Modify No.' + this.selectIdx)
442          .onClick(() => {
443            this.simpleList.splice(this.selectIdx, 1, new Repeat006Clazz(`${this.counter++}_modify_item`));
444          })
445        Button('Del No.' + this.selectIdx)
446          .onClick(() => {
447            this.simpleList.splice(this.selectIdx, 1);
448            this.reloadSelectOptions();
449          })
450      }
451      Button('Update array length to 5.')
452        .onClick(() => {
453          this.simpleList = this.simpleList.slice(0, 5);
454          this.reloadSelectOptions();
455        })
456
457      Text('Click on two items to exchange.')
458        .fontSize(15)
459        .fontColor(Color.Gray)
460
461      List({ space: 10 }) {
462        Repeat<Repeat006Clazz>(this.simpleList)
463          .each((obj: RepeatItem<Repeat006Clazz>) => {
464            ListItem() {
465              Text(`[each] index${obj.index}: ${obj.item.message}`)
466                .fontSize(25)
467                .onClick(() => {
468                  this.handleExchange(obj.index);
469                })
470            }
471          })
472          .key((item: Repeat006Clazz, index: number) => {
473            return item.message;
474          })
475          .virtualScroll({ totalCount: this.simpleList.length })
476          .templateId((item: Repeat006Clazz, index: number) => {
477            return (index % 2 === 0) ? 'odd' : 'even';
478          })
479          .template('odd', (ri) => {
480            Text(`[odd] index${ri.index}: ${ri.item.message}`)
481              .fontSize(25)
482              .fontColor(Color.Blue)
483              .onClick(() => {
484                this.handleExchange(ri.index);
485              })
486          }, { cachedCount: 3 })
487          .template('even', (ri) => {
488            Text(`[even] index${ri.index}: ${ri.item.message}`)
489              .fontSize(25)
490              .fontColor(Color.Green)
491              .onClick(() => {
492                this.handleExchange(ri.index);
493              })
494          }, { cachedCount: 1 })
495      }
496      .cachedCount(2)
497      .border({ width: 1 })
498      .width('95%')
499      .height('40%')
500    }
501    .justifyContent(FlexAlign.Center)
502    .width('100%')
503    .height('100%')
504  }
505}
506```
507
508![Repeat-VirtualScroll-2T-Demo](./figures/Repeat-VirtualScroll-2T-Demo.gif)
509
510### Repeat嵌套
511
512Repeat支持嵌套使用。示例代码:
513
514```ts
515// Repeat嵌套
516@Entry
517@ComponentV2
518struct RepeatNest {
519  @Local outerList: string[] = [];
520  @Local innerList: number[] = [];
521
522  aboutToAppear(): void {
523    for (let i = 0; i < 20; i++) {
524      this.outerList.push(i.toString());
525      this.innerList.push(i);
526    }
527  }
528
529  build() {
530    Column({ space: 20 }) {
531      Text('Repeat virtualScroll嵌套')
532        .fontSize(15)
533        .fontColor(Color.Gray)
534      List() {
535        Repeat<string>(this.outerList)
536          .each((obj) => {
537            ListItem() {
538              Column() {
539                Text('outerList item: ' + obj.item)
540                  .fontSize(30)
541                List() {
542                  Repeat<number>(this.innerList)
543                    .each((subObj) => {
544                      ListItem() {
545                        Text('innerList item: ' + subObj.item)
546                          .fontSize(20)
547                      }
548                    })
549                    .key((item) => "innerList_" + item)
550                }
551                .width('80%')
552                .border({ width: 1 })
553                .backgroundColor(Color.Orange)
554              }
555              .height('30%')
556              .backgroundColor(Color.Pink)
557            }
558            .border({ width: 1 })
559          })
560          .key((item) => "outerList_" + item)
561      }
562      .width('80%')
563      .border({ width: 1 })
564    }
565    .justifyContent(FlexAlign.Center)
566    .width('90%')
567    .height('80%')
568  }
569}
570```
571
572运行效果:
573
574![Repeat-Nest](./figures/Repeat-Nest.png)
575
576## 父容器组件应用场景
577
578### 与List组合使用
579
580在List容器组件中使用Repeat的virtualScroll模式,示例如下:
581
582```ts
583class DemoListItemInfo {
584  name: string;
585  icon: Resource;
586
587  constructor(name: string, icon: Resource) {
588    this.name = name;
589    this.icon = icon;
590  }
591}
592
593@Entry
594@ComponentV2
595struct DemoList {
596  @Local videoList: Array<DemoListItemInfo> = [];
597
598  aboutToAppear(): void {
599    for (let i = 0; i < 10; i++) {
600      // 此处app.media.listItem0app.media.listItem1app.media.listItem2仅作示例,请开发者自行替换
601      this.videoList.push(new DemoListItemInfo('视频' + i,
602        i % 3 == 0 ? $r("app.media.listItem0") :
603        i % 3 == 1 ? $r("app.media.listItem1") : $r("app.media.listItem2")));
604    }
605  }
606
607  @Builder
608  itemEnd(index: number) {
609    Button('删除')
610      .backgroundColor(Color.Red)
611      .onClick(() => {
612        this.videoList.splice(index, 1);
613      })
614  }
615
616  build() {
617    Column({ space: 10 }) {
618      Text('List容器组件中包含Repeat组件')
619        .fontSize(15)
620        .fontColor(Color.Gray)
621
622      List({ space: 5 }) {
623        Repeat<DemoListItemInfo>(this.videoList)
624          .each((obj: RepeatItem<DemoListItemInfo>) => {
625            ListItem() {
626              Column() {
627                Image(obj.item.icon)
628                  .width('80%')
629                  .margin(10)
630                Text(obj.item.name)
631                  .fontSize(20)
632              }
633            }
634            .swipeAction({
635              end: {
636                builder: () => {
637                  this.itemEnd(obj.index);
638                }
639              }
640            })
641            .onAppear(() => {
642              console.info('AceTag', obj.item.name);
643            })
644          })
645          .key((item: DemoListItemInfo) => item.name)
646          .virtualScroll()
647      }
648      .cachedCount(2)
649      .height('90%')
650      .border({ width: 1 })
651      .listDirection(Axis.Vertical)
652      .alignListItem(ListItemAlign.Center)
653      .divider({
654        strokeWidth: 1,
655        startMargin: 60,
656        endMargin: 60,
657        color: '#ffe9f0f0'
658      })
659
660      Row({ space: 10 }) {
661        Button('删除第1项')
662          .onClick(() => {
663            this.videoList.splice(0, 1);
664          })
665        Button('删除第5项')
666          .onClick(() => {
667            this.videoList.splice(4, 1);
668          })
669      }
670    }
671    .width('100%')
672    .height('100%')
673    .justifyContent(FlexAlign.Center)
674  }
675}
676```
677右滑并点击按钮,或点击底部按钮,可删除视频卡片:
678
679![Repeat-Demo-List](./figures/Repeat-Demo-List.gif)
680
681### 与Grid组合使用
682
683在Grid容器组件中使用Repeat的virtualScroll模式,示例如下:
684
685```ts
686class DemoGridItemInfo {
687  name: string;
688  icon: Resource;
689
690  constructor(name: string, icon: Resource) {
691    this.name = name;
692    this.icon = icon;
693  }
694}
695
696@Entry
697@ComponentV2
698struct DemoGrid {
699  @Local itemList: Array<DemoGridItemInfo> = [];
700  @Local isRefreshing: boolean = false;
701  private layoutOptions: GridLayoutOptions = {
702    regularSize: [1, 1],
703    irregularIndexes: [10]
704  }
705  private GridScroller: Scroller = new Scroller();
706  private num: number = 0;
707
708  aboutToAppear(): void {
709    for (let i = 0; i < 10; i++) {
710      // 此处app.media.gridItem0app.media.gridItem1app.media.gridItem2仅作示例,请开发者自行替换
711      this.itemList.push(new DemoGridItemInfo('视频' + i,
712        i % 3 == 0 ? $r("app.media.gridItem0") :
713        i % 3 == 1 ? $r("app.media.gridItem1") : $r("app.media.gridItem2")));
714    }
715  }
716
717  build() {
718    Column({ space: 10 }) {
719      Text('Grid容器组件中包含Repeat组件')
720        .fontSize(15)
721        .fontColor(Color.Gray)
722
723      Refresh({ refreshing: $$this.isRefreshing }) {
724        Grid(this.GridScroller, this.layoutOptions) {
725          Repeat<DemoGridItemInfo>(this.itemList)
726            .each((obj: RepeatItem<DemoGridItemInfo>) => {
727              if (obj.index === 10 ) {
728                GridItem() {
729                  Text('先前浏览至此,点击刷新')
730                    .fontSize(20)
731                }
732                .height(30)
733                .border({ width: 1 })
734                .onClick(() => {
735                  this.GridScroller.scrollToIndex(0);
736                  this.isRefreshing = true;
737                })
738                .onAppear(() => {
739                  console.info('AceTag', obj.item.name);
740                })
741              } else {
742                GridItem() {
743                  Column() {
744                    Image(obj.item.icon)
745                      .width('100%')
746                      .height(80)
747                      .objectFit(ImageFit.Cover)
748                      .borderRadius({ topLeft: 16, topRight: 16 })
749                    Text(obj.item.name)
750                      .fontSize(15)
751                      .height(20)
752                  }
753                }
754                .height(100)
755                .borderRadius(16)
756                .backgroundColor(Color.White)
757                .onAppear(() => {
758                  console.info('AceTag', obj.item.name);
759                })
760              }
761            })
762            .key((item: DemoGridItemInfo) => item.name)
763            .virtualScroll()
764        }
765        .columnsTemplate('repeat(auto-fit, 150)')
766        .cachedCount(4)
767        .rowsGap(15)
768        .columnsGap(10)
769        .height('100%')
770        .padding(10)
771        .backgroundColor('#F1F3F5')
772      }
773      .onRefreshing(() => {
774        setTimeout(() => {
775          this.itemList.splice(10, 1);
776          this.itemList.unshift(new DemoGridItemInfo('refresh', $r('app.media.gridItem0'))); // 此处app.media.gridItem0仅作示例,请开发者自行替换
777          for (let i = 0; i < 10; i++) {
778            // 此处aapp.media.gridItem0app.media.gridItem1app.media.gridItem2仅作示例,请开发者自行替换
779            this.itemList.unshift(new DemoGridItemInfo('新视频' + this.num,
780              i % 3 == 0 ? $r("app.media.gridItem0") :
781              i % 3 == 1 ? $r("app.media.gridItem1") : $r("app.media.gridItem2")));
782            this.num++;
783          }
784          this.isRefreshing = false;
785        }, 1000);
786        console.info('AceTag', 'onRefreshing');
787      })
788      .refreshOffset(64)
789      .pullToRefresh(true)
790      .width('100%')
791      .height('85%')
792
793      Button('刷新')
794        .onClick(() => {
795          this.GridScroller.scrollToIndex(0);
796          this.isRefreshing = true;
797        })
798    }
799    .width('100%')
800    .height('100%')
801    .justifyContent(FlexAlign.Center)
802  }
803}
804```
805下拉屏幕,或点击刷新按钮,或点击“先前浏览至此,点击刷新”,可加载新的视频内容:
806
807![Repeat-Demo-Grid](./figures/Repeat-Demo-Grid.gif)
808
809### 与Swiper组合使用
810
811在Swiper容器组件中使用Repeat的virtualScroll模式,示例如下:
812
813```ts
814const remotePictures: Array<string> = [
815  'https://www.example.com/xxx/0001.jpg', // 请填写具体的网络图片地址
816  'https://www.example.com/xxx/0002.jpg',
817  'https://www.example.com/xxx/0003.jpg',
818  'https://www.example.com/xxx/0004.jpg',
819  'https://www.example.com/xxx/0005.jpg',
820  'https://www.example.com/xxx/0006.jpg',
821  'https://www.example.com/xxx/0007.jpg',
822  'https://www.example.com/xxx/0008.jpg',
823  'https://www.example.com/xxx/0009.jpg',
824]
825
826@ObservedV2
827class DemoSwiperItemInfo {
828  id: string;
829  @Trace url: string = 'default';
830
831  constructor(id: string) {
832    this.id = id;
833  }
834}
835
836@Entry
837@ComponentV2
838struct DemoSwiper {
839  @Local pics: Array<DemoSwiperItemInfo> = [];
840
841  aboutToAppear(): void {
842    for (let i = 0; i < 9; i++) {
843      this.pics.push(new DemoSwiperItemInfo('pic' + i));
844    }
845    setTimeout(() => {
846      this.pics[0].url = remotePictures[0];
847    }, 1000);
848  }
849
850  build() {
851    Column() {
852      Text('Swiper容器组件中包含Repeat组件')
853        .fontSize(15)
854        .fontColor(Color.Gray)
855
856      Stack() {
857        Text('图片加载中')
858          .fontSize(15)
859          .fontColor(Color.Gray)
860        Swiper() {
861          Repeat(this.pics)
862            .each((obj: RepeatItem<DemoSwiperItemInfo>) => {
863              Image(obj.item.url)
864                .onAppear(() => {
865                  console.info('AceTag', obj.item.id);
866                })
867            })
868            .key((item: DemoSwiperItemInfo) => item.id)
869            .virtualScroll()
870        }
871        .cachedCount(9)
872        .height('50%')
873        .loop(false)
874        .indicator(true)
875        .onChange((index) => {
876          setTimeout(() => {
877            this.pics[index].url = remotePictures[index];
878          }, 1000);
879        })
880      }
881      .width('100%')
882      .height('100%')
883      .backgroundColor(Color.Black)
884    }
885  }
886}
887```
888定时1秒后加载图片,模拟网络延迟:
889
890![Repeat-Demo-Swiper](./figures/Repeat-Demo-Swiper.gif)
891
892## 常见问题
893
894### 屏幕外的列表数据发生变化时,保证滚动条位置不变
895
896在List组件中声明Repeat组件,实现key值生成逻辑和each逻辑(如下示例代码),点击按钮“insert”,在屏幕显示的第一个元素前面插入一个元素,屏幕出现向下滚动。
897
898```ts
899// 定义一个类,标记为可观察的
900// 类中自定义一个数组,标记为可追踪的
901@ObservedV2
902class ArrayHolder {
903  @Trace arr: Array<number> = [];
904
905  // constructor,用于初始化数组个数
906  constructor(count: number) {
907    for (let i = 0; i < count; i++) {
908      this.arr.push(i);
909    }
910  }
911}
912
913@Entry
914@ComponentV2
915export struct RepeatTemplateSingle {
916  @Local arrayHolder: ArrayHolder = new ArrayHolder(100);
917  @Local totalCount: number = this.arrayHolder.arr.length;
918  scroller: Scroller = new Scroller();
919
920  build() {
921    Column({ space: 5 }) {
922      List({ space: 20, initialIndex: 19, scroller: this.scroller }) {
923        Repeat(this.arrayHolder.arr)
924          .virtualScroll({ totalCount: this.totalCount })
925          .templateId((item, index) => {
926            return 'number';
927          })
928          .template('number', (r) => {
929            ListItem() {
930              Text(r.index! + ":" + r.item + "Reuse");
931            }
932          })
933          .each((r) => {
934            ListItem() {
935              Text(r.index! + ":" + r.item + "eachMessage");
936            }
937          })
938      }
939      .height('30%')
940
941      Button(`insert totalCount ${this.totalCount}`)
942        .height(60)
943        .onClick(() => {
944          // 插入元素,元素位置为屏幕显示的前一个元素
945          this.arrayHolder.arr.splice(18, 0, this.totalCount);
946          this.totalCount = this.arrayHolder.arr.length;
947        })
948    }
949    .width('100%')
950    .margin({ top: 5 })
951  }
952}
953```
954
955运行效果:
956
957![Repeat-case1-Error](./figures/Repeat-Case1-Error.gif)
958
959在一些场景中,我们不希望屏幕外的数据源变化影响屏幕中List列表Scroller停留的位置,可以通过List组件的[onScrollIndex](../ui/arkts-layout-development-create-list.md#响应滚动位置)事件对列表滚动动作进行监听,当列表发生滚动时,获取列表滚动位置。使用Scroller组件的[scrollToIndex](../reference/apis-arkui/arkui-ts/ts-container-scroll.md#scrolltoindex)特性,滑动到指定index位置,实现屏幕外的数据源增加/删除数据时,Scroller停留的位置不变的效果。
960
961示例代码仅对增加数据的情况进行展示。
962
963```ts
964// ...ArrayHolder的定义和上述demo代码一致
965
966@Entry
967@ComponentV2
968export struct RepeatTemplateSingle {
969  @Local arrayHolder: ArrayHolder = new ArrayHolder(100);
970  @Local totalCount: number = this.arrayHolder.arr.length;
971  scroller: Scroller = new Scroller();
972
973  private start: number = 1;
974  private end: number = 1;
975
976  build() {
977    Column({ space: 5 }) {
978      List({ space: 20, initialIndex: 19, scroller: this.scroller }) {
979        Repeat(this.arrayHolder.arr)
980          .virtualScroll({ totalCount: this.totalCount })
981          .templateId((item, index) => {
982            return 'number';
983          })
984          .template('number', (r) => {
985            ListItem() {
986              Text(r.index! + ":" + r.item + "Reuse");
987            }
988          })
989          .each((r) => {
990            ListItem() {
991              Text(r.index! + ":" + r.item + "eachMessage");
992            }
993          })
994      }
995      .onScrollIndex((start, end) => {
996        this.start = start;
997        this.end = end;
998      })
999      .height('30%')
1000
1001      Button(`insert totalCount ${this.totalCount}`)
1002        .height(60)
1003        .onClick(() => {
1004          // 插入元素,元素位置为屏幕显示的前一个元素
1005          this.arrayHolder.arr.splice(18, 0, this.totalCount);
1006          let rect = this.scroller.getItemRect(this.start); // 获取子组件的大小位置
1007          this.scroller.scrollToIndex(this.start + 1); // 滑动到指定index
1008          this.scroller.scrollBy(0, -rect.y); // 滑动指定距离
1009          this.totalCount = this.arrayHolder.arr.length;
1010        })
1011    }
1012    .width('100%')
1013    .margin({ top: 5 })
1014  }
1015}
1016```
1017
1018运行效果:
1019
1020![Repeat-case1-Succ](./figures/Repeat-Case1-Succ.gif)
1021
1022### totalCount值大于数据源长度
1023
1024当数据源总长度很大时,会使用懒加载的方式先加载一部分数据,为了使Repeat显示正确的滚动条样式,需要将数据总长度赋值给totalCount,即数据源全部加载完成前,totalCount大于array.length1025
1026totalCount > array.length时,在父组件容器滚动过程中,应用需要保证列表即将滑动到数据源末尾时请求后续数据,开发者需要对数据请求的错误场景(如网络延迟)进行保护操作,直到数据源全部加载完成,否则列表滑动的过程中会出现滚动效果异常。
1027
1028上述规范可以通过实现父组件List/Grid的[onScrollIndex](../ui/arkts-layout-development-create-list.md#响应滚动位置)属性的回调函数完成。示例代码如下:
1029
1030```ts
1031@ObservedV2
1032class VehicleData {
1033  @Trace name: string;
1034  @Trace price: number;
1035
1036  constructor(name: string, price: number) {
1037    this.name = name;
1038    this.price = price;
1039  }
1040}
1041
1042@ObservedV2
1043class VehicleDB {
1044  public vehicleItems: VehicleData[] = [];
1045
1046  constructor() {
1047    // init data size 20
1048    for (let i = 1; i <= 20; i++) {
1049      this.vehicleItems.push(new VehicleData(`Vehicle${i}`, i));
1050    }
1051  }
1052}
1053
1054@Entry
1055@ComponentV2
1056struct entryCompSucc {
1057  @Local vehicleItems: VehicleData[] = new VehicleDB().vehicleItems;
1058  @Local listChildrenSize: ChildrenMainSize = new ChildrenMainSize(60);
1059  @Local totalCount: number = this.vehicleItems.length;
1060  scroller: Scroller = new Scroller();
1061
1062  build() {
1063    Column({ space: 3 }) {
1064      List({ scroller: this.scroller }) {
1065        Repeat(this.vehicleItems)
1066          .virtualScroll({ totalCount: 50 }) // total data size 50
1067          .templateId(() => 'default')
1068          .template('default', (ri) => {
1069            ListItem() {
1070              Column() {
1071                Text(`${ri.item.name} + ${ri.index}`)
1072                  .width('90%')
1073                  .height(this.listChildrenSize.childDefaultSize)
1074                  .backgroundColor(0xFFA07A)
1075                  .textAlign(TextAlign.Center)
1076                  .fontSize(20)
1077                  .fontWeight(FontWeight.Bold)
1078              }
1079            }.border({ width: 1 })
1080          }, { cachedCount: 5 })
1081          .each((ri) => {
1082            ListItem() {
1083              Text("Wrong: " + `${ri.item.name} + ${ri.index}`)
1084                .width('90%')
1085                .height(this.listChildrenSize.childDefaultSize)
1086                .backgroundColor(0xFFA07A)
1087                .textAlign(TextAlign.Center)
1088                .fontSize(20)
1089                .fontWeight(FontWeight.Bold)
1090            }.border({ width: 1 })
1091          })
1092          .key((item, index) => `${index}:${item}`)
1093      }
1094      .height('50%')
1095      .margin({ top: 20 })
1096      .childrenMainSize(this.listChildrenSize)
1097      .alignListItem(ListItemAlign.Center)
1098      .onScrollIndex((start, end) => {
1099        console.log('onScrollIndex', start, end);
1100        // lazy data loading
1101        if (this.vehicleItems.length < 50) {
1102          for (let i = 0; i < 10; i++) {
1103            if (this.vehicleItems.length < 50) {
1104              this.vehicleItems.push(new VehicleData("Vehicle_loaded", i));
1105            }
1106          }
1107        }
1108      })
1109    }
1110  }
1111}
1112```
1113
1114示例代码运行效果:
1115
1116![Repeat-Case2-Succ](./figures/Repeat-Case2-Succ.gif)
1117
1118### Repeat与@Builder混用的限制
1119
1120当Repeat与@Builder混用时,必须将RepeatItem类型整体进行传参,组件才能监听到数据变化,如果只传递`RepeatItem.item`或`RepeatItem.index`,将会出现UI渲染异常。
1121
1122示例代码如下:
1123
1124```ts
1125@Entry
1126@ComponentV2
1127struct RepeatBuilderPage {
1128  @Local simpleList1: Array<number> = [];
1129  @Local simpleList2: Array<number> = [];
1130
1131  aboutToAppear(): void {
1132    for (let i = 0; i < 100; i++) {
1133      this.simpleList1.push(i)
1134      this.simpleList2.push(i)
1135    }
1136  }
1137
1138  build() {
1139    Column({ space: 20 }) {
1140      Text('Repeat与@Builder混用,左边是异常场景,右边是正常场景,向下滑动一段距离可以看出差别')
1141        .fontSize(15)
1142        .fontColor(Color.Gray)
1143
1144      Row({ space: 20 }) {
1145        List({ initialIndex: 5, space: 20 }) {
1146          Repeat<number>(this.simpleList1)
1147            .each((ri) => {})
1148            .virtualScroll({ totalCount: this.simpleList1.length })
1149            .templateId((item: number, index: number) => "default")
1150            .template('default', (ri) => {
1151              ListItem() {
1152                Column() {
1153                  Text('Text id = ' + ri.item)
1154                    .fontSize(20)
1155                  this.buildItem1(ri.item) // 修改为:this.buildItem1(ri)
1156                }
1157              }
1158              .border({ width: 1 })
1159            }, { cachedCount: 3 })
1160        }
1161        .cachedCount(1)
1162        .border({ width: 1 })
1163        .width('45%')
1164        .height('60%')
1165
1166        List({ initialIndex: 5, space: 20 }) {
1167          Repeat<number>(this.simpleList2)
1168            .each((ri) => {})
1169            .virtualScroll({ totalCount: this.simpleList2.length })
1170            .templateId((item: number, index: number) => "default")
1171            .template('default', (ri) => {
1172              ListItem() {
1173                Column() {
1174                  Text('Text id = ' + ri.item)
1175                    .fontSize(20)
1176                  this.buildItem2(ri)
1177                }
1178              }
1179              .border({ width: 1 })
1180            }, { cachedCount: 3 })
1181        }
1182        .cachedCount(1)
1183        .border({ width: 1 })
1184        .width('45%')
1185        .height('60%')
1186      }
1187    }
1188    .height('100%')
1189    .justifyContent(FlexAlign.Center)
1190  }
1191
1192  @Builder
1193  // @Builder参数必须传RepeatItem类型才能正常渲染
1194  buildItem1(item: number) {
1195    Text('Builder1 id = ' + item)
1196      .fontSize(20)
1197      .fontColor(Color.Red)
1198      .margin({ top: 2 })
1199  }
1200
1201  @Builder
1202  buildItem2(ri: RepeatItem<number>) {
1203    Text('Builder2 id = ' + ri.item)
1204      .fontSize(20)
1205      .fontColor(Color.Red)
1206      .margin({ top: 2 })
1207  }
1208}
1209```
1210
1211界面展示如下图,进入页面后向下滑动一段距离可以看出差别,左边是错误用法,右边是正确用法(Text组件为黑色,Builder组件为红色)。上述代码展示了开发过程中易出错的场景,即在@Builder构造函数中传参方式为值传递。
1212
1213![Repeat-Builder](./figures/Repeat-Builder.png)