diff --git a/src/__tests__/MasonryLayoutManager.test.ts b/src/__tests__/MasonryLayoutManager.test.ts index d5f6d4a0a..d47d5292b 100644 --- a/src/__tests__/MasonryLayoutManager.test.ts +++ b/src/__tests__/MasonryLayoutManager.test.ts @@ -1,5 +1,6 @@ import { RVLayoutManager } from "../recyclerview/layout-managers/LayoutManager"; import { RVMasonryLayoutManagerImpl } from "../recyclerview/layout-managers/MasonryLayoutManager"; +import { ConsecutiveNumbers } from "../recyclerview/helpers/ConsecutiveNumbers"; import { getAllLayouts, @@ -186,6 +187,49 @@ describe("MasonryLayoutManager", () => { }); }); + describe("Item Column Locking", () => { + it("should keep locked items in their assigned columns when height changes", () => { + const manager = createLayoutManager(LayoutManagerType.MASONRY, defaultParams); + manager.modifyLayout([ + createMockLayoutInfo(0, 200, 100), + createMockLayoutInfo(1, 200, 150), + createMockLayoutInfo(2, 200, 120), // Col 0 + ], 3); + + manager.onVisibleIndicesChanged(new ConsecutiveNumbers(0, 2)); + manager.modifyLayout([ + createMockLayoutInfo(0, 200, 100), + createMockLayoutInfo(1, 200, 150), + createMockLayoutInfo(2, 200, 300), // much taller, still stays in Col 0 + ], 3); + expect(getAllLayouts(manager)[2].x).toBe(0); + }); + + it("should allow unlocked items to reposition into optimal columns", () => { + const manager = createLayoutManager(LayoutManagerType.MASONRY, defaultParams); + manager.modifyLayout([ + createMockLayoutInfo(0, 200, 100), + createMockLayoutInfo(1, 200, 150), + createMockLayoutInfo(2, 200, 120), + createMockLayoutInfo(3, 200, 80), + ], 4); + + // Only lock items 0-1, items 2-3 remain free to move + manager.onVisibleIndicesChanged(new ConsecutiveNumbers(0, 1)); + manager.modifyLayout([ + createMockLayoutInfo(0, 200, 100), + createMockLayoutInfo(1, 200, 150), + createMockLayoutInfo(2, 200, 120), + createMockLayoutInfo(3, 200, 80), + ], 4); + + const layouts = getAllLayouts(manager); + expect(layouts[0].x).toBe(0); + expect(layouts[1].x).toBe(200); + expect(layouts[2].x).toBe(0); // shortest column + expect(layouts[3].x).toBe(200); + }); + describe("Empty Layout", () => { it("should return zero size for empty layout", () => { const manager = createLayoutManager( diff --git a/src/recyclerview/RecyclerViewManager.ts b/src/recyclerview/RecyclerViewManager.ts index ede5f4117..c0687ebb4 100644 --- a/src/recyclerview/RecyclerViewManager.ts +++ b/src/recyclerview/RecyclerViewManager.ts @@ -119,6 +119,7 @@ export class RecyclerViewManager { if (engagedIndices) { this.updateRenderStack(engagedIndices); + this.layoutManager.onEngagedIndicesChanged(engagedIndices); return engagedIndices; } } diff --git a/src/recyclerview/layout-managers/LayoutManager.ts b/src/recyclerview/layout-managers/LayoutManager.ts index ffecb96e5..10b6a7404 100644 --- a/src/recyclerview/layout-managers/LayoutManager.ts +++ b/src/recyclerview/layout-managers/LayoutManager.ts @@ -264,6 +264,13 @@ export abstract class RVLayoutManager { return this.layouts.length; } + /** + * Called when engaged indices change. Subclasses can override + * to update internal state that depends on engaged items. + * @param indices The current engaged indices + */ + onEngagedIndicesChanged(_indices: ConsecutiveNumbers): void {} + /** * Abstract method to recompute layouts for items in the given range. * @param startIndex Starting index of items to recompute diff --git a/src/recyclerview/layout-managers/MasonryLayoutManager.ts b/src/recyclerview/layout-managers/MasonryLayoutManager.ts index c0b41b664..80a02dcd3 100644 --- a/src/recyclerview/layout-managers/MasonryLayoutManager.ts +++ b/src/recyclerview/layout-managers/MasonryLayoutManager.ts @@ -1,3 +1,4 @@ +import { ConsecutiveNumbers } from "../helpers/ConsecutiveNumbers"; import { LayoutParams, RVDimension, @@ -22,6 +23,9 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager { /** If there's a span change for masonry layout, we need to recompute all the widths */ private fullRelayoutRequired = false; + /** Last engaged end index — items at or before this keep their column assignment */ + private lastLockedItemIndex = -1; + constructor(params: LayoutParams, previousLayoutManager?: RVLayoutManager) { super(params, previousLayoutManager); this.boundedSize = params.windowSize.width; @@ -46,6 +50,9 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager { if (this.layouts.length > 0) { // console.log("-----> recomputeLayouts"); + // Full relayout — unlock all items so they can reflow across columns + this.lastLockedItemIndex = -1; + // update all widths this.updateAllWidths(); this.recomputeLayouts(0, this.layouts.length - 1); @@ -72,6 +79,7 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager { // TODO: Can be optimized if (this.fullRelayoutRequired) { + this.lastLockedItemIndex = -1; this.updateAllWidths(); this.fullRelayoutRequired = false; return 0; @@ -102,6 +110,16 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager { this.fullRelayoutRequired = true; } + /** + * Updates the locked item boundary from engaged indices. + * Items at or before the last engaged index keep their column assignment. + */ + onEngagedIndicesChanged(indices: ConsecutiveNumbers): void { + if (this.optimizeItemArrangement) { + this.lastLockedItemIndex = indices.endIndex; + } + } + /** * Returns the total size of the layout area. * @returns RVDimension containing width and height of the layout @@ -143,7 +161,11 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager { const span = this.getSpan(i, true); if (this.optimizeItemArrangement) { - if (span === 1) { + const isItemLocked = i <= this.lastLockedItemIndex; + + if (isItemLocked && layout.isHeightMeasured) { + this.placeInAssignedColumn(layout, span); + } else if (span === 1) { // For single column items, place in the shortest column this.placeSingleColumnItem(layout); } else { @@ -239,6 +261,32 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager { this.columnHeights[shortestColumnIndex] += layout.height; } + /** + * Places an item in its previously assigned column(s). + * @param layout Layout information for the item + * @param span Number of columns the item spans + */ + private placeInAssignedColumn(layout: RVLayout, span: number): void { + const columnWidth = this.boundedSize / this.maxColumns; + const startColumn = Math.min( + this.maxColumns - span, + Math.max(0, Math.round(layout.x / columnWidth)) + ); + + let maxHeight = this.columnHeights[startColumn]; + // Find the highest column (when span > 1) + for (let col = startColumn + 1; col < startColumn + span; col++) { + maxHeight = Math.max(maxHeight, this.columnHeights[col]); + } + + layout.x = columnWidth * startColumn; + layout.y = maxHeight; + + for (let col = startColumn; col < startColumn + span; col++) { + this.columnHeights[col] = maxHeight + layout.height; + } + } + /** * Places a multi-column item in the position that minimizes total column heights. * @param layout Layout information for the item