Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## 3.3.3

- Feat: Add `MasonryLegendList` at `@legendapp/list/masonry` for vertically balancing dynamic items across columns without bundling the masonry implementation into the core entrypoint.
- Fix: Row measurements are applied together in a batch, so item positions don't sometimes move after rendering.
- Fix: `onStartReached` and `onEndReached` no longer bounce between opposite edges during the same scroll gesture after data changes, MVCP adjustments, or residual scroll events.
- Fix: Prepending items with `maintainVisibleContentPosition` was sometimes flashing the wrong items for one frame
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,26 @@ export default LegendListExample

```

### Masonry layout

Import `MasonryLegendList` from the optional masonry entrypoint to place each item in the shortest available column without adding the masonry implementation to the core bundle.

```tsx
import { MasonryLegendList } from "@legendapp/list/masonry"

<MasonryLegendList
contentContainerStyle={{ columnGap: 12, rowGap: 12 }}
data={photos}
estimatedItemSize={180}
keyExtractor={(photo) => photo.id}
numColumns={2}
recycleItems
renderItem={({ item }) => <PhotoCard photo={item} />}
/>
```

Masonry lists are vertical and support dynamically measured or fixed-size items. Column spans and `overrideItemLayout` are not supported.

---

## How to Build
Expand All @@ -132,7 +152,7 @@ There's not a ton of code so hopefully it's easy to contribute. If you want to a
- [] Column spans
- [] overrideItemLayout
- [] Sticky headers
- [] Masonry layout
- [x] Masonry layout
- [] getItemType
- [] React DOM implementation

Expand Down
339 changes: 339 additions & 0 deletions __tests__/components/MasonryLegendList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
import * as React from "react";

import { beforeEach, describe, expect, it, mock } from "bun:test";
import type { ScrollAdjustHandler } from "../../src/core/ScrollAdjustHandler";
import type { StateContext } from "../../src/state/state";
import type { LegendListRef } from "../../src/types.base";
import TestRenderer, { act } from "../helpers/testRenderer";
import { registerBaseModuleMocks } from "../setup";

const handlerInstances: ScrollAdjustHandler[] = [];
let lastListProps: any;

function registerMasonryListMocks() {
mock.module("@/components/ListComponent", () => ({
ListComponent: (props: any) => {
lastListProps = props;
return null;
},
}));

mock.module("@/core/ScrollAdjustHandler", () => ({
ScrollAdjustHandler: class {
context: StateContext;

constructor(ctx: StateContext) {
this.context = ctx;
handlerInstances.push(this as any);
}

requestAdjust() {}
setMounted() {}
getAdjust() {
return 0;
}
commitPendingAdjust() {}
},
}));
}

beforeEach(() => {
mock.restore();
registerBaseModuleMocks();
registerMasonryListMocks();
handlerInstances.length = 0;
lastListProps = undefined;
});

describe("MasonryLegendList", () => {
it("places each item in the shortest column", async () => {
const { LegendList } = await import("../../src/components/LegendList?masonry-shortest-column-core");
mock.module("@legendapp/list/react-native", () => ({ LegendList }));
const { MasonryLegendList } = await import("../../src/integrations/masonry?shortest-column");
const ref = React.createRef<LegendListRef>();
const data = [
{ height: 100, id: "a" },
{ height: 200, id: "b" },
{ height: 50, id: "c" },
{ height: 60, id: "d" },
];

let renderer: ReturnType<typeof TestRenderer.create> | undefined;
await act(async () => {
renderer = TestRenderer.create(
<MasonryLegendList
data={data}
getFixedItemSize={(item) => item.height}
keyExtractor={(item) => item.id}
numColumns={2}
recycleItems={false}
ref={ref}
renderItem={() => null}
/>,
);
});

const state = ref.current?.getState();
expect([0, 1, 2, 3].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 100, 150]);
expect(state?.contentLength).toBe(210);

await act(async () => {
renderer?.unmount();
});
});

it("reflows downstream items when an estimated item is measured", async () => {
const { LegendList } = await import("../../src/components/LegendList?masonry-dynamic-size-core");
mock.module("@legendapp/list/react-native", () => ({ LegendList }));
const { MasonryLegendList } = await import("../../src/integrations/masonry?dynamic-size");
const ref = React.createRef<LegendListRef>();
const data = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }];

let renderer: ReturnType<typeof TestRenderer.create> | undefined;
await act(async () => {
renderer = TestRenderer.create(
<MasonryLegendList
data={data}
estimatedItemSize={100}
keyExtractor={(item) => item.id}
numColumns={2}
recycleItems={false}
ref={ref}
renderItem={() => null}
/>,
);
});

expect([0, 1, 2, 3].map((index) => ref.current?.getState().positionAtIndex(index))).toEqual([0, 0, 100, 100]);

await act(async () => {
lastListProps?.onLayout?.({
nativeEvent: { layout: { height: 300, width: 320, x: 0, y: 0 } },
});
});
const internalState = (handlerInstances.at(-1) as any).context.state;
internalState.didContainersLayout = true;
internalState.startBuffered = 0;
internalState.endBuffered = 3;

await act(async () => {
ref.current?.setItemSize("a", { height: 200, width: 160 });
});

const state = ref.current?.getState();
expect([0, 1, 2, 3].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 200, 200]);
expect(state?.contentLength).toBe(400);

await act(async () => {
renderer?.unmount();
});
});

it("falls back to one column when numColumns is not finite", async () => {
const { LegendList } = await import("../../src/components/LegendList?masonry-invalid-columns-core");
mock.module("@legendapp/list/react-native", () => ({ LegendList }));
const { MasonryLegendList } = await import("../../src/integrations/masonry?invalid-columns");
const ref = React.createRef<LegendListRef>();

let renderer: ReturnType<typeof TestRenderer.create> | undefined;
await act(async () => {
renderer = TestRenderer.create(
<MasonryLegendList
data={[{ id: "a" }, { id: "b" }]}
estimatedItemSize={100}
keyExtractor={(item) => item.id}
numColumns={Number.NaN}
recycleItems={false}
ref={ref}
renderItem={() => null}
/>,
);
});

expect([0, 1].map((index) => ref.current?.getState().positionAtIndex(index))).toEqual([0, 100]);

await act(async () => {
renderer?.unmount();
});
});

it("uses the scroll-axis gap when balancing columns", async () => {
const { LegendList } = await import("../../src/components/LegendList?masonry-gap-core");
mock.module("@legendapp/list/react-native", () => ({ LegendList }));
const { MasonryLegendList } = await import("../../src/integrations/masonry?gap");
const ref = React.createRef<LegendListRef>();
const data = [
{ height: 100, id: "a" },
{ height: 50, id: "b" },
{ height: 100, id: "c" },
];

let renderer: ReturnType<typeof TestRenderer.create> | undefined;
await act(async () => {
renderer = TestRenderer.create(
<MasonryLegendList
contentContainerStyle={{ columnGap: 12, rowGap: 10 }}
data={data}
getFixedItemSize={(item) => item.height}
keyExtractor={(item) => item.id}
numColumns={2}
recycleItems={false}
ref={ref}
renderItem={() => null}
/>,
);
});

const state = ref.current?.getState();
expect([0, 1, 2].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 60]);
expect(state?.contentLength).toBe(170);

await act(async () => {
renderer?.unmount();
});
});

it("rebalances when data is appended", async () => {
const { LegendList } = await import("../../src/components/LegendList?masonry-append-core");
mock.module("@legendapp/list/react-native", () => ({ LegendList }));
const { MasonryLegendList } = await import("../../src/integrations/masonry?append");
const ref = React.createRef<LegendListRef>();
const initialData = [
{ height: 100, id: "a" },
{ height: 200, id: "b" },
{ height: 50, id: "c" },
];
const renderList = (data: typeof initialData) => (
<MasonryLegendList
data={data}
getFixedItemSize={(item) => item.height}
keyExtractor={(item) => item.id}
numColumns={2}
recycleItems={false}
ref={ref}
renderItem={() => null}
/>
);

let renderer: ReturnType<typeof TestRenderer.create> | undefined;
await act(async () => {
renderer = TestRenderer.create(renderList(initialData));
});
await act(async () => {
lastListProps?.onLayout?.({
nativeEvent: { layout: { height: 300, width: 320, x: 0, y: 0 } },
});
});

await act(async () => {
renderer?.update(renderList([...initialData, { height: 60, id: "d" }]));
});

const state = ref.current?.getState();
expect([0, 1, 2, 3].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 100, 150]);
expect(state?.contentLength).toBe(210);

await act(async () => {
renderer?.unmount();
});
});

it("rebalances when numColumns changes", async () => {
const { LegendList } = await import("../../src/components/LegendList?masonry-column-change-core");
mock.module("@legendapp/list/react-native", () => ({ LegendList }));
const { MasonryLegendList } = await import("../../src/integrations/masonry?column-change");
const ref = React.createRef<LegendListRef>();
const data = [
{ height: 100, id: "a" },
{ height: 200, id: "b" },
{ height: 50, id: "c" },
{ height: 60, id: "d" },
];
const renderList = (numColumns: number) => (
<MasonryLegendList
data={data}
getFixedItemSize={(item) => item.height}
keyExtractor={(item) => item.id}
numColumns={numColumns}
recycleItems={false}
ref={ref}
renderItem={() => null}
/>
);

let renderer: ReturnType<typeof TestRenderer.create> | undefined;
await act(async () => {
renderer = TestRenderer.create(renderList(2));
});
await act(async () => {
lastListProps?.onLayout?.({
nativeEvent: { layout: { height: 300, width: 320, x: 0, y: 0 } },
});
});

await act(async () => {
renderer?.update(renderList(3));
});

const state = ref.current?.getState();
expect([0, 1, 2, 3].map((index) => state?.positionAtIndex(index))).toEqual([0, 0, 0, 50]);
expect(state?.contentLength).toBe(200);

await act(async () => {
renderer?.unmount();
});
});

it("balances a large fixed-size dataset in one positioning pass", async () => {
const { LegendList } = await import("../../src/components/LegendList?masonry-large-dataset-core");
mock.module("@legendapp/list/react-native", () => ({ LegendList }));
const { MasonryLegendList } = await import("../../src/integrations/masonry?large-dataset");
const ref = React.createRef<LegendListRef>();
const data = Array.from({ length: 10_000 }, (_, index) => ({
height: 40 + ((index * 37) % 200),
id: String(index),
}));
const getFixedItemSize = mock((item: (typeof data)[number]) => item.height);
const expectedPositions: number[] = [];
const expectedColumns: number[] = [];
const columnHeights = [0, 0, 0];

for (let index = 0; index < data.length; index++) {
let shortestColumn = 0;
for (let column = 1; column < columnHeights.length; column++) {
if (columnHeights[column] < columnHeights[shortestColumn]) {
shortestColumn = column;
}
}
expectedPositions.push(columnHeights[shortestColumn]);
expectedColumns.push(shortestColumn + 1);
columnHeights[shortestColumn] += data[index].height;
}

let renderer: ReturnType<typeof TestRenderer.create> | undefined;
await act(async () => {
renderer = TestRenderer.create(
<MasonryLegendList
data={data}
getFixedItemSize={getFixedItemSize}
keyExtractor={(item) => item.id}
numColumns={3}
recycleItems
ref={ref}
renderItem={() => null}
/>,
);
});

const state = ref.current?.getState();
const internalState = (handlerInstances.at(-1) as any).context.state;
expect(data.map((_, index) => state?.positionAtIndex(index))).toEqual(expectedPositions);
expect(internalState.columns).toEqual(expectedColumns);
expect(state?.contentLength).toBe(Math.max(...columnHeights));
expect(getFixedItemSize).toHaveBeenCalledTimes(data.length);

await act(async () => {
renderer?.unmount();
});
});
});
5 changes: 5 additions & 0 deletions example-web/src/catalogMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ export const FIXTURE_SECTIONS: CatalogSection[] = [
slug: "columns",
title: "Columns",
},
{
description: "Balances dynamically sized cards into the shortest available column.",
slug: "masonry",
title: "Masonry",
},
{
description: "Forces external state updates through visible cells.",
slug: "extra-data",
Expand Down
Loading