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