diff --git a/src/__tests__/MasonryLayoutManager.test.ts b/src/__tests__/MasonryLayoutManager.test.ts index d5f6d4a0a..8667cfd84 100644 --- a/src/__tests__/MasonryLayoutManager.test.ts +++ b/src/__tests__/MasonryLayoutManager.test.ts @@ -199,4 +199,98 @@ describe("MasonryLayoutManager", () => { expect(getAllLayouts(manager).length).toBe(0); }); }); + + describe("Visibility with uneven column heights (optimizeItemArrangement: false)", () => { + const unevenParams = { + windowSize, + maxColumns: 2, + optimizeItemArrangement: false, + }; + + // Creates a masonry layout with different column heights. + // Sequential placement: item 0->col0, item 1->col1, item 2->col0, ... + // Col 0 items have height 100, col 1 items have height 50. + // Col 0: items 0,2,4,6 at y=0,100,200,300 + // Col 1: items 1,3,5,7 at y=0,50,100,150 + const createUnevenMasonryLayout = () => { + const manager = createLayoutManager( + LayoutManagerType.MASONRY, + unevenParams + ); + const layoutInfos = [ + createMockLayoutInfo(0, 200, 100), + createMockLayoutInfo(1, 200, 50), + createMockLayoutInfo(2, 200, 100), + createMockLayoutInfo(3, 200, 50), + createMockLayoutInfo(4, 200, 100), + createMockLayoutInfo(5, 200, 50), + createMockLayoutInfo(6, 200, 100), + createMockLayoutInfo(7, 200, 50), + ]; + manager.modifyLayout(layoutInfos, 8); + return manager; + }; + + it("should find items visible at the top of the layout", () => { + const manager = createUnevenMasonryLayout(); + + const visible = manager.getVisibleLayouts(0, 100); + + expect(visible.includes(0)).toBe(true); + expect(visible.includes(1)).toBe(true); + expect(visible.includes(3)).toBe(true); + expect(visible.includes(6)).toBe(false); + }); + + it("should find visible items when viewport is in the middle (column heights diverge)", () => { + const manager = createUnevenMasonryLayout(); + + const visible = manager.getVisibleLayouts(100, 200); + + expect(visible.includes(2)).toBe(true); + expect(visible.includes(5)).toBe(true); + expect(visible.includes(7)).toBe(true); + expect(visible.includes(0)).toBe(false); + expect(visible.includes(1)).toBe(false); + }); + + it("should return empty for viewport beyond all content", () => { + const manager = createUnevenMasonryLayout(); + + const visible = manager.getVisibleLayouts(500, 600); + + expect(visible.length).toBe(0); + }); + }); + + describe("Visibility with optimizeItemArrangement: true", () => { + it("should find visible items correctly with optimized placement", () => { + const manager = createLayoutManager( + LayoutManagerType.MASONRY, + defaultParams + ); + // Items placed in shortest column: + // Item 0 -> col 0, y=0, h=200 + // Item 1 -> col 1, y=0, h=50 + // Item 2 -> col 1, y=50, h=50 (col 1 is shorter) + // Item 3 -> col 1, y=100, h=50 (col 1 is still shorter) + // Item 4 -> col 1, y=150, h=50 (col 1 at 150, col 0 at 200) + const layoutInfos = [ + createMockLayoutInfo(0, 200, 200), + createMockLayoutInfo(1, 200, 50), + createMockLayoutInfo(2, 200, 50), + createMockLayoutInfo(3, 200, 50), + createMockLayoutInfo(4, 200, 50), + ]; + manager.modifyLayout(layoutInfos, 5); + + // Viewport from y=0 to y=100: should include items at y=0 (items 0, 1) + // and items that end after y=0 and start before y=100 (items 2, 3) + const visible = manager.getVisibleLayouts(0, 100); + expect(visible.includes(0)).toBe(true); + expect(visible.includes(1)).toBe(true); + expect(visible.includes(2)).toBe(true); + expect(visible.includes(3)).toBe(true); + }); + }); }); diff --git a/src/recyclerview/layout-managers/MasonryLayoutManager.ts b/src/recyclerview/layout-managers/MasonryLayoutManager.ts index 5ba0b5f80..3d20f8813 100644 --- a/src/recyclerview/layout-managers/MasonryLayoutManager.ts +++ b/src/recyclerview/layout-managers/MasonryLayoutManager.ts @@ -5,6 +5,7 @@ import { RVLayoutInfo, RVLayoutManager, } from "./LayoutManager"; +import { ConsecutiveNumbers } from "../helpers/ConsecutiveNumbers"; /** * MasonryLayoutManager implementation that arranges items in a masonry/pinterest-style layout. @@ -321,6 +322,145 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager { } } + /** + * Overrides getVisibleLayouts to handle masonry layouts where item + * y-positions are not monotonically sorted by index. Instead of a + * single binary search over all items, we perform a binary search + * per column. Within each column, items are naturally sorted by y, + * so binary search works correctly. + * + * @param unboundDimensionStart Start of the viewport + * @param unboundDimensionEnd End of the viewport + * @returns ConsecutiveNumbers containing visible indices + */ + getVisibleLayouts( + unboundDimensionStart: number, + unboundDimensionEnd: number + ): ConsecutiveNumbers { + if (this.layouts.length === 0) { + return ConsecutiveNumbers.EMPTY; + } + + let minIdx = Infinity; + let maxIdx = -1; + + for (let col = 0; col < this.maxColumns; col++) { + const first = this.binarySearchColumn( + col, + unboundDimensionStart, + true + ); + const last = this.binarySearchColumn(col, unboundDimensionEnd, false); + if (first !== -1) minIdx = Math.min(minIdx, first); + if (last !== -1) maxIdx = Math.max(maxIdx, last); + } + + return minIdx > maxIdx + ? ConsecutiveNumbers.EMPTY + : new ConsecutiveNumbers(minIdx, maxIdx); + } + + /** + * Performs binary search within a single column of the masonry layout. + * Items in each column are sorted by y-position, enabling correct binary search. + * + * For sequential placement (optimizeItemArrangement=false), item i belongs + * to column i % maxColumns, allowing O(log(N/maxColumns)) lookup without + * extra memory. For optimized placement, we determine column from x-position. + * + * @param col Column index to search in + * @param threshold The viewport boundary to search for + * @param findFirst If true, find first visible; if false, find last visible + * @returns The original index of the found item, or -1 + */ + private binarySearchColumn( + col: number, + threshold: number, + findFirst: boolean + ): number { + const mc = this.maxColumns; + const columnWidth = this.boundedSize / mc; + + if (!this.optimizeItemArrangement) { + // Sequential placement: item i belongs to column i % maxColumns. + // Items in this column: col, col+mc, col+2*mc, ... + const count = Math.ceil((this.layouts.length - col) / mc); + if (count <= 0) return -1; + + let left = 0; + let right = count - 1; + let result = -1; + + while (left <= right) { + const mid = (left + right) >> 1; + const idx = col + mid * mc; + const layout = this.layouts[idx]; + const pos = this.horizontal ? layout.x : layout.y; + const size = this.horizontal ? layout.width : layout.height; + + if (findFirst) { + if (pos >= threshold || pos + size > threshold) { + result = idx; + right = mid - 1; + } else { + left = mid + 1; + } + } else { + if (pos <= threshold) { + result = idx; + left = mid + 1; + } else { + right = mid - 1; + } + } + } + + return result; + } + + // Optimized placement: determine column from x-position. + // Collect indices for this column, then binary search. + const columnIndices: number[] = []; + for (let i = 0; i < this.layouts.length; i++) { + const layoutCol = Math.round(this.layouts[i].x / columnWidth); + if (layoutCol === col) { + columnIndices.push(i); + } + } + + if (columnIndices.length === 0) return -1; + + let left = 0; + let right = columnIndices.length - 1; + let result = -1; + + while (left <= right) { + const mid = (left + right) >> 1; + const idx = columnIndices[mid]; + const layout = this.layouts[idx]; + const pos = this.horizontal ? layout.x : layout.y; + const size = this.horizontal ? layout.width : layout.height; + + if (findFirst) { + if (pos >= threshold || pos + size > threshold) { + result = idx; + right = mid - 1; + } else { + left = mid + 1; + } + } else { + if (pos <= threshold) { + result = idx; + left = mid + 1; + } else { + right = mid - 1; + } + } + } + + return result; + } + // TODO: For masonry, the "last row" is the last item in each column. // Override isInLastRow if ItemSeparatorComponent support is needed // for masonry layouts.