diff --git a/src/__tests__/LinearLayoutManager.test.ts b/src/__tests__/LinearLayoutManager.test.ts index 53b70cbb8..01756b8d5 100644 --- a/src/__tests__/LinearLayoutManager.test.ts +++ b/src/__tests__/LinearLayoutManager.test.ts @@ -288,4 +288,63 @@ describe("LinearLayoutManager", () => { expect(getAllLayouts(manager).length).toBe(3); }); }); + describe("Tail sync after a partial recompute", () => { + // A recompute pass is capped at maxItemsToProcess items. When the items it + // touched end up positioned past the (untouched) last item, the layout + // manager has to recompute the tail as well, otherwise the reported content + // size is stale and the tail overlaps the items that were just moved. + const itemCount = 300; + const measuredCount = 25; + const measuredSize = 1000; + + it("recomputes the tail of a vertical list", () => { + const manager = createLayoutManager( + LayoutManagerType.LINEAR, + defaultParams + ); + manager.modifyLayout([], itemCount); + + manager.modifyLayout( + Array.from({ length: measuredCount }, (_, index) => + createMockLayoutInfo(index, 400, measuredSize) + ), + itemCount + ); + + const layouts = getAllLayouts(manager); + for (let index = 1; index < itemCount; index++) { + expect(layouts[index].y).toBeGreaterThanOrEqual(layouts[index - 1].y); + } + const lastLayout = layouts[itemCount - 1]; + expect(manager.getLayoutSize().height).toBe( + lastLayout.y + lastLayout.height + ); + }); + + it("recomputes the tail of a horizontal list", () => { + const manager = createLayoutManager( + LayoutManagerType.LINEAR, + horizontalParams + ); + manager.modifyLayout([], itemCount); + + manager.modifyLayout( + Array.from({ length: measuredCount }, (_, index) => + createMockLayoutInfo(index, measuredSize, 300) + ), + itemCount + ); + + const layouts = getAllLayouts(manager); + // Every item must still start at or after the previous one - a stale tail + // shows up here as item n sitting on top of much earlier items. + for (let index = 1; index < itemCount; index++) { + expect(layouts[index].x).toBeGreaterThanOrEqual(layouts[index - 1].x); + } + const lastLayout = layouts[itemCount - 1]; + expect(manager.getLayoutSize().width).toBe( + lastLayout.x + lastLayout.width + ); + }); + }); }); diff --git a/src/recyclerview/layout-managers/LayoutManager.ts b/src/recyclerview/layout-managers/LayoutManager.ts index fc73c9148..7a11f117a 100644 --- a/src/recyclerview/layout-managers/LayoutManager.ts +++ b/src/recyclerview/layout-managers/LayoutManager.ts @@ -359,15 +359,27 @@ export abstract class RVLayoutManager { this.lastSkippedLayoutIndex ); const lastIndex = this.layouts.length - 1; - // Since layout managers derive height from last indices we need to make - // sure they're not too much out of sync. - if (this.layouts[lastIndex].y < this.layouts[endIndex].y) { + // Since layout managers derive the content size from the last indices we + // need to make sure they're not too much out of sync. The position that + // grows along the list is x for horizontal layouts and y otherwise. + const lastPosition = this.getScrollAxisPosition(this.layouts[lastIndex]); + const endPosition = this.getScrollAxisPosition(this.layouts[endIndex]); + if (lastPosition < endPosition) { this.recomputeLayouts(this.lastSkippedLayoutIndex, lastIndex); this.lastSkippedLayoutIndex = Number.MAX_VALUE; } } } + /** + * Returns the position of a layout along the scroll axis. + * @param layout Layout to read the position from + * @returns x for horizontal layouts, y otherwise + */ + private getScrollAxisPosition(layout: RVLayout): number { + return this.horizontal ? layout.x : layout.y; + } + /** * Computes size estimates and finds the minimum recompute index. * @param layoutInfo Array of layout information for items