Skip to content

Commit 20c935c

Browse files
fix(layout): use per-column binary search for masonry visibility detection
Replace O(n) linear scan with per-column binary search for O(numColumns * log(n/numColumns)) performance. Within each masonry column, y-positions are monotonically sorted, so binary search is valid. Column-to-item mappings are built during recomputeLayouts (which already iterates all items) so the visibility hot path stays fast during scrolling. Co-authored-by: Talha Naqvi <naqvitalha@users.noreply.github.com>
1 parent 9c10766 commit 20c935c

2 files changed

Lines changed: 83 additions & 23 deletions

File tree

src/__tests__/MasonryLayoutManager.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,11 @@ describe("MasonryLayoutManager", () => {
202202
// Col 1: items 1 (h=50), 3 (h=50), 5 (h=50) -> y: 0, 50, 100
203203
const layoutInfos = [
204204
createMockLayoutInfo(0, 200, 300), // Col 0, y=0
205-
createMockLayoutInfo(1, 200, 50), // Col 1, y=0
205+
createMockLayoutInfo(1, 200, 50), // Col 1, y=0
206206
createMockLayoutInfo(2, 200, 100), // Col 0, y=300
207-
createMockLayoutInfo(3, 200, 50), // Col 1, y=50
207+
createMockLayoutInfo(3, 200, 50), // Col 1, y=50
208208
createMockLayoutInfo(4, 200, 100), // Col 0, y=400
209-
createMockLayoutInfo(5, 200, 50), // Col 1, y=100
209+
createMockLayoutInfo(5, 200, 50), // Col 1, y=100
210210
];
211211
manager.modifyLayout(layoutInfos, 6);
212212
const layouts = getAllLayouts(manager);
@@ -240,10 +240,10 @@ describe("MasonryLayoutManager", () => {
240240
// Col 0: item 0 (h=50), item 2 (h=50) -> y: 0, 50
241241
// Col 1: item 1 (h=300), item 3 (h=50) -> y: 0, 300
242242
const layoutInfos = [
243-
createMockLayoutInfo(0, 200, 50), // Col 0, y=0
243+
createMockLayoutInfo(0, 200, 50), // Col 0, y=0
244244
createMockLayoutInfo(1, 200, 300), // Col 1, y=0
245-
createMockLayoutInfo(2, 200, 50), // Col 0, y=50
246-
createMockLayoutInfo(3, 200, 50), // Col 1, y=300
245+
createMockLayoutInfo(2, 200, 50), // Col 0, y=50
246+
createMockLayoutInfo(3, 200, 50), // Col 1, y=300
247247
];
248248
manager.modifyLayout(layoutInfos, 4);
249249

src/recyclerview/layout-managers/MasonryLayoutManager.ts

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import { ConsecutiveNumbers } from "../helpers/ConsecutiveNumbers";
2+
13
import {
24
LayoutParams,
35
RVDimension,
46
RVLayout,
57
RVLayoutInfo,
68
RVLayoutManager,
79
} from "./LayoutManager";
8-
import { ConsecutiveNumbers } from "../helpers/ConsecutiveNumbers";
910

1011
/**
1112
* MasonryLayoutManager implementation that arranges items in a masonry/pinterest-style layout.
@@ -19,6 +20,8 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager {
1920
private columnHeights: number[];
2021
/** Current column index for sequential placement */
2122
private currentColumn = 0;
23+
/** Per-column sorted lists of item indices for efficient visibility detection */
24+
private columnItems: number[][] = [];
2225

2326
/** If there's a span change for masonry layout, we need to recompute all the widths */
2427
private fullRelayoutRequired = false;
@@ -156,6 +159,8 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager {
156159
this.placeItemSequentially(layout, span);
157160
}
158161
}
162+
// Rebuild per-column item mappings for efficient visibility detection
163+
this.rebuildColumnItems();
159164
}
160165

161166
/**
@@ -323,31 +328,86 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager {
323328
}
324329

325330
/**
326-
* Override getVisibleLayouts to avoid using binary search on the full
327-
* layouts array. In masonry layout, item y-positions are NOT sorted by
328-
* index (items are placed in columns), so binary search produces wrong
329-
* results causing items to disappear. Instead, we scan each column
330-
* independently where y-positions ARE sorted, and combine the results.
331+
* Rebuilds per-column item index arrays from layout positions.
332+
* Within each column, items are sorted by y-position (guaranteed by
333+
* placement order since column heights only increase).
334+
*/
335+
private rebuildColumnItems(): void {
336+
const columnWidth = this.boundedSize / this.maxColumns;
337+
this.columnItems = Array.from({ length: this.maxColumns }, () => []);
338+
for (let i = 0; i < this.layouts.length; i++) {
339+
const layout = this.layouts[i];
340+
const col = Math.min(
341+
Math.round(layout.x / columnWidth),
342+
this.maxColumns - 1
343+
);
344+
this.columnItems[col].push(i);
345+
}
346+
}
347+
348+
/**
349+
* Override getVisibleLayouts to use per-column binary search instead of
350+
* a single binary search on the full layouts array. In masonry layout,
351+
* y-positions are NOT sorted by index (items are placed in columns), but
352+
* they ARE sorted within each column. Binary searching each column gives
353+
* O(numColumns * log(n/numColumns)) performance.
331354
*/
332355
getVisibleLayouts(
333356
unboundDimensionStart: number,
334357
unboundDimensionEnd: number
335358
): ConsecutiveNumbers {
359+
if (this.layouts.length === 0) {
360+
return ConsecutiveNumbers.EMPTY;
361+
}
362+
336363
let firstVisibleIndex = -1;
337364
let lastVisibleIndex = -1;
338-
const layoutCount = this.layouts.length;
339-
340-
for (let i = 0; i < layoutCount; i++) {
341-
const layout = this.layouts[i];
342-
const position = layout.y;
343-
const size = layout.height;
344365

345-
// Item is visible if it overlaps with the viewport
346-
if (position + size > unboundDimensionStart && position < unboundDimensionEnd) {
347-
if (firstVisibleIndex === -1) {
348-
firstVisibleIndex = i;
366+
for (const items of this.columnItems) {
367+
if (items.length === 0) continue;
368+
369+
// Binary search for first visible item in this column:
370+
// find first item where y + height > unboundDimensionStart
371+
let lo = 0;
372+
let hi = items.length - 1;
373+
let colFirst = -1;
374+
while (lo <= hi) {
375+
const mid = (lo + hi) >>> 1;
376+
const layout = this.layouts[items[mid]];
377+
if (layout.y + layout.height > unboundDimensionStart) {
378+
colFirst = mid;
379+
hi = mid - 1;
380+
} else {
381+
lo = mid + 1;
382+
}
383+
}
384+
if (colFirst === -1) continue;
385+
386+
// Binary search for last visible item in this column:
387+
// find last item where y < unboundDimensionEnd
388+
lo = colFirst;
389+
hi = items.length - 1;
390+
let colLast = -1;
391+
while (lo <= hi) {
392+
const mid = (lo + hi) >>> 1;
393+
const layout = this.layouts[items[mid]];
394+
if (layout.y < unboundDimensionEnd) {
395+
colLast = mid;
396+
lo = mid + 1;
397+
} else {
398+
hi = mid - 1;
349399
}
350-
lastVisibleIndex = i;
400+
}
401+
if (colLast === -1) continue;
402+
403+
// Update global min/max with item indices from this column
404+
const colFirstIndex = items[colFirst];
405+
const colLastIndex = items[colLast];
406+
if (firstVisibleIndex === -1 || colFirstIndex < firstVisibleIndex) {
407+
firstVisibleIndex = colFirstIndex;
408+
}
409+
if (lastVisibleIndex === -1 || colLastIndex > lastVisibleIndex) {
410+
lastVisibleIndex = colLastIndex;
351411
}
352412
}
353413

0 commit comments

Comments
 (0)