Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/__tests__/MasonryLayoutManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,78 @@ describe("MasonryLayoutManager", () => {
});
});

describe("Visible Layouts in Masonry", () => {
it("should find all visible items when y-positions are not sorted by index", () => {
// With optimizeItemArrangement=false and 2 columns, items are placed
// sequentially: item 0 in col 0, item 1 in col 1, item 2 in col 0, etc.
// When column heights diverge, y-positions are NOT sorted by index,
// which broke the binary search in the base class.
const manager = createLayoutManager(LayoutManagerType.MASONRY, {
...defaultParams,
optimizeItemArrangement: false,
});

// Create items with varying heights to create column height divergence
// Col 0: items 0 (h=300), 2 (h=100), 4 (h=100) -> y: 0, 300, 400
// Col 1: items 1 (h=50), 3 (h=50), 5 (h=50) -> y: 0, 50, 100
const layoutInfos = [
createMockLayoutInfo(0, 200, 300), // Col 0, y=0
createMockLayoutInfo(1, 200, 50), // Col 1, y=0
createMockLayoutInfo(2, 200, 100), // Col 0, y=300
createMockLayoutInfo(3, 200, 50), // Col 1, y=50
createMockLayoutInfo(4, 200, 100), // Col 0, y=400
createMockLayoutInfo(5, 200, 50), // Col 1, y=100
];
manager.modifyLayout(layoutInfos, 6);
const layouts = getAllLayouts(manager);

// Verify the unsorted y-positions by index
// Items by index: y = [0, 0, 300, 50, 400, 100]
expect(layouts[0].y).toBe(0);
expect(layouts[1].y).toBe(0);
expect(layouts[2].y).toBe(300);
expect(layouts[3].y).toBe(50);
expect(layouts[4].y).toBe(400);
expect(layouts[5].y).toBe(100);

// Viewport from y=40 to y=110 should include items at:
// - Item 1: y=0, h=50 -> ends at 50 > 40 ✓, starts at 0 < 110 ✓
// - Item 3: y=50, h=50 -> ends at 100 > 40 ✓, starts at 50 < 110 ✓
// - Item 5: y=100, h=50 -> ends at 150 > 40 ✓, starts at 100 < 110 ✓
const visible = manager.getVisibleLayouts(40, 110);
// Must include items 1, 3, and 5 (all in viewport)
expect(visible.includes(1)).toBe(true);
expect(visible.includes(3)).toBe(true);
expect(visible.includes(5)).toBe(true);
});

it("should handle viewport that only sees one column", () => {
const manager = createLayoutManager(LayoutManagerType.MASONRY, {
...defaultParams,
optimizeItemArrangement: false,
});

// Col 0: item 0 (h=50), item 2 (h=50) -> y: 0, 50
// Col 1: item 1 (h=300), item 3 (h=50) -> y: 0, 300
const layoutInfos = [
createMockLayoutInfo(0, 200, 50), // Col 0, y=0
createMockLayoutInfo(1, 200, 300), // Col 1, y=0
createMockLayoutInfo(2, 200, 50), // Col 0, y=50
createMockLayoutInfo(3, 200, 50), // Col 1, y=300
];
manager.modifyLayout(layoutInfos, 4);

// Viewport from y=60 to y=120: only item 1 (y=0, h=300) is visible
// Item 0: y=0, h=50, ends at 50 < 60 -> NOT visible
// Item 1: y=0, h=300, ends at 300 > 60, starts at 0 < 120 -> visible
// Item 2: y=50, h=50, ends at 100 > 60, starts at 50 < 120 -> visible
// Item 3: y=300, h=50, starts at 300 > 120 -> NOT visible
const visible = manager.getVisibleLayouts(60, 120);
expect(visible.includes(1)).toBe(true);
expect(visible.includes(2)).toBe(true);
});
});

