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
94 changes: 94 additions & 0 deletions src/__tests__/MasonryLayoutManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
140 changes: 140 additions & 0 deletions src/recyclerview/layout-managers/MasonryLayoutManager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {

Check failure on line 1 in src/recyclerview/layout-managers/MasonryLayoutManager.ts

View workflow job for this annotation

GitHub Actions / Lint Typescript

There should be at least one empty line between import groups
LayoutParams,
RVDimension,
RVLayout,
RVLayoutInfo,
RVLayoutManager,
} from "./LayoutManager";
import { ConsecutiveNumbers } from "../helpers/ConsecutiveNumbers";

Check failure on line 8 in src/recyclerview/layout-managers/MasonryLayoutManager.ts

View workflow job for this annotation

GitHub Actions / Lint Typescript

`../helpers/ConsecutiveNumbers` import should occur before import of `./LayoutManager`

/**
* MasonryLayoutManager implementation that arranges items in a masonry/pinterest-style layout.
Expand Down Expand Up @@ -321,6 +322,145 @@
}
}

/**
* 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(

Check failure on line 348 in src/recyclerview/layout-managers/MasonryLayoutManager.ts

View workflow job for this annotation

GitHub Actions / Lint Typescript

Replace `⏎········col,⏎········unboundDimensionStart,⏎········true⏎······` with `col,·unboundDimensionStart,·true`
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) {

Check failure on line 409 in src/recyclerview/layout-managers/MasonryLayoutManager.ts

View workflow job for this annotation

GitHub Actions / Lint Typescript

Unexpected if as the only statement in an else block
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) {

Check failure on line 452 in src/recyclerview/layout-managers/MasonryLayoutManager.ts

View workflow job for this annotation

GitHub Actions / Lint Typescript

Unexpected if as the only statement in an else block
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.
Expand Down
Loading