1/* 2 * Copyright (c) 2025 Huawei Device Co., Ltd. 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 * 15 * all definitions in this file are framework internal 16*/ 17 18/** 19 * Repeat with .virtualScroll TS side implementation 20 * for C++ side part of the implementation read the comments at top of file 21 * core/components_ng/syntax/repeat_virtual_scroll_node.h 22 * 23 * See the factory solutions in pu_repeat.ts 24 * __RepeatVirtualScroll2Impl gets created before first render 25 * 26 * onGetRid4Index(index) called upon layout calling C++ GetFrameChildByIndex 27 * (whenever C++ does not find UINode sub-tree for index in L1) 28 * 1- gets the data item - item might be lazy loaded (documented below) 29 * 2- calculates the templateId (called ttype internally) for this index, then 30 * 3- canUpdateTryMatch(index, ttype) tries to find spare UINode tree (identified by its RID) 31 * 4- if found calls updateChild(rid, item, index, ....) 32 * updateChild simply updates repeatItem.item, .index, and requests UI updates synchronously (updateDirty2(true) 33 * TS informs to C++ the RID , and that existing UINode sub-tree has been updated 34 * 5- if not fond calls createNewChild(index, ttype, ...) 35 * creates a new RepeatItem instance with a new RID 36 * calls the .each or .template item builder function to perform initial render 37 * TS informs C++ about the RID and that new UINode sub-tree root node should be taken from ViewStackProcessor 38 * added to C++ side RID -> UINode map / general cache 39 * 40 * onRecycleItems(fromIndex, upToBeforeIndex) - called from C++ RecycleItems 41 * - move L1 items with fromIndex <= index < upToBeforeIndex to L2. 42 * calls C++ RepeatVirtualScroll2Native.setInvalid so also C++ side moves the RID from L1 to L2 43 * C++ does NOT remove the item from active tree, or change its Active status 44 * if so needed at the end of one processing cycle onActiveRange will do 45 * 46 * onActiveRange(....) - called from C++ 47 * - TS iterates over L1 items to check which which one is still in informed range, move to L2 48 * does NOT call RepeatVirtualScroll2Caches::SetInvalid 49 * - C++ does the same 50 * a) iterate over L1 to remove items no longer in active range, move to L2 51 * b) iterate over L2, remove from render tree and set Active status 52 * c) order purge idle task 53 * d) calculate dynamicCachedCount for .each and for templateIds that do not have specified cachedCount option 54 * 55 * - Note: the rather convolute algorithm that uses parameters to decide if item is in active range or not 56 * needs to be exactly same in this function on TS side and on C++ side to ensure L1 info in TS and in C++ side 57 * reman in sync. 58 * 59 * onMoveFromTo(moveFrom, moveTo) - called from C++, used for drag drop sorting 60 * - TS received moveFromTo from C++ and updated its own moveFromTo. Mainly used to do the following two things: 61 * a) update its own moveFromTo. 62 * b) reset its own moveFromTo. 63 * 64 * How does the drag-drop-sorting work? 65 * 1)users long press the ListItem and drag starts 66 * 2)whenever the index of the dragged item changes, the translation table(this.moveFromTo_) takes care of fixing 67 * the order. While drag is on-going we do not expect the app to make any changes to the array. If it does, 68 * the visual result might not be what is expected 69 * 3)user drops item 70 * 4)FireOnMove resets the translation table 71 * 5)onMove callback to the application, application changes the array 72 * 6)frameWork observes array change, triggers rerender 73 * 7)Repeat rerender 74 * 75 * why TS needs maintain moveFromTo_? 76 * - Mainly used to serve step_2 above 77 * - For a more specific description, please refer to the description above function "convertFromToIndex" 78 * 79 * when onMoveFromTo will be called from C++? 80 * - When users drag item and move item without dropping, each order change will trigger MoveData(function in C++). 81 * For example, [a, b, c, d] shows in List, user drags item 'a' and move it after to 'c' without dropping. 82 * During this period, MoveData(0, 1) and MoveData(1, 2) will be called separately, and this.onMoveFromTo_ will 83 * be updated to [0, 1] and [0, 2] in sequence 84 * 85 * 86 * onPurge called from C++ RepeatVirtualScrollNode::Purge in idle task 87 * ensure L1 side is within limits (cachedCount), deletes UINode subtrees that do not fit the permissible side 88 * cachedCount is defined for each templateId / ttype for for .each separately 89 * 90 * 91 * rerender called from ViewV2 UpdateElement(repeatElmtId) 92 * 93 * what triggers Repeat rerender? 94 * - new source array assignment; array add, delete, move item 95 * - input to templateId function changed 96 * - input to key function changed (if key used) 97 * - totalCount changed 98 * 99 * cached array item at index and new array item at index2 are considered the same if 100 * - the keys computed from them are the same (if key used) 101 * - if items compare equal with === operator 102 * the algorithm that compares items can handle duplicate array items. 103 * the algorithm will fail unnoticed if cached array item and new array item are different but their key is the same 104 * (this violates the requirement of stable keys) 105 * 106 * read the source code of rerender, each step has documentation 107 * 108 * the outcome of rerender is 109 * 1- array items in cached and new array (active range), aka retained array items, repeatItem.index updated if changed 110 * 2- delete array items -> RID / UINode sub-tree moved to L2 111 * 3- added array items - try to find fitting RID / UINode subtree (same templateId / ttype) and 112 * update by updating repeatItem.item and .index newly added array items that can not be rendered 113 * by update are NOT rendered, will deal with new renders when GetFrameChildByIndex requests 114 * 4- synchronous partial update to update all bindings that need update from step 1 and 3. 115 * 5- inform new L1 (and thereby also L2) to C++ by calling updateL1Rid4Index(list of rid), same call also 116 * invalidates container layout starting from first changes item index, updates also totalCount on C++ side 117 * 118 * 119 * Lazy loading feature, available for API 18 -> 120 * 121 * 1- getItemUnmonitored() executes onLazyLoadingFunc_ function if the function is defined and requested item is not in 122 * the source array. Member variable lazyLoadingIndex_ is used to store the requested index. 123 * 2- ArrayProxyHandler catches the operation (this.arr_[index] = newItem) and calls tryFastRelayout. 124 * 3- tryFastRelayout calls updateTotalCount(), enqueues a requestContainerReLayout call to the container (that lets the 125 * container know there is a new item and the new total count) and returns true, meaning Repeat should be excluded 126 * from following fireChange. 127 * 4- onLazyLoadingFunc_ function returns, lazyLoadingIndex_ is set to -1 128 * 5- getItemUnmonitored() returns the lazy loaded item 129 * 130 * Lazy loading can happen in these situations: 131 * 1- onGetRid4Index(index) is called the first time for this index 132 * 2- rerender step 1 checks if item in this.arr_[index] (from the old active range) has changed, 133 * but there is no item at this.arr_[index] 134 * (this can happen e.g. when the application calls this.arr_ = []) 135 * 136 * Why lazy loading does not need rerender? 137 * In case 2 abowe Repeat is already rerendering, and onGetRid4Index executes always either createNewChild or 138 * updateChild, so we don't need a rerender after a lazy loading. Rerender CAN still happen if the totalCount 139 * was defined with a variable that updates its value after lazy loading has added an item (changed the array length)! 140 * Therefore using onTotalCount function is recommended with onLazyLoading. 141 * 142 * Situations when an Error is thrown: 143 * 1- if during lazy loading the ArrayProxyHandler reports some other operation than 'set' (arr[index] = ...) 144 * 2- if during lazy loading the ArrayProxyHandler reports 'set' to some other index than lazyLoadingIndex_ 145 * 146 * Also, it is considered an error situation if there still is no item in this.arr_[index] after onLazyLoadingFunc_(index) 147 * returns, but we will not throw Error. Note that the container stops renderding when this happens. 148 * 149 * 150 * The most important data structures 151 * 152 * RID - Repeat Item ID - a unique number, uniquely identifies a RepeatItem - templateId - UINode triple 153 * these found data items remain together until deleted from the system 154 * (ie.. until purge or unload deletes the UINode subtree) 155 * 156 * meta4Rid_: Map<number, RIDMeta<T>> 157 * - for each RID: 158 * - constant: RepeatItem 159 * - constant: ttype 160 * - mutable: key 161 * - counterpart on C++ side RepeatVirtualScroll2Caches.cacheItem4Rid_ maps RID -> UINode 162 * 163 * activeDataItems - Array<ActiveDataItem<T | void>> 164 * This is the central data structure for rerender, as allows to compare previous item value / keys 165 * Sparse array, only includes items for active range 166 * - array item value at last render/update, rid, ttype, key (if used), some state info 167 * 168 * spareRid_ : set<RID> RID currently not in active range, "L2" 169 * 170 * ttypeGenFunc_: templateId function 171 * itemGenFuncs_: map of item builder functions per templateId / ttype and .each 172 */ 173 174class ActiveDataItem<T> { 175 public item: T; 176 public rid?: number; 177 public ttype?: string; 178 public key?: string; 179 public state: number; 180 181 public static readonly UINodeExists = 1; 182 public static readonly UINodeRenderFailed = 2; 183 public static readonly UINodeToBeRendered = 3; 184 public static readonly NoValue = 4; 185 186 protected constructor(state: number, itemValue?: T, rid?: number, ttype?: string, key?: string) { 187 this.state = state; 188 this.item = itemValue; 189 this.rid = rid; 190 this.ttype = ttype; 191 this.key = key; 192 } 193 194 // factory functions for permissible ActiveDataItems 195 public static createWithUINode<T>(itemValue: T, rid: number, ttype: string, key?: string): ActiveDataItem<T> { 196 return new ActiveDataItem(ActiveDataItem.UINodeExists, itemValue, rid, ttype, key); 197 } 198 199 public static createFailedToCreateUINodeDataItem<T>(itemValue: T): ActiveDataItem<T> { 200 return new ActiveDataItem(ActiveDataItem.UINodeRenderFailed, itemValue); 201 } 202 203 public static createMissingDataItem(): ActiveDataItem<void> { 204 return new ActiveDataItem<void>(ActiveDataItem.NoValue); 205 } 206 207 public static createToBeRenderedDataItem<T>(itemValue: T, ttype: string, key?: string): ActiveDataItem<T> { 208 return new ActiveDataItem(ActiveDataItem.UINodeToBeRendered, itemValue, undefined, ttype, key); 209 } 210 211 public toString(): string { 212 return this.state === ActiveDataItem.UINodeExists 213 ? `[rid: ${this.rid}, ttype: ${this.ttype}${this.key ? ', key: ' + this.key : ''}]` 214 : `[no item]`; 215 } 216 217 public dump(): string { 218 const state = this.state === ActiveDataItem.UINodeExists 219 ? 'UINode exists' 220 : this.state === ActiveDataItem.UINodeRenderFailed 221 ? 'UINode failed to render' 222 : this.state === ActiveDataItem.UINodeToBeRendered 223 ? 'UINode to be rendered' 224 : this.state === ActiveDataItem.NoValue 225 ? 'No data value' 226 : 'unknown state (error)'; 227 const rid = this.rid ?? 'no RID/not rendered'; 228 const ttype = this.ttype ?? 'ttype N/A'; 229 return (this.state === ActiveDataItem.UINodeExists) 230 ? `state: '${state}', RID: ${rid}, ttype: ${ttype}, key: ${this.key}` 231 : `state: '${state}'`; 232 } 233 234 public shortDump() : string { 235 const state = this.state === ActiveDataItem.UINodeExists 236 ? 'UINode exists' 237 : this.state === ActiveDataItem.UINodeRenderFailed 238 ? 'UINode failed to render' 239 : this.state === ActiveDataItem.NoValue 240 ? 'No data value' 241 : 'unknown state (error)'; 242 const rid = this.rid ?? 'no RID/not rendered'; 243 const ttype = this.ttype ?? 'ttype N/A'; 244 return (this.state === ActiveDataItem.UINodeExists) 245 ? `state: '${state}', RID: ${rid}, ttype: ${ttype}` 246 : `state: '${state}'`; 247 } 248} 249 250// info about each created UINode / each RepeatItem / each RID 251// only optional key is allowed to change 252class RIDMeta<T> { 253 public readonly repeatItem_: __RepeatItemV2<T>; 254 public readonly ttype_: string; 255 public key_?: string; 256 257 constructor(repeatItem: __RepeatItemV2<T>, ttype: string, key?: string) { 258 this.repeatItem_ = repeatItem; 259 this.ttype_ = ttype; 260 this.key_ = key; 261 } 262} 263 264// see enum NG::UINode::NotificationType 265enum NotificationType { 266 START_CHANGE_POSITION = 0, END_CHANGE_POSITION, START_AND_END_CHANGE_POSITION 267} 268 269// empty function 270const NOOP : () => void = function() {}; 271 272class __RepeatVirtualScroll2Impl<T> { 273 public static readonly REF_META = Symbol('__repeat_ref_meta__'); 274 275 private arr_: Array<T>; 276 277 // pointer to self, used to subscribe to source array 278 private selfPtr_ = new WeakRef<__RepeatVirtualScroll2Impl<T>>(this); 279 280 // key function 281 private keyGenFunc_?: RepeatKeyGenFunc<T>; 282 283 // is key function specified ? 284 private useKeys_: boolean = false; 285 286 // index <-> key bidirectional mapping 287 private key4Index_: Map<number, string> = new Map<number, string>(); 288 private index4Key_: Map<string, number> = new Map<string, number>(); 289 // duplicate keys 290 private oldDuplicateKeys_: Set<string> = new Set<string>(); 291 292 // map if .each and .template functions 293 private itemGenFuncs_: { [type: string]: RepeatItemGenFunc<T> }; 294 295 // templateId function 296 private ttypeGenFunc_?: RepeatTTypeGenFunc<T>; 297 298 // virtualScroll({ totalCount: number }), optional to set 299 private totalCount_: (() => number) | number | undefined; 300 301 // virtualScroll({ onLazyLoading: (index: number) => void }), optional to set 302 private onLazyLoadingFunc_: (index : number) => void; 303 private lazyLoadingIndex_: number = -1; 304 305 // .template 3rd parameter, cachedCount 306 private templateOptions_: { [type: string]: RepeatTemplateImplOptions }; 307 308 // reuse node in L2 cache or not 309 private allowUpdate_?: boolean = true; 310 311 // factory for interface RepeatItem<T> objects 312 private mkRepeatItem_: (item: T, index?: number) => __RepeatItemFactoryReturn<T>; 313 314 // register drag drop manager, only used for List 315 private onMoveHandler_?: OnMoveHandler; 316 317 // register drag drop Handler Class, only used for List onMove 318 private itemDragEventHandler_?: ItemDragEventHandler; 319 320 // update from C++ when MoveData happens 321 // reset from C++ when FireOnMove happens 322 private moveFromTo_?: [number, number] = undefined; 323 324 // RepeatVirtualScroll2Node elmtId 325 public repeatElmtId_: number = -1; 326 327 private owningViewV2_: ViewV2; 328 329 // used to generate unique RID 330 private nextRid: number = 1; 331 332 // previously informed active range from - to 333 private activeRange_: [number, number] = [Number.NaN, Number.NaN]; 334 335 // previously informed visible range from - to 336 private visibleRange_: [number, number] = [Number.NaN, Number.NaN]; 337 338 // adjusted activeRange[0] based on accumulated array mutations 339 private activeRangeAdjustedStart_ = Number.NaN; 340 341 // adjusted visibleRange[0] based on accumulated array mutations 342 private visibleRangeAdjustedStart_ = Number.NaN; 343 344 // Map containing all rid: rid -> RepeatItem, ttype, key? 345 // entires never change 346 private meta4Rid_: Map<number, RIDMeta<T>> = new Map<number, RIDMeta<T>>(); 347 348 // Map containing all rid: rid -> ttype, 349 // entires never change 350 // private ttype4Rid_: Map<number, string> = new Map<number, string>(); 351 352 // sparse Array containing copy of data items and optionally keys in active range 353 private activeDataItems_: Array<ActiveDataItem<T | void>> = new Array<ActiveDataItem<T | void>>(); 354 355 // rid not in L1 / not in active range belong to this set 356 // they are no longer associated with a data item 357 private spareRid_: Set<number> = new Set<number>(); 358 359 // record the additional spare rid added in addRemovedItemsToSpare() 360 private additionalSpareRid_: Set<number> = new Set<number>(); 361 362 // record the rid that need to Call OnRecycle() on C++ side 363 private ridNeedToRecycle_: Set<number> = new Set<number>(); 364 365 // request container re-layout 366 private firstIndexChanged_: number = 0; 367 368 private firstIndexChangedInTryFastRelayout_: number = Number.NaN; 369 370 // optimization: true if any items have bindings with their indexes 371 private hasItemBindingsToIndex_ = false; 372 373 // optimization: flag for checking if rerender is already ongoing 374 private rerenderOngoing_: boolean = false; 375 376 // microtask to sync render-tree after tryFastRelayout 377 private nextTickTask_: ((changeIndex?: number) => void) | undefined = undefined; 378 379 // prevents reRender() trigger 380 private preventReRender_: boolean = false; 381 382 // when access view model record dependency on 'this'. 383 private startRecordDependencies(clearBindings: boolean = false): void { 384 ObserveV2.getObserve().startRecordDependencies(this.owningViewV2_, this.repeatElmtId_, clearBindings); 385 } 386 387 private stopRecordDependencies(): void { 388 ObserveV2.getObserve().stopRecordDependencies(); 389 } 390 391 /** 392 * return array item if it exists 393 * 394 * @param index 395 * @returns tuple data item exists , data item 396 * (need to do like this to differentiate missing data item and undefined item value 397 * same as std::optional in C++) 398 */ 399 private getItemUnmonitored(index: number | string): [boolean, T] { 400 stateMgmtConsole.debug(`getItemUnmonitored ${index} data item exists: ${index in this.arr_}`); 401 if (this.onLazyLoadingFunc_ && !(index in this.arr_)) { 402 this.lazyLoadingIndex_ = index as number; 403 // ensure unrecorded lazy loading! 404 ObserveV2.getObserve().executeUnrecorded(() => { this.onLazyLoadingFunc_(this.lazyLoadingIndex_) }); 405 if (!(index in this.arr_)) { 406 const msg = ` onLazyLoading function did not provide data to index ${index}`; 407 stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}): ${msg}`); 408 } 409 this.lazyLoadingIndex_ = -1; 410 } 411 return [(index in this.arr_), this.arr_[index]]; 412 } 413 414 private getItemMonitored(index: number | string): [boolean, T] { 415 stateMgmtConsole.debug(`getItemMonitored ${index} data item exists: ${index in this.arr_}`); 416 417 this.startRecordDependencies(/* do not clear bindings */ false); 418 419 const result = this.arr_[index]; 420 421 this.stopRecordDependencies(); 422 423 return [(index in this.arr_), result]; 424 } 425 426 private totalCount(forceRetrieveTotalCount = false): number { 427 // when 'totalCount' is set as an <observable>, we call updateElement() just to 428 // retrieve its actual value - prevent triggering re-render here. 429 if (forceRetrieveTotalCount && typeof this.totalCount_ === 'number') { 430 this.preventReRender_ = true; 431 this.owningViewV2_.UpdateElement(this.repeatElmtId_); 432 this.preventReRender_ = false; 433 return this.totalCount(); 434 } 435 if (typeof this.totalCount_ === 'function') { 436 let totalCount = this.totalCount_(); 437 return (Number.isInteger(totalCount) && totalCount >= 0) ? totalCount : this.arr_.length; 438 } 439 return this.totalCount_ ?? this.arr_.length; 440 } 441 442 // initial render 443 // called from __Repeat.render 444 public render(config: __RepeatConfig<T>, isInitialRender: boolean): void { 445 446 if (this.arr_ && this.arr_ !== config.arr && this.arr_[__RepeatVirtualScroll2Impl.REF_META] !== undefined) { 447 // unsubscribe from the old source array 448 this.arr_[__RepeatVirtualScroll2Impl.REF_META].delete(this.selfPtr_); 449 } 450 451 // switch to the new array 452 this.arr_ = config.arr; 453 454 // add subscribers Set if needed 455 if (!(__RepeatVirtualScroll2Impl.REF_META in this.arr_)) { 456 this.arr_[__RepeatVirtualScroll2Impl.REF_META] = new Set<WeakRef<__RepeatVirtualScroll2Impl<T>>>(); 457 } 458 459 // subscribe to the new source array 460 if (this.arr_[__RepeatVirtualScroll2Impl.REF_META] !== undefined) { 461 this.arr_[__RepeatVirtualScroll2Impl.REF_META].add(this.selfPtr_); 462 } 463 464 // totalCount can be function, number or undefined 465 this.totalCount_ = config.totalCount; 466 467 this.onMoveHandler_ = config.onMoveHandler; 468 this.itemDragEventHandler_ = config.itemDragEventHandler; 469 470 this.owningViewV2_ = config.owningView_; 471 if ((this.owningViewV2_ instanceof ViewV2) && ('onLazyLoading' in config)) { 472 this.onLazyLoadingFunc_ = config.onLazyLoading; 473 } 474 475 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_})`, 476 `totalCount ${this.totalCount()} arr length ${this.arr_.length} .`); 477 478 if (!this.onLazyLoadingFunc_ && this.totalCount() > this.arr_.length) { 479 stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_})`, 480 `'totalCount' must not exceed the array length without 'onLazyLoading' being defined!`); 481 } 482 483 if (isInitialRender) { 484 this.itemGenFuncs_ = config.itemGenFuncs; 485 this.ttypeGenFunc_ = config.ttypeGenFunc; 486 this.templateOptions_ = config.templateOptions; 487 488 this.keyGenFunc_ = config.keyGenFunc; 489 this.allowUpdate_ = config.reusable; 490 491 this.mkRepeatItem_ = config.mkRepeatItem; 492 493 if (!(this.owningViewV2_ instanceof ViewV2)) { 494 stateMgmtConsole.applicationWarn(`${this.constructor.name}(${this.repeatElmtId_}))`, 495 `it is not allowed to use Repeat virtualScroll inside a @Component!`); 496 } 497 498 if (!this.itemGenFuncs_[RepeatEachFuncTtype]) { 499 throw new Error(`${this.constructor.name}(${this.repeatElmtId_}))` + 500 `lacks mandatory '.each' attribute function, i.e. has no default item builder. Application error!`); 501 } 502 503 this.initialRender(); 504 } else { 505 this.preventReRender_ || this.reRender(); 506 } 507 508 this.updateTemplateOptions(); 509 } 510 511 private updateTemplateOptions(): void { 512 if (!this.allowUpdate_) { 513 for (const templateType in this.templateOptions_) { 514 this.templateOptions_[templateType] = { cachedCountSpecified: true, cachedCount: 0 }; 515 } 516 } 517 } 518 519 private initialRender(): void { 520 this.repeatElmtId_ = ObserveV2.getCurrentRecordedId(); 521 522 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) initialRender()`, 523 `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - start`); 524 525 const arrLen = this.onLazyLoadingFunc_ ? this.totalCount() : this.arr_.length; 526 // Create the RepeatVirtualScroll2Node object 527 // pass the C++ to TS callback functions. 528 RepeatVirtualScroll2Native.create(arrLen, this.totalCount(), { 529 onGetRid4Index: this.onGetRid4Index.bind(this), 530 onRecycleItems: this.onRecycleItems.bind(this), 531 onActiveRange: this.onActiveRange.bind(this), 532 onMoveFromTo: this.onMoveFromTo.bind(this), 533 onPurge: this.onPurge.bind(this) 534 }); 535 536 // init onMove 537 RepeatVirtualScroll2Native.onMove(this.repeatElmtId_, this.onMoveHandler_, this.itemDragEventHandler_); 538 539 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) initialRender() data array length: `, 540 `${this.arr_.length}, totalCount: ${this.totalCount()} - done`); 541 } 542 543 // given data item and the ttype it needs to be rendered with from updated array: 544 // find same data item in activeDataItems, it also still needs to use same ttype as given 545 // return its { index in activeDataItems, its rid } 546 private findDataItemInOldActivateDataItemsByValue( 547 dataItem: T, ttype: string): { oldIndexStr: string, rid?: number } | undefined { 548 for (const oldIndex in this.activeDataItems_) { 549 const oldItem = this.activeDataItems_[oldIndex]; 550 if (oldItem.item === dataItem && oldItem.rid && oldItem.ttype === ttype) { 551 return { oldIndexStr: oldIndex, rid: oldItem.rid }; 552 } 553 } 554 return undefined; 555 } 556 557 // find same data item in activeDataItems by key, it also still needs to use same ttype as given 558 private findDataItemInOldActivateDataItemsByKey( 559 key: string, ttype: string): {oldIndexStr: string, rid?: number} | undefined { 560 for (const oldIndex in this.activeDataItems_) { 561 const oldItem = this.activeDataItems_[oldIndex]; 562 if (oldItem.key === key && oldItem.rid && oldItem.ttype === ttype) { 563 return { oldIndexStr: oldIndex, rid: oldItem.rid }; 564 } 565 } 566 return undefined; 567 } 568 569 // update Repeat, see overview documentation at the top of this file. 570 private reRender(): void { 571 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) reRender() data array length: `, 572 `${this.arr_.length}, totalCount: ${this.totalCount()} - start`); 573 574 this.rerenderOngoing_ = true; 575 576 // update onMove 577 // scenario: developers control whether onMove exists or not dynamically. 578 RepeatVirtualScroll2Native.onMove(this.repeatElmtId_, this.onMoveHandler_, this.itemDragEventHandler_); 579 580 const activeRangeFrom = this.activeRange_[0]; 581 const activeRangeTo = this.activeRange_[1]; 582 const arrLen = this.onLazyLoadingFunc_ ? this.totalCount() : this.arr_.length; 583 584 stateMgmtConsole.debug(`checking range ${activeRangeFrom} - ${activeRangeTo}`); 585 586 this.firstIndexChanged_ = Math.min(activeRangeTo + 1, this.arr_.length); 587 588 // replacement for this.activeDataItems_ 589 const newActiveDataItems: Array<ActiveDataItem<void | T>> = new Array<ActiveDataItem<void | T>>(); 590 591 // replacement for l1Rid4Index_ index -> rid map on C++ side 592 // will send to C++ when done 593 const newL1Rid4Index: Map<number, number> = new Map<number, number>(); 594 595 // clear keys for new rerender 596 this.key4Index_.clear(); 597 this.index4Key_.clear(); 598 this.oldDuplicateKeys_.clear(); 599 600 // step 1. move data items to newActiveDataItems that are unchanged 601 // (same item / same key, still at same index, same ttype) 602 // create createMissingDataItem -type entries for all other new data items. 603 if (!this.moveItemsUnchanged(newActiveDataItems, newL1Rid4Index)) { 604 this.rerenderOngoing_ = false; 605 return; 606 } 607 608 // step 2. move retained data items 609 // these are items with same value / same key in new and old array: 610 // their index has changed, ttype is unchanged 611 this.moveRetainedItems(newActiveDataItems, newL1Rid4Index); 612 613 // step 3. remaining old data items, i.e. data item removed from source array 614 // add their rid to spare 615 this.addRemovedItemsToSpare(); 616 617 // step 4: data items in new source array that are either new in the array 618 // or have been there before but need to be rendered with different ttype 619 // if canUpdate then do the update. 620 // if need new render, do not do the new render right away. Wait for layout to ask 621 // for the item to render. 622 this.newItemsNeedToRender(newActiveDataItems, newL1Rid4Index); 623 624 // render all data changes in one go 625 ObserveV2.getObserve().updateDirty2(true); 626 627 this.activeDataItems_ = newActiveDataItems; 628 629 stateMgmtConsole.debug(`rerender result: `, 630 `\nspareRid : ${this.dumpSpareRid()}`, 631 `\nthis.dumpDataItems: ${this.activeDataItems_}`, 632 `\nnewL1Rid4Index: ${JSON.stringify(Array.from(newL1Rid4Index))}`, 633 `\nfirst item changed at index ${this.firstIndexChanged_} .`); 634 635 if (!isNaN(this.firstIndexChangedInTryFastRelayout_)) { 636 this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, this.firstIndexChangedInTryFastRelayout_); 637 this.firstIndexChangedInTryFastRelayout_ = Number.NaN; 638 } 639 RepeatVirtualScroll2Native.updateL1Rid4Index(this.repeatElmtId_, arrLen, this.totalCount(), 640 this.firstIndexChanged_, Array.from(newL1Rid4Index), Array.from(this.ridNeedToRecycle_)); 641 642 this.rerenderOngoing_ = false; 643 644 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) reRender() data array length: `, 645 `${this.arr_.length}, totalCount: ${this.totalCount()} - done`); 646 } 647 648 private moveItemsUnchanged( 649 newActiveDataItems: Array<ActiveDataItem<void | T>>, newL1Rid4Index: Map<number, number>): boolean { 650 let hasChanges = false; 651 for (const indexS in this.activeDataItems_) { 652 const activeIndex = parseInt(indexS); 653 if (activeIndex < 0) { 654 // out of range to consider 655 continue; 656 } 657 if (activeIndex >= this.arr_.length || activeIndex >= this.totalCount()) { 658 // data item has been popped from arr_ array that are part of active range 659 hasChanges = true; 660 break; 661 } 662 663 const [dataItemExists, dataItemAtIndex] = this.getItemUnmonitored(activeIndex); 664 if (!dataItemExists) { 665 stateMgmtConsole.debug(`index ${activeIndex} no data in new array, had data before `, 666 `${this.activeDataItems_[indexS].state !== ActiveDataItem.NoValue}`); 667 hasChanges = hasChanges || (this.activeDataItems_[indexS].state !== ActiveDataItem.NoValue); 668 newActiveDataItems[activeIndex] = ActiveDataItem.createMissingDataItem(); 669 continue; 670 } 671 672 const ttype = this.computeTtype(dataItemAtIndex, activeIndex, 673 /* monitored access already enabled */ false); 674 const key = this.computeKey(dataItemAtIndex, activeIndex, 675 /* monitor access already on-going */ false, newActiveDataItems); 676 677 // compare with ttype and data item, or with ttype and key 678 if ((ttype === this.activeDataItems_[activeIndex].ttype) && 679 ((!this.useKeys_ && dataItemAtIndex === this.activeDataItems_[activeIndex].item) || 680 (this.useKeys_ && key === this.activeDataItems_[activeIndex].key))) { 681 stateMgmtConsole.debug( 682 `index ${activeIndex} ttype '${ttype}'${this.useKeys_ ? ', key ' + key : ''} `, 683 `and dataItem unchanged.`); 684 newActiveDataItems[activeIndex] = this.activeDataItems_[activeIndex]; 685 686 // add to index -> rid map to be sent to C++ 687 newL1Rid4Index.set(activeIndex, this.activeDataItems_[activeIndex].rid); 688 689 // the data item is handled, remove it from old active data range 690 // so we do not use it again 691 delete this.activeDataItems_[activeIndex]; 692 } else { 693 stateMgmtConsole.debug(`index ${activeIndex} has changed `, 694 `${dataItemAtIndex !== this.activeDataItems_[activeIndex].item}, ttype ${ttype} has changed `, 695 `${ttype !== this.activeDataItems_[activeIndex].ttype}, key ${key} has changed `, 696 `${key !== this.activeDataItems_[activeIndex].key}, using keys ${this.useKeys_}`); 697 newActiveDataItems[activeIndex] = 698 ActiveDataItem.createToBeRenderedDataItem(dataItemAtIndex, ttype, key); 699 hasChanges = true; 700 } 701 } // for activeItems 702 703 // tells the container to adjust the scroll position (when it's needed) 704 const hasNotifiedLayoutChange = this.notifyContainerLayoutChangeAcc(); 705 706 if (hasChanges) { 707 return true; 708 } 709 710 // invalidate the layout only for items beyond active range 711 // this is for the case that there is space for more visible items in the container. 712 // triggers layout to request FrameCount() / totalCount and if increased newly added source array items 713 this.activeDataItems_ = newActiveDataItems; 714 715 if (hasNotifiedLayoutChange) { 716 this.requestContainerReLayout(); 717 } else { 718 this.requestContainerReLayout(Math.min(this.totalCount() - 1, this.activeRange_[1] + 1)); 719 } 720 721 return false; 722 } 723 724 private moveRetainedItems( 725 newActiveDataItems: Array<ActiveDataItem<void | T>>, newL1Rid4Index: Map<number, number>): void { 726 for (const indexS in newActiveDataItems) { 727 const activeIndex = parseInt(indexS); 728 const newActiveDataItemAtActiveIndex = newActiveDataItems[activeIndex]; 729 730 if (newActiveDataItemAtActiveIndex.state === ActiveDataItem.UINodeExists) { 731 // same index in new and old, processed in step 1 732 continue; 733 } 734 735 if (newActiveDataItemAtActiveIndex.state === ActiveDataItem.NoValue) { 736 stateMgmtConsole.debug(`new index ${activeIndex} missing in updated source array.`); 737 this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, activeIndex); 738 continue; 739 } 740 741 // retainedItem must have same item value / same key, same ttype, but new index 742 let movedDataItem; 743 if (this.useKeys_) { 744 const key = this.computeKey(newActiveDataItemAtActiveIndex.item as T, activeIndex, 745 /* monitor access already ongoing */ false, newActiveDataItems); 746 movedDataItem = 747 this.findDataItemInOldActivateDataItemsByKey(key, newActiveDataItemAtActiveIndex.ttype); 748 } else { 749 movedDataItem = this.findDataItemInOldActivateDataItemsByValue( 750 newActiveDataItemAtActiveIndex.item as T, newActiveDataItemAtActiveIndex.ttype); 751 } 752 753 if (movedDataItem) { 754 // data item rendered before, and needed ttype to render has not changed 755 newActiveDataItemAtActiveIndex.rid = movedDataItem.rid; 756 newActiveDataItemAtActiveIndex.state = ActiveDataItem.UINodeExists; 757 758 // add to index -> rid map to be sent to C++ 759 newL1Rid4Index.set(activeIndex, movedDataItem.rid); 760 761 // index has changed, update it in RepeatItem 762 const ridMeta = this.meta4Rid_.get(movedDataItem.rid); 763 stateMgmtConsole.debug(`new index ${activeIndex} / old index ${movedDataItem.oldIndexStr}: `, 764 `keep in L1: rid ${movedDataItem.rid}, unchanged ttype '${newActiveDataItemAtActiveIndex.ttype}'`); 765 ridMeta.repeatItem_.updateIndex(activeIndex); 766 this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, activeIndex); 767 768 // the data item is handled, remove it from old active data range 769 // so we do not use it again 770 delete this.activeDataItems_[movedDataItem.oldIndexStr]; 771 } else { 772 // update is needed for this data item 773 // either because dataItem is new, or new ttype needs to used 774 stateMgmtConsole.debug(`need update for index ${activeIndex}`); 775 this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, activeIndex); 776 } 777 } // for new data items in active range 778 } 779 780 private addRemovedItemsToSpare(): void { 781 this.additionalSpareRid_.clear(); 782 for (let oldIndex in this.activeDataItems_) { 783 if (this.activeDataItems_[oldIndex].rid) { 784 this.spareRid_.add(this.activeDataItems_[oldIndex].rid); 785 this.additionalSpareRid_.add(this.activeDataItems_[oldIndex].rid); 786 const index = parseInt(oldIndex); 787 this.index4Key_.delete(this.key4Index_.get(index)); 788 this.key4Index_.delete(index); 789 } 790 } 791 } 792 793 private newItemsNeedToRender( 794 newActiveDataItems: Array<ActiveDataItem<void | T>>, newL1Rid4Index: Map<number, number>): void { 795 this.ridNeedToRecycle_.clear(); 796 for (const indexS in newActiveDataItems) { 797 const activeIndex = parseInt(indexS); 798 const newActiveDataItemAtActiveIndex = newActiveDataItems[activeIndex]; 799 800 if (newActiveDataItemAtActiveIndex.state !== ActiveDataItem.UINodeToBeRendered) { 801 continue; 802 } 803 804 const optRid = this.canUpdate(newActiveDataItemAtActiveIndex.ttype); 805 if (optRid <= 0) { 806 stateMgmtConsole.debug(`active range index ${activeIndex}: no rid found to update`); 807 continue; 808 } 809 const ridMeta = this.meta4Rid_.get(optRid); 810 if (ridMeta) { 811 // found rid / repeatItem to update 812 stateMgmtConsole.debug(`index ${activeIndex}: update rid ${optRid} / ttype `, 813 `'${newActiveDataItemAtActiveIndex.ttype}'`); 814 815 newActiveDataItemAtActiveIndex.rid = optRid; 816 newActiveDataItemAtActiveIndex.state = ActiveDataItem.UINodeExists; 817 818 if (this.useKeys_) { 819 const key = this.computeKey(newActiveDataItemAtActiveIndex.item as T, activeIndex, 820 /* monitor access already ongoing */ false, newActiveDataItems); 821 newActiveDataItemAtActiveIndex.key = key; 822 ridMeta.key_ = key; 823 } 824 825 // spare rid is used 826 this.spareRid_.delete(optRid); 827 828 // add to index -> rid map to be sent to C++ 829 newL1Rid4Index.set(activeIndex, optRid); 830 831 // if the rid is recycled in current render, notify C++ to call OnRecycle() 832 if (this.additionalSpareRid_.has(optRid)) { 833 this.ridNeedToRecycle_.add(optRid); 834 } 835 836 // don't need to call getItem here, already checked that the data item exists 837 ridMeta.repeatItem_.updateItem(newActiveDataItemAtActiveIndex.item as T); 838 ridMeta.repeatItem_.updateIndex(activeIndex); 839 840 this.firstIndexChanged_ = Math.min(this.firstIndexChanged_, activeIndex); 841 } 842 }; 843 } 844 845 private computeTtype(item: T, index: number, monitorAccess: boolean): string { 846 if (this.ttypeGenFunc_ === undefined) { 847 return RepeatEachFuncTtype; 848 } 849 // record dependencies if monitoring is enabled 850 monitorAccess && this.startRecordDependencies(false); 851 let ttype = RepeatEachFuncTtype; 852 try { 853 ttype = this.ttypeGenFunc_(item, index); 854 } catch (e) { 855 stateMgmtConsole.applicationError( 856 `${this.constructor.name}(${this.repeatElmtId_}): Error generating ttype at index: ${index}`, 857 e?.message); 858 } 859 if (ttype in this.itemGenFuncs_ === false) { 860 stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}):`, 861 `No template found for ttype '${ttype}'`); 862 ttype = RepeatEachFuncTtype; 863 } 864 monitorAccess && this.stopRecordDependencies(); 865 return ttype; 866 } 867 868 private computeKey(item: T, index: number, monitorAccess: boolean = true, 869 activateDataItems?: Array<ActiveDataItem<void | T>>): string | undefined { 870 if (!this.useKeys_) { 871 return undefined; 872 } 873 874 let key = this.key4Index_.get(index); 875 if (!key) { 876 monitorAccess && this.startRecordDependencies(false); 877 try { 878 key = this.keyGenFunc_(item, index); 879 } catch { 880 stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}):`, 881 `unstable key function. Fix the key gen function in your application!`); 882 key = this.mkRandomKey(index, '_key-gen-crashed_'); 883 } 884 monitorAccess && this.stopRecordDependencies(); 885 886 const optIndex1: number | undefined = this.index4Key_.get(key); 887 if (optIndex1 !== undefined || this.oldDuplicateKeys_.has(key)) { 888 key = this.handleDuplicateKey(index, key, optIndex1, activateDataItems); 889 } else { 890 this.key4Index_.set(index, key); 891 this.index4Key_.set(key, index); 892 } 893 } 894 return key; 895 } 896 897 private mkRandomKey(index: number, origKey: string): string { 898 return `___${index}_+_${origKey}_+_${Math.random()}`; 899 } 900 901 // when generating key for index2, detected that index1 has same key already 902 // need to change the key for both index'es 903 // returns random key for index 2 904 private handleDuplicateKey(curIndex: number, origKey: string, prevIndex?: number, 905 activateDataItems?: Array<ActiveDataItem<void | T>>): string { 906 this.oldDuplicateKeys_.add(origKey); 907 908 const curKey = this.mkRandomKey(curIndex, origKey); 909 this.key4Index_.set(curIndex, curKey); 910 this.index4Key_.set(curKey, curIndex); 911 912 if (prevIndex !== undefined) { 913 // also make a new key for prevIndex 914 const prevKey = this.mkRandomKey(prevIndex, origKey); 915 this.key4Index_.set(prevIndex, prevKey); 916 this.index4Key_.set(prevKey, prevIndex); 917 this.index4Key_.delete(origKey); 918 if (activateDataItems && activateDataItems[prevIndex] !== undefined) { 919 stateMgmtConsole.debug(`correcting key of activeDataItem index ${prevIndex} from `, 920 `'${activateDataItems[prevIndex].key}' to '${prevKey}'.`); 921 activateDataItems[prevIndex].key = prevKey; 922 } 923 } 924 stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}): `, 925 `Detected duplicate key for index ${curIndex}! `, 926 `Generated random key will decrease Repeat performance. Fix the key gen function in your application!`); 927 return curKey; 928 } 929 930 /** 931 * called from C++ GetFrameChild whenever need to create new node and add to L1 932 * or update spare node and add back to L1 933 * 934 * @param forIndex 935 * @returns 936 */ 937 private onGetRid4Index(forIndex: number): [number, number] { 938 if (forIndex < 0 || forIndex >= this.totalCount()) { 939 throw new Error(`${this.constructor.name}(${this.repeatElmtId_}) onGetRid4Index index ${forIndex}` + 940 `\ndata array length: ${this.arr_.length}, totalCount: ${this.totalCount()}: ` + 941 `Out of range, application error.`); 942 } 943 const [dataItemExists, dataItem] = this.getItemUnmonitored(forIndex); 944 if (!dataItemExists) { 945 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) `, 946 `onGetRid4Index index ${forIndex} - missing data item.`); 947 this.activeDataItems_[forIndex] = ActiveDataItem.createMissingDataItem(); 948 return [0, /* failed to create or update */ 0]; 949 } 950 951 const ttype = this.computeTtype(dataItem, forIndex, /* enable monitored access */ true); 952 const key = this.computeKey(dataItem, forIndex, /* monitor access*/ true, this.activeDataItems_); 953 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onGetRid4Index index ${forIndex}, `, 954 `ttype is '${ttype}' data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - start`); 955 956 // spare UINode / RID available to update? 957 const optRid = this.canUpdateTryMatch(ttype, dataItem, key); 958 959 const result: [number, number] = (optRid > 0) 960 ? this.updateChild(optRid, ttype, forIndex, key) 961 : this.createNewChild(forIndex, ttype, key); 962 963 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onGetRid4Index index ${forIndex} `, 964 `ttype is '${ttype}' data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - DONE`); 965 return result; 966 } 967 968 // return RID of Node that can be updated (matching ttype), 969 // or -1 if none 970 private canUpdate(ttype: string): number { 971 if (!this.allowUpdate_) { 972 return -1; 973 } 974 for (const rid of this.spareRid_) { 975 if (this.meta4Rid_.get(rid).ttype_ === ttype) { 976 stateMgmtConsole.debug(`canUpdate: Found spare rid ${rid} for ttype '${ttype}'`); 977 return rid; 978 } 979 } 980 stateMgmtConsole.debug(`canUpdate: Found NO spare rid for ttype '${ttype}'`); 981 return -1; 982 } 983 984 // return RID of Node that can be updated (matching ttype), 985 // or -1 if none 986 private canUpdateTryMatch(ttype: string, dataItem: T, key?: string): number { 987 if (!this.allowUpdate_) { 988 return -1; 989 } 990 // 1. round: find matching RID, also data item matches 991 for (const rid of this.spareRid_) { 992 const ridMeta = this.meta4Rid_.get(rid); 993 // compare ttype and data item, or ttype and key 994 if (ridMeta && ridMeta.ttype_ === ttype && 995 ((!this.useKeys_ && ridMeta.repeatItem_?.item === dataItem) || 996 (this.useKeys_ && ridMeta.key_ === key))) { 997 stateMgmtConsole.debug( 998 `canUpdateTryMatch: Found spare rid ${rid} for ttype '${ttype}' contentItem matches.`); 999 return rid; 1000 } 1001 } 1002 1003 // just find a matching RID 1004 for (const rid of this.spareRid_) { 1005 if (this.meta4Rid_.get(rid).ttype_ === ttype) { 1006 stateMgmtConsole.debug(`canUpdateTryMatch: Found spare rid ${rid} for ttype '${ttype}'`); 1007 return rid; 1008 } 1009 } 1010 stateMgmtConsole.debug(`canUpdateTryMatch: Found NO spare rid for ttype '${ttype}'`); 1011 return -1; 1012 } 1013 1014 /** 1015 * crete new Child node onto the ViewStackProcessor 1016 * 1017 * @param forIndex 1018 * @param ttype 1019 * @returns [ success, 1 for new node created ] 1020 */ 1021 private createNewChild(forIndex: number, ttype: string, key?: string): [number, number] { 1022 let itemGenFunc = this.itemGenFuncs_[ttype]; 1023 this.startRecordDependencies(); 1024 1025 // item exists in arr_, has been checked before 1026 const [_, dataItem] = this.getItemUnmonitored(forIndex); 1027 const repeatItem = this.mkRepeatItem_(dataItem, forIndex) as __RepeatItemV2<T>; 1028 const rid = this.nextRid++; 1029 1030 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) createNewChild index ${forIndex} -> `, 1031 `new rid ${rid} / ttype ${ttype}, key ${key} - data array length: ${this.arr_.length}, `, 1032 `totalCount: ${this.totalCount()} - start`); 1033 1034 try { 1035 // execute item builder function 1036 const isTemplate: boolean = (ttype !== RepeatEachFuncTtype); 1037 itemGenFunc(repeatItem); 1038 RepeatVirtualScroll2Native.setCreateByTemplate(isTemplate); 1039 } catch (e) { 1040 this.stopRecordDependencies(); 1041 1042 stateMgmtConsole.applicationError(`${this.constructor.name}(${this.repeatElmtId_}) `, 1043 `initialRenderChild(forIndex: ${forIndex}, templateId: '${ttype}') -> RID ${rid}: `, 1044 `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - `, 1045 `item initial render failed!`); 1046 this.activeDataItems_[forIndex] = ActiveDataItem.createFailedToCreateUINodeDataItem(this.arr_[forIndex]); 1047 return [0, /* did not success creating new UINode */ 0]; 1048 } 1049 1050 // do any items have bindings with their indexes? 1051 // // this.hasItemBindingsToIndex_ ||= repeatItem.hasBindingToIndex(); 1052 1053 // a new UINode subtree, create a new rid -> RepeatItem, ttype, key 1054 this.meta4Rid_.set(rid, new RIDMeta(repeatItem, ttype, key)); 1055 this.activeDataItems_[forIndex] = ActiveDataItem.createWithUINode(this.arr_[forIndex], rid, ttype, key); 1056 1057 this.stopRecordDependencies(); 1058 1059 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) createNewChild index ${forIndex} -> `, 1060 `new rid ${rid} / ttype ${ttype}, key ${key} - data array length: ${this.arr_.length}, `, 1061 `totalCount: ${this.totalCount()} - done`); 1062 return [rid, /* created new UINode successfully */ 1]; 1063 } 1064 1065 /** 1066 * update given rid / RepeatItem to data item of given index 1067 * 1068 * @param rid 1069 * @param ttype 1070 * @param forIndex 1071 * @returns [ success, 2 for updated existing node ] 1072 */ 1073 private updateChild(rid: number, ttype: string, forIndex: number, key?: string): [number, number] { 1074 const ridMeta = this.meta4Rid_.get(rid); 1075 if (!ridMeta || !ridMeta.repeatItem_) { 1076 // error 1077 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) `, 1078 `updateChild(forIndex: ${forIndex}, templateId: ${ttype}) <- RID ${rid}: `, 1079 `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()}. `, 1080 `Failed to find RepeatItem. Internal error!.`); 1081 this.activeDataItems_[forIndex] = ActiveDataItem.createFailedToCreateUINodeDataItem(this.arr_[forIndex]); 1082 return [0, /* failed to update */ 0]; 1083 } 1084 1085 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) updateChild`, 1086 `(forIndex: ${forIndex}, templateId: ${ttype}) <- RID ${rid}, key: ${key}: `, 1087 `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - start`); 1088 1089 // item exists in arr_, has been checked before 1090 const [_, dataItem] = this.getItemMonitored(forIndex); 1091 1092 // rid is taken, move out of spare Set 1093 this.spareRid_.delete(rid); 1094 this.activeDataItems_[forIndex] = ActiveDataItem.createWithUINode(dataItem, rid, ttype, key); 1095 if (this.useKeys_ && key !== undefined) { 1096 // rid, repeatItem, ttype are constant, but key changes in ridMeta on child update 1097 ridMeta.key_ = key; 1098 } 1099 1100 if (ridMeta.repeatItem_.item !== dataItem || ridMeta.repeatItem_.index !== forIndex) { 1101 // repeatItem needs update, will trigger partial update to using UINodes: 1102 ridMeta.repeatItem_.updateItem(dataItem); 1103 ridMeta.repeatItem_.updateIndex(forIndex); 1104 1105 ObserveV2.getObserve().updateDirty2(/* update synchronously */ true); 1106 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) updateChild`, 1107 `(forIndex: ${forIndex}, templateId: ${ttype}) <- RID ${rid}, key: ${key}: `, 1108 `update has been done - done`); 1109 } else { 1110 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) updateChild`, 1111 `(forIndex: ${forIndex}, templateId: ${ttype}) <- RID ${rid}, key: ${key}: `, 1112 `item and index value as in spare, no update needed - done`); 1113 } 1114 1115 return [rid, /* update success */ 2]; 1116 } 1117 1118 /** 1119 * overloaded function from FrameNode 1120 * called from layout, inform index range of active / L1 items that can be removed from L1 1121 * to spare nodes, allow to update them 1122 * Note: Grid, List layout has a bug: Frequently calls GetFrameChildForIndex for the index 'fromIndex' 1123 * which moves this item back to L1 1124 * 1125 * @param fromIndex 1126 * @param toIndex 1127 */ 1128 private onRecycleItems(fromIndex: number, toIndex: number): void { 1129 // avoid negative fromIndex 1130 fromIndex = Math.max(0, fromIndex); 1131 for (let index = fromIndex; index < toIndex; index++) { 1132 // when ListItem is being dragged without dropping, index will be mapped. 1133 let indexMapped = this.convertFromToIndex(index); 1134 if (indexMapped >= this.totalCount() || !(indexMapped in this.activeDataItems_)) { 1135 continue; 1136 } 1137 if (this.activeDataItems_[indexMapped].state === ActiveDataItem.UINodeExists) { 1138 this.dropFromL1ActiveNodes(indexMapped); 1139 } 1140 } 1141 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onRecycleItems(${fromIndex}...<${toIndex})`, 1142 `after applying changes:\n${this.dumpSpareRid()}\n${this.dumpDataItems()}`); 1143 } 1144 1145 private onMoveFromTo(moveFrom: number, moveTo: number): void { 1146 moveFrom = Math.trunc(moveFrom); 1147 moveTo = Math.trunc(moveTo); 1148 if (!this.isNonNegative(moveFrom) || !this.isNonNegative(moveTo)) { 1149 this.moveFromTo_ = undefined; 1150 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onMoveFromTo param invalid,`, 1151 `reset moveFromTo.`); 1152 return; 1153 } 1154 if (this.moveFromTo_) { 1155 this.moveFromTo_[1] = moveTo; 1156 if (this.moveFromTo_[1] === this.moveFromTo_[0]) { 1157 this.moveFromTo_ = undefined; 1158 } 1159 } else { 1160 this.moveFromTo_ = [moveFrom, moveTo]; 1161 } 1162 if (this.moveFromTo_) { 1163 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onMoveFromTo updated`, 1164 `(${this.moveFromTo_[0]}, ${this.moveFromTo_[1]})`); 1165 } else { 1166 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onMoveFromTo data moved`, 1167 `to original pos, reset moveFromTo.`); 1168 } 1169 } 1170 1171 private isNonNegative(index: number): boolean { 1172 return (Number.isFinite(index) && index >= 0); 1173 } 1174 1175 /** 1176 * What is the effective time of these two function(convertFromToIndex, convertFromToIndexRevert)? 1177 * - Only valid when ListItem is being dragged up and moved without dropping. 1178 * - Otherwise, this.moveFromTo_ is undefined, nothing will be processed and the original index value 1179 * will be returned directly. 1180 * 1181 * How does this function convert index value? 1182 * - Look at this scenario. 1183 * - If original arr is [a, b, c, d], and users long-press item 'a' and drag it up and move it to pos after 'c' 1184 * without dropping. What users see is [b, c, a, d]. Then this.moveFromTo_ is [0, 2]. 1185 * - If mapping index by convertFromToIndex: 1186 * index is 2, then the mappedIndex is 0. 1187 * index is 1, then the mappedIndex is 2. 1188 * index is 0, then the mappedIndex is 1. 1189 * - If mapping index by convertFromToIndexRevert: 1190 * index is 0, then the mappedIndex is 2. 1191 * index is 1, then the mappedIndex is 0. 1192 * index is 2, then the mappedIndex is 1. 1193 * 1194 * Why these two function is needed? 1195 * - Simply put, they are used for onActiveRange and onRecycleItems when drag and drop sorting is on-going. 1196 * - Specifically, also based on the scenario upon and List is scrolling at the same time: 1197 * a) if onActiveRange(1, 3) is being called, then, convertFromToIndexRevert is needed. 1198 * "onActiveRange" is iterating activeDataItems_ and need to map each index in it by convertFromToIndexRevert, 1199 * to judge mappedIndex whether is in active range. Otherwise, item 'a' whose index is 0 is not in active 1200 * range and will be deleted. But actually, item 'a' whose index is 0 has been dragged to index 2. 1201 * So item that need to be deleted is 'b', whose index is 1 and its mappedIndex is 0. 1202 * b) if onRecycleItems(0, 1) is being called, then, convertFromToIndex is needed. 1203 * "onRecycleItems(0, 1)" is iterating index from fromIndex to toIndex. In this scene, it needs to 1204 * map each index by convertFromToIndex. Otherwise, item 'a' whose index is 0 will be removed from L1. 1205 * But actually, item 'a' whose index is 0 has been dragged to index 2. So item that need to be removed 1206 * is 'b', whose index is 1 and its mappedIndex is 0. 1207 */ 1208 private convertFromToIndex(index: number): number { 1209 if (!this.moveFromTo_) { 1210 return index; 1211 } 1212 if (this.moveFromTo_[1] === index) { 1213 return this.moveFromTo_[0]; 1214 } 1215 if (this.moveFromTo_[0] <= index && index < this.moveFromTo_[1]) { 1216 return index + 1; 1217 } 1218 if (this.moveFromTo_[1] < index && index <= this.moveFromTo_[0]) { 1219 return index - 1; 1220 } 1221 return index; 1222 } 1223 1224 // used for onActiveRange. Specific instructions are provided above. 1225 private convertFromToIndexRevert(index: number): number { 1226 if (!this.moveFromTo_) { 1227 return index; 1228 } 1229 if (this.moveFromTo_[0] === index) { 1230 return this.moveFromTo_[1]; 1231 } 1232 if (this.moveFromTo_[0] < index && index <= this.moveFromTo_[1]) { 1233 return index - 1; 1234 } 1235 if (this.moveFromTo_[1] <= index && index < this.moveFromTo_[0]) { 1236 return index + 1; 1237 } 1238 return index; 1239 } 1240 1241 private dropFromL1ActiveNodes(index: number, invalidate: boolean = true): boolean { 1242 if (!(index in this.activeDataItems_)) { 1243 return false; 1244 } 1245 1246 const rid: number | undefined = this.activeDataItems_[index].rid; 1247 const ttype: string | undefined = this.activeDataItems_[index].ttype; 1248 1249 // delete makes array item empty, does not re-index. 1250 delete this.activeDataItems_[index]; 1251 this.index4Key_.delete(this.key4Index_.get(index)); 1252 this.key4Index_.delete(index); 1253 1254 if (rid === undefined || ttype === undefined) { 1255 // data item is not rendered, yet 1256 stateMgmtConsole.debug(`dropFromL1ActiveNodes index: ${index} no rid ${rid} or no ttype `, 1257 `'${ttype ? ttype : 'undefined'}'. Dropping un-rendered item silently.`); 1258 return false; 1259 } 1260 1261 // add to spare rid Set 1262 this.spareRid_.add(rid); 1263 1264 if (invalidate) { 1265 stateMgmtConsole.debug(`dropFromL1ActiveNodes index: ${index} - rid: ${rid}/ttype: '${ttype}' `, 1266 `- spareRid: ${this.dumpSpareRid()} - invalidate in RepeatVirtualScroll2Node ...`); 1267 // call RepeatVirtualScroll2Caches::SetInvalid 1268 RepeatVirtualScroll2Native.setInvalid(this.repeatElmtId_, rid); 1269 } else { 1270 stateMgmtConsole.debug(`dropFromL1ActiveNodes index: ${index} - rid: ${rid}/ttype: '${ttype}' `, 1271 `- spareRid: ${this.dumpSpareRid()}`); 1272 } 1273 1274 return true; 1275 } 1276 1277 private onActiveRange(nStart: number, nEnd: number, vStart: number, vEnd: number, isLoop: boolean, 1278 forceUpdate: boolean): void { 1279 if (Number.isNaN(this.activeRange_[0])) { 1280 // first call to onActiveRange / no active node 1281 this.activeRange_ = [nStart, nEnd]; 1282 this.visibleRange_ = [vStart, vEnd]; 1283 this.activeRangeAdjustedStart_ = nStart; 1284 this.visibleRangeAdjustedStart_ = vStart; 1285 } else if (this.activeRange_[0] === nStart && this.activeRange_[1] === nEnd) { 1286 if (this.visibleRange_[0] !== vStart || this.visibleRange_[1] !== vEnd) { 1287 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onActiveRange`, 1288 `update visibleRange_ (vStart: ${vStart}, vEnd: ${vEnd})`); 1289 this.visibleRange_ = [vStart, vEnd]; 1290 this.visibleRangeAdjustedStart_ = vStart; 1291 } 1292 if (!isLoop && !forceUpdate) { 1293 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onActiveRange`, 1294 `(nStart: ${nStart}, nEnd: ${nEnd})`, 1295 `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - unchanged, skipping.`); 1296 return; 1297 } 1298 } 1299 1300 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) onActiveRange`, 1301 `(nStart: ${nStart}, nEnd: ${nEnd}), (start: ${vStart}, end: ${vEnd})`, 1302 `data array length: ${this.arr_.length}, totalCount: ${this.totalCount()} - start`); 1303 1304 // check which of the activeDataItems needs to be removed from L1 & activeDataItems 1305 let numberOfActiveItems = 0; 1306 for (let index = 0; index < this.activeDataItems_.length; index++) { 1307 // when ListItem is being dragged without dropping, index will be mapped. 1308 let indexMapped = this.convertFromToIndexRevert(index); 1309 if (!(index in this.activeDataItems_)) { 1310 continue; 1311 } 1312 1313 // same condition as in C++ RepeatVirtualScroll2Node::CheckNode4IndexInL1 1314 let remainInL1 = (nStart <= indexMapped && indexMapped <= nEnd); 1315 if (isLoop) { 1316 remainInL1 = remainInL1 || 1317 (nStart > nEnd && (nStart <= index || index <= nEnd)) || 1318 (nStart < 0 && index >= nStart + this.totalCount()) || 1319 (nEnd >= this.totalCount() && index <= nEnd - this.totalCount()); 1320 } 1321 stateMgmtConsole.debug(`index: ${index}: ${remainInL1 ? 'keep in L1' : 'drop from L1'}`, 1322 `dataItem: ${this.activeDataItems_[index].dump()}`); 1323 1324 if (remainInL1) { 1325 if (this.activeDataItems_[index].state === ActiveDataItem.UINodeExists) { 1326 numberOfActiveItems += 1; 1327 } 1328 } else { 1329 if (this.activeDataItems_[index].state === ActiveDataItem.UINodeExists) { 1330 this.dropFromL1ActiveNodes(index, /* call C++ RepeatVirtualScroll2Caches::SetInvalid */ false); 1331 } 1332 } 1333 } 1334 1335 // memorize 1336 this.activeRange_ = [nStart, nEnd]; 1337 this.visibleRange_ = [vStart, vEnd]; 1338 this.activeRangeAdjustedStart_ = nStart; 1339 this.visibleRangeAdjustedStart_ = vStart; 1340 1341 stateMgmtConsole.debug(`onActiveRange Result: number remaining activeItems ${numberOfActiveItems}.`, 1342 `\n${this.dumpDataItems()}\n${this.dumpSpareRid()}\n${this.dumpRepeatItem4Rid()}`); 1343 1344 // adjust dynamic cachedCount for each template type that is using dynamic cached count 1345 stateMgmtConsole.debug(`templateOptions_ ${JSON.stringify(this.templateOptions_)}`); 1346 Object.entries(this.templateOptions_).forEach((pair) => { 1347 const options: RepeatTemplateImplOptions = pair[1]; 1348 if (!options.cachedCountSpecified) { 1349 options.cachedCount = Number.isInteger(options.cachedCount) 1350 ? Math.max(numberOfActiveItems, options.cachedCount) 1351 : numberOfActiveItems; 1352 } 1353 }); 1354 stateMgmtConsole.debug(this.dumpCachedCount()); 1355 } 1356 1357 // handles circular ranges by normalizing the start and end values within 1358 // the circular range [-totalCount, +totalCount] 1359 private isIndexInRange(index: number, start: number, end: number, totalCount = this.totalCount()): boolean { 1360 // to codechecker: yes, need switch to math names here 1361 let [i, a, b, n] = [index, start, end, totalCount]; 1362 1363 // convert all indices to range [0, n-1], e.g. [-1] should become [0] 1364 a = ((a >= 0) ? a : Math.abs(a + 1)) % n; 1365 b = ((b >= 0) ? b : Math.abs(b + 1)) % n; 1366 1367 // shift values so that 'start' ('a') becomes 0 1368 i = (i - a + n) % n; 1369 b = (b - a + n) % n; 1370 1371 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) isIndexInRange:`, 1372 `${index} in [${start}, ${end}] totalCount=${this.totalCount()} =>`, i <= b); 1373 return i <= b; 1374 } 1375 1376 private hasOverlapWithActiveRange(startIndex: number, endIndex: number): boolean { 1377 // ensure we are not out of bounds 1378 const start = Math.min(startIndex, this.totalCount() - 1); 1379 const end = Math.min(endIndex, this.totalCount() - 1); 1380 1381 // ranges ovelap if at least one boundary of one range is inside the other 1382 return this.isIndexInRange(start, this.activeRange_[0], this.activeRange_[1]) || 1383 this.isIndexInRange(end, this.activeRange_[0], this.activeRange_[1]) || 1384 this.isIndexInRange(this.activeRange_[0], start, end) || 1385 this.isIndexInRange(this.activeRange_[1], start, end); 1386 } 1387 1388 private needRerenderChange(changeIndex: number, deleteCount: number, addCount: number): boolean { 1389 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) needRerenderChange(${changeIndex},`, 1390 `${deleteCount}, ${addCount}), activeRange:`, ...this.activeRange_); 1391 1392 // 0. Looping detected. We're in Swiper or another fancy container, need rerender 1393 if (this.activeRange_[1] < this.activeRange_[0]) { 1394 stateMgmtConsole.debug(`needRerenderChange (0) return`, true); 1395 return true; 1396 } 1397 1398 // 1. All changed or shifted items are behind the end of the activeRange, no need rerender 1399 if (changeIndex > this.activeRange_[1]) { 1400 stateMgmtConsole.debug(`needRerenderChange (1) return`, false); 1401 return false; 1402 } 1403 1404 // 2. New items have added within the activeRange, need rerender 1405 if (this.hasOverlapWithActiveRange(changeIndex, changeIndex + addCount)) { 1406 stateMgmtConsole.debug(`needRerenderChange (2) return`, true); 1407 return true; 1408 } 1409 1410 // 3. All changes are before the active range, no shifts 1411 if (deleteCount === addCount) { 1412 stateMgmtConsole.debug(`needRerenderChange (3) return`, false); 1413 return false; 1414 } 1415 1416 // 4. Items in the activeRange have only shifted. If there are no index-dependent items, 1417 // no need rerender 1418 1419 // Keep the code below commented out until FrameNode::NotifyChange is fixed 1420 // // let hasDependencyOnIndex = true; 1421 // // has dependency on index in itemgen func ? 1422 // // hasDependencyOnIndex = this.hasItemBindingsToIndex_; 1423 // // has dependency on index in typegen func ? 1424 // // hasDependencyOnIndex ||= (this.ttypeGenFunc_?.length >= 2); 1425 // // has dependency on index in keygen func ? 1426 // // hasDependencyOnIndex ||= (this.keyGenFunc_?.length >= 2); 1427 1428 // // if (!hasDependencyOnIndex) { 1429 // // stateMgmtConsole.debug(`needRerenderChange (4) return`, false); 1430 // // return false; 1431 // // } 1432 1433 stateMgmtConsole.debug(`needRerenderChange return`, true); 1434 return true; 1435 } 1436 1437 // update this.activeRangeAdjustedStart_ 1438 private adjustActiveRangeStart(index: number, deleteCount: number, addCount: number): void { 1439 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_})`, 1440 `adjustActiveRangeStart(${index}, ${deleteCount}, ${addCount})`); 1441 1442 // If activeRange_ is not yet known, do nothing 1443 if (isNaN(this.activeRangeAdjustedStart_) || isNaN(this.visibleRangeAdjustedStart_)) { 1444 this.activeRangeAdjustedStart_ = this.activeRange_[0]; 1445 this.visibleRangeAdjustedStart_ = this.visibleRange_[0]; 1446 } 1447 if (isNaN(this.activeRangeAdjustedStart_) || isNaN(this.visibleRangeAdjustedStart_)) { 1448 return; 1449 } 1450 1451 // count changes before visible range 1452 if (index <= this.visibleRangeAdjustedStart_) { 1453 this.visibleRangeAdjustedStart_ -= Math.min(deleteCount, this.visibleRangeAdjustedStart_ - index); 1454 this.visibleRangeAdjustedStart_ += addCount; 1455 } 1456 1457 // count changes before active range 1458 if (index <= this.activeRangeAdjustedStart_) { 1459 this.activeRangeAdjustedStart_ -= Math.min(deleteCount, this.activeRangeAdjustedStart_ - index); 1460 this.activeRangeAdjustedStart_ += addCount; 1461 } 1462 1463 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_})`, 1464 `activeRangeAdjustedStart_ = ${this.activeRangeAdjustedStart_}`, 1465 `visibleRangeAdjustedStart_ = ${this.visibleRangeAdjustedStart_}`); 1466 } 1467 1468 // Return false if a regular re-render is required. Otherwise, only notify the container 1469 // about a layout change and schedule a re-layout 1470 public tryFastRelayout(arrChange: string, args: Array<unknown>): boolean { 1471 // if rerender is already running, just skip everything 1472 if (this.rerenderOngoing_) { 1473 return true; 1474 } 1475 1476 if (this.lazyLoadingIndex_ !== -1 && arrChange !== 'set') { 1477 const msg = `onLazyLoading function executed illegal operation: ${arrChange}!`; 1478 throw new Error(`${this.constructor.name}(${this.repeatElmtId_}) ${msg}`); 1479 } 1480 1481 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) tryFastRelayout for '${arrChange}'`, 1482 `args: ${args}, activeRange: ${this.activeRange_}, visibleRange: ${this.visibleRange_}`); 1483 1484 // Note that the change in the array has already been done! 1485 // Now our activeRange reflects the array before the change 1486 // and this.arr_.length is the change after the change! 1487 1488 if (arrChange === 'push') { 1489 const originalLength = this.arr_.length - args.length; 1490 return this.tryFastRelayoutForChange(originalLength, this.arr_.length - args.length, 0, args.length); 1491 } 1492 1493 if (arrChange === 'pop') { 1494 const originalLength = this.arr_.length + 1; 1495 return this.tryFastRelayoutForChange(originalLength, this.arr_.length, 1, 0); 1496 } 1497 1498 if (arrChange === 'shift') { 1499 const originalLength = this.arr_.length + 1; 1500 return this.tryFastRelayoutForChange(originalLength, 0, 1, 0); 1501 } 1502 1503 if (arrChange === 'unshift') { 1504 const originalLength = this.arr_.length - args.length; 1505 return this.tryFastRelayoutForChange(originalLength, 0, 0, args.length); 1506 } 1507 1508 if (arrChange === 'splice') { 1509 // first parameter contains original array length before splice 1510 const [originalLength, index = undefined, deleteCount = undefined, ...items] = args as number[]; 1511 return this.tryFastRelayoutForChange(originalLength, index, deleteCount, items.length); 1512 } 1513 1514 if (arrChange === 'set') { 1515 const changeIndex = args[0] as number; 1516 if (this.lazyLoadingIndex_ !== -1 && changeIndex !== this.lazyLoadingIndex_) { 1517 const msg = `onLazyLoading function illegally set to index: ${changeIndex}`; 1518 throw new Error(`${this.constructor.name}(${this.repeatElmtId_}) ${msg}`); 1519 } 1520 return (changeIndex >= 0) && this.tryFastRelayoutForChange(this.arr_.length, changeIndex, 0, 0); 1521 } 1522 1523 // discard nextTickTask if it's scheduled 1524 this.nextTickTask_ &&= NOOP; 1525 return false; 1526 } 1527 1528 private tryFastRelayoutForChange(originalLength: number, index: number | undefined, 1529 deleteCount: number | undefined, addCount: number): boolean { 1530 1531 // array hasn't changed, render is not needed 1532 if (index === undefined && deleteCount === undefined && addCount === 0) { 1533 return true; 1534 } 1535 1536 // normalize index 1537 let nIndex = index ?? 0; 1538 if (nIndex < 0) { 1539 nIndex = Math.max(nIndex + originalLength, 0); 1540 } 1541 if (nIndex > originalLength) { 1542 nIndex = originalLength; 1543 } 1544 1545 // normalize deleteCount 1546 let nDeleteCount = deleteCount ?? (originalLength - nIndex); 1547 nDeleteCount = Math.min(nDeleteCount, originalLength - nIndex); 1548 nDeleteCount = Math.max(nDeleteCount, 0); 1549 1550 return this.tryFastRelayoutForChangeNormalized(nIndex, nDeleteCount, addCount); 1551 } 1552 1553 private tryFastRelayoutForChangeNormalized(index: number, deleteCount: number, addCount: number): boolean { 1554 // update accumulated active-range offset here 1555 this.adjustActiveRangeStart(index, deleteCount, addCount); 1556 1557 if (this.lazyLoadingIndex_ === -1 && this.needRerenderChange(index, deleteCount, addCount)) { 1558 // discard nextTickTask if it's scheduled 1559 this.nextTickTask_ &&= NOOP; 1560 this.updateFirstIndexChangedInTryFastRelayout(index); 1561 return false; 1562 } 1563 1564 if (deleteCount !== addCount) { 1565 // forcibly retrieve totalCount (needed only when 'totalCount' option is used) 1566 this.totalCount(true); 1567 } 1568 1569 // schedule microtask to run after all synchronous array updates 1570 if (this.nextTickTask_ === undefined) { 1571 Promise.resolve().then(() => { 1572 this.nextTickTask_?.(); 1573 this.nextTickTask_ = undefined; 1574 }); 1575 this.nextTickTask_ = () : void => { 1576 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}), nextTickTask()`); 1577 this.notifyContainerLayoutChangeAcc(); 1578 this.requestContainerReLayout(); 1579 }; 1580 } 1581 1582 return true; 1583 } 1584 1585 private updateFirstIndexChangedInTryFastRelayout(index: number): void { 1586 if (isNaN(this.firstIndexChangedInTryFastRelayout_)) { 1587 this.firstIndexChangedInTryFastRelayout_ = index; 1588 } else { 1589 this.firstIndexChangedInTryFastRelayout_ = Math.min(this.firstIndexChangedInTryFastRelayout_, index); 1590 } 1591 } 1592 1593 private notifyContainerLayoutChangeAcc(): boolean { 1594 const changeCount = this.visibleRangeAdjustedStart_ - this.visibleRange_[0]; 1595 if (isNaN(changeCount) || changeCount === 0) { 1596 return false; 1597 } 1598 1599 // get changeIndex to notify container with accumulated changes 1600 let changeIndex = (changeCount < 0) ? 0 : this.visibleRange_[0]; 1601 1602 // tells the container to adjust the scroll position, exact behavior is determined by 1603 // List.maintainVisibleContentPosition(bool) 1604 this.notifyContainerLayoutChange(changeIndex, changeCount); 1605 this.visibleRangeAdjustedStart_ = NaN; 1606 this.activeRangeAdjustedStart_ = NaN; 1607 return true; 1608 } 1609 1610 private notifyContainerLayoutChange(changeIndex: number, changeCount: number, 1611 notificationType = NotificationType.END_CHANGE_POSITION): void { 1612 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_})`, 1613 `notifyContainerLayoutChange(${changeIndex}, ${changeCount}, ${NotificationType[notificationType]})`); 1614 1615 const arrLen = this.onLazyLoadingFunc_ ? this.totalCount() : this.arr_.length; 1616 // triggers FrameNode::NotifyChange in CPP side 1617 RepeatVirtualScroll2Native.notifyContainerLayoutChange(this.repeatElmtId_, arrLen, this.totalCount(), 1618 changeIndex, changeCount, notificationType); 1619 } 1620 1621 private requestContainerReLayout(changeIndex?: number): void { 1622 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) requestContainerReLayout`, changeIndex); 1623 1624 const arrLen = this.onLazyLoadingFunc_ ? this.totalCount() : this.arr_.length; 1625 // trigger MarkNeedSyncRenderTree, MarkNeedFrameFlushDirty in CPP side 1626 RepeatVirtualScroll2Native.requestContainerReLayout(this.repeatElmtId_, arrLen, this.totalCount(), changeIndex); 1627 } 1628 1629 private onPurge(): void { 1630 stateMgmtConsole.debug(`${this.constructor.name}(${this.repeatElmtId_}) purge(), totalCount: `, 1631 `${this.totalCount()} - start`); 1632 1633 // deep copy templateOptions_ 1634 let availableCachedCount: { [ttype: string]: number } = {}; 1635 Object.entries(this.templateOptions_).forEach((pair) => { 1636 availableCachedCount[pair[0]] = pair[1].cachedCount as number; 1637 }); 1638 1639 // Improvement needed: 1640 // this is a simplistic purge is more or less randomly purges 1641 // extra nodes 1642 // avoid delete on iterated Set, copy into Array 1643 const spareRid1 : Array<number> = Array.from(this.spareRid_); 1644 for (const rid of spareRid1) { 1645 const ttype: string = this.meta4Rid_.get(rid).ttype_; 1646 if (availableCachedCount[ttype] === 0) { 1647 // purge rid 1648 this.purgeNode(rid); 1649 } else { 1650 availableCachedCount[ttype] -= 1; 1651 } 1652 } 1653 stateMgmtConsole.debug(`onPurge after applying changes: \n${this.dumpSpareRid()}\n${this.dumpDataItems()}`); 1654 } 1655 1656 private purgeNode(rid: number): void { 1657 stateMgmtConsole.debug(`delete node rid: ${rid}.`); 1658 this.meta4Rid_.delete(rid); 1659 this.spareRid_.delete(rid); 1660 RepeatVirtualScroll2Native.removeNode(rid); 1661 } 1662 1663 private dumpSpareRid(): string { 1664 return `spareRid size: ${this.spareRid_.size} ` + 1665 `${JSON.stringify(Array.from(this.spareRid_).map(rid => `rid: ${rid}`))}.`; 1666 } 1667 1668 private dumpRepeatItem4Rid(): string { 1669 return `meta4Rid_ size: ${this.meta4Rid_.size}: ${JSON.stringify(Array.from(this.meta4Rid_))}.`; 1670 } 1671 1672 private dumpDataItems(): string { 1673 let result = ``; 1674 let sepa = ''; 1675 let count = 0; 1676 for (const index in this.activeDataItems_) { 1677 const dataItemDump = this.activeDataItems_[index].dump(); 1678 const repeatItemIndex = this.activeDataItems_[index].rid 1679 ? this.meta4Rid_.get(this.activeDataItems_[index].rid)?.repeatItem_?.index 1680 : 'N/A'; 1681 result += `${sepa}index ${index}, ${dataItemDump} (repeatItemIndex ${repeatItemIndex})`; 1682 sepa = ', \n'; 1683 count += 1; 1684 } 1685 return `activeDataItems(array): length: ${this.activeDataItems_.length}, ` + 1686 `range: [${this.activeRange_[0]}-${this.activeRange_[1]}], ` + 1687 `entries count: ${count} =============\n${result}`; 1688 } 1689 1690 private dumpCachedCount(): string { 1691 let result = ''; 1692 let sepa = ''; 1693 Object.entries(this.templateOptions_).forEach((pair) => { 1694 const options: RepeatTemplateImplOptions = pair[1]; 1695 result += `${sepa}'template ${pair[0]}': specified: ${options.cachedCountSpecified} ` + 1696 `cachedCount: ${options.cachedCount}: `; 1697 sepa = ', '; 1698 }); 1699 return result; 1700 } 1701 1702 private dumpKeys(): string { 1703 let result = ''; 1704 let sepa = ''; 1705 this.key4Index_.forEach((key, index) => { 1706 result += `${sepa}${index}: ${key}`; 1707 sepa = '\n'; 1708 }); 1709 return result; 1710 } 1711};