describe("Empty Layout", () => {
it("should return zero size for empty layout", () => {
const manager = createLayoutManager(
Expand Down
96 changes: 96 additions & 0 deletions src/recyclerview/layout-managers/MasonryLayoutManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ConsecutiveNumbers } from "../helpers/ConsecutiveNumbers";

import {
LayoutParams,
RVDimension,
Expand All @@ -18,6 +20,8 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager {
private columnHeights: number[];
/** Current column index for sequential placement */
private currentColumn = 0;
/** Per-column sorted lists of item indices for efficient visibility detection */
private columnItems: number[][] = [];

/** If there's a span change for masonry layout, we need to recompute all the widths */
private fullRelayoutRequired = false;
Expand Down Expand Up @@ -155,6 +159,8 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager {
this.placeItemSequentially(layout, span);
}
}
// Rebuild per-column item mappings for efficient visibility detection
this.rebuildColumnItems();
}

/**
Expand Down Expand Up @@ -321,6 +327,96 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager {
}
}

/**
* Rebuilds per-column item index arrays from layout positions.
* Within each column, items are sorted by y-position (guaranteed by
* placement order since column heights only increase).
*/
private rebuildColumnItems(): void {
const columnWidth = this.boundedSize / this.maxColumns;
this.columnItems = Array.from({ length: this.maxColumns }, () => []);
for (let i = 0; i < this.layouts.length; i++) {
const layout = this.layouts[i];
const col = Math.min(
Math.round(layout.x / columnWidth),
this.maxColumns - 1
);
this.columnItems[col].push(i);
}
}

/**
* Override getVisibleLayouts to use per-column binary search instead of
* a single binary search on the full layouts array. In masonry layout,
* y-positions are NOT sorted by index (items are placed in columns), but
* they ARE sorted within each column. Binary searching each column gives
* O(numColumns * log(n/numColumns)) performance.
*/
Comment on lines +348 to +354
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title/description says masonry visibility detection is fixed by “avoiding binary search” / doing a “linear scan”, but this implementation still uses binary search (per-column). Either update the PR description/title to match what’s implemented, or adjust the implementation/inline comment so they’re consistent.

Copilot uses AI. Check for mistakes.
getVisibleLayouts(
unboundDimensionStart: number,
unboundDimensionEnd: number
): ConsecutiveNumbers {
if (this.layouts.length === 0) {
return ConsecutiveNumbers.EMPTY;
}

let firstVisibleIndex = -1;
let lastVisibleIndex = -1;

for (const items of this.columnItems) {
if (items.length === 0) continue;
Comment on lines +355 to +367
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getVisibleLayouts relies on this.columnItems, but columnItems is only rebuilt in recomputeLayouts(). When a RVMasonryLayoutManagerImpl is constructed with a previousLayoutManager (see RecyclerViewManager.updateLayoutParams passing the old manager into the new one), this.layouts can be non-empty while columnItems is still [], causing this method to incorrectly return EMPTY and potentially render nothing. Consider lazily calling rebuildColumnItems() when columnItems.length !== this.maxColumns (or when the total count across columns doesn’t match this.layouts.length), and/or rebuilding once in the constructor when this.layouts.length > 0.

Copilot uses AI. Check for mistakes.

// Binary search for first visible item in this column:
// find first item where y + height > unboundDimensionStart
let lo = 0;
let hi = items.length - 1;
let colFirst = -1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const layout = this.layouts[items[mid]];
if (layout.y + layout.height > unboundDimensionStart) {
colFirst = mid;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
if (colFirst === -1) continue;

// Binary search for last visible item in this column:
// find last item where y < unboundDimensionEnd
lo = colFirst;
hi = items.length - 1;
let colLast = -1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const layout = this.layouts[items[mid]];
if (layout.y < unboundDimensionEnd) {
colLast = mid;
lo = mid + 1;
} else {
hi = mid - 1;
Comment on lines +392 to +398
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visibility boundary semantics here differ from the base RVLayoutManager implementation (findLastVisibleIndex treats position <= unboundDimensionEnd as visible). Using layout.y < unboundDimensionEnd may exclude items that start exactly at unboundDimensionEnd, potentially changing engaged/visible index behavior compared to other layout managers. Align this comparison with the base implementation to keep consistent edge handling across layout managers.

Copilot uses AI. Check for mistakes.
}
}
if (colLast === -1) continue;

// Update global min/max with item indices from this column
const colFirstIndex = items[colFirst];
const colLastIndex = items[colLast];
if (firstVisibleIndex === -1 || colFirstIndex < firstVisibleIndex) {
firstVisibleIndex = colFirstIndex;
}
if (lastVisibleIndex === -1 || colLastIndex > lastVisibleIndex) {
lastVisibleIndex = colLastIndex;
}
}

if (firstVisibleIndex !== -1 && lastVisibleIndex !== -1) {
return new ConsecutiveNumbers(firstVisibleIndex, lastVisibleIndex);
}
return ConsecutiveNumbers.EMPTY;
}

// TODO: For masonry, the "last row" is the last item in each column.
// Override isInLastRow if ItemSeparatorComponent support is needed
// for masonry layouts.
Expand Down
Loading