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
44 changes: 44 additions & 0 deletions src/__tests__/MasonryLayoutManager.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/recyclerview/RecyclerViewManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export class RecyclerViewManager<T> {

if (engagedIndices) {
this.updateRenderStack(engagedIndices);
this.layoutManager.onEngagedIndicesChanged(engagedIndices);
return engagedIndices;
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/recyclerview/layout-managers/LayoutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 49 additions & 1 deletion src/recyclerview/layout-managers/MasonryLayoutManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ConsecutiveNumbers } from "../helpers/ConsecutiveNumbers";
import {
LayoutParams,
RVDimension,
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down