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 40 41### virtualScroll规则 42 43和non-virtualScroll的键值生成规则基本一致,`key()`可以缺省。 44 45 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 60 61### virtualScroll规则 62 63子组件在Repeat首次渲染只生成当前需要的组件,在滑动和数据更新时会缓存下屏的节点,在需要生成新的组件时,对缓存里的组件进行复用。 64 65#### 滑动场景 66 67滑动前节点现状如下图所示 68 69 70 71当前Repeat组件template type有a和b两种,template type等于a对应的缓存池,其最大缓存值为3,template type等于b对应的缓存池,其最大缓存值为4,其父组件默认预加载节点1个。这时,我们将屏幕向右滑动,Repeat将开始复用缓存池中的节点。 72 73 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 80 81#### 数据更新场景 82 83 84 85此时我们做如下更新操作,删除index=12节点,更新index=13节点的数据,更新index=14节点的template type为a,更新index=15节点的key。 86 87 88 89此时Repeat会通知父组件重新布局,逐一对比template type值,若和原节点template type值相同,则复用该节点,更新key、index和data,若template type值发生变化,则复用相应template type的缓存池中的节点,并更新key、index和data。 90 91 92 93上图显示node13节点更新了数据data和index;node14更新了template type和index,于是从缓存池中取走一个复用;node15由于key值发生变化并且template type不变,复用自身节点并同步更新key、index、data;node16和node17均只更新index。index=17的节点是新的,从缓存池中复用。 94 95 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 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 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 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 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 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.listItem0、app.media.listItem1、app.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 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.gridItem0、app.media.gridItem1、app.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.gridItem0、app.media.gridItem1、app.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 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 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 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 1021 1022### totalCount值大于数据源长度 1023 1024当数据源总长度很大时,会使用懒加载的方式先加载一部分数据,为了使Repeat显示正确的滚动条样式,需要将数据总长度赋值给totalCount,即数据源全部加载完成前,totalCount大于array.length。 1025 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 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