From 08e90d96c34a8f053bb317227d15297858dc1904 Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Sat, 18 Jul 2026 16:38:32 +1200 Subject: [PATCH 1/2] feat: add masonry list layout --- CHANGELOG.md | 1 + README.md | 22 +- .../components/MasonryLegendList.test.tsx | 339 ++++++++++++++++++ example-web/src/catalogMeta.ts | 5 + example-web/src/fixtures/MasonryExample.tsx | 33 ++ example-web/src/fixtures/routes.tsx | 8 + example-web/tsconfig.json | 6 + example-web/vite.config.ts | 2 + example/screens/fixtures/masonry.tsx | 59 +++ example/screens/routes.tsx | 10 + example/tsconfig.json | 3 + package.json | 6 + postbuild.ts | 2 +- src/components/LegendList.tsx | 4 + src/core/updateItemPositions.ts | 41 ++- src/integrations/masonry.tsx | 230 ++++++++++++ src/types.internal.ts | 35 ++ tsup.config.ts | 1 + 18 files changed, 795 insertions(+), 12 deletions(-) create mode 100644 __tests__/components/MasonryLegendList.test.tsx create mode 100644 example-web/src/fixtures/MasonryExample.tsx create mode 100644 example/screens/fixtures/masonry.tsx create mode 100644 src/integrations/masonry.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c52c55a78..524c7aaea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 75363db74..71e2b24e4 100644 --- a/README.md +++ b/README.md @@ -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" + + photo.id} + numColumns={2} + recycleItems + renderItem={({ item }) => } +/> +``` + +Masonry lists are vertical and support dynamically measured or fixed-size items. Column spans and `overrideItemLayout` are not supported. + --- ## How to Build @@ -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 diff --git a/__tests__/components/MasonryLegendList.test.tsx b/__tests__/components/MasonryLegendList.test.tsx new file mode 100644 index 000000000..54ddc90fd --- /dev/null +++ b/__tests__/components/MasonryLegendList.test.tsx @@ -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(); + const data = [ + { height: 100, id: "a" }, + { height: 200, id: "b" }, + { height: 50, id: "c" }, + { height: 60, id: "d" }, + ]; + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + 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(); + const data = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + 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(); + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + 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(); + const data = [ + { height: 100, id: "a" }, + { height: 50, id: "b" }, + { height: 100, id: "c" }, + ]; + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = TestRenderer.create( + 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(); + const initialData = [ + { height: 100, id: "a" }, + { height: 200, id: "b" }, + { height: 50, id: "c" }, + ]; + const renderList = (data: typeof initialData) => ( + item.height} + keyExtractor={(item) => item.id} + numColumns={2} + recycleItems={false} + ref={ref} + renderItem={() => null} + /> + ); + + let renderer: ReturnType | 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(); + const data = [ + { height: 100, id: "a" }, + { height: 200, id: "b" }, + { height: 50, id: "c" }, + { height: 60, id: "d" }, + ]; + const renderList = (numColumns: number) => ( + item.height} + keyExtractor={(item) => item.id} + numColumns={numColumns} + recycleItems={false} + ref={ref} + renderItem={() => null} + /> + ); + + let renderer: ReturnType | 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(); + 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 | undefined; + await act(async () => { + renderer = TestRenderer.create( + 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(); + }); + }); +}); diff --git a/example-web/src/catalogMeta.ts b/example-web/src/catalogMeta.ts index d4d3a11d4..1a2bc90b7 100644 --- a/example-web/src/catalogMeta.ts +++ b/example-web/src/catalogMeta.ts @@ -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", diff --git a/example-web/src/fixtures/MasonryExample.tsx b/example-web/src/fixtures/MasonryExample.tsx new file mode 100644 index 000000000..90740e7ec --- /dev/null +++ b/example-web/src/fixtures/MasonryExample.tsx @@ -0,0 +1,33 @@ +import { MasonryLegendList } from "@legendapp/list/masonry"; + +const COLORS = ["#7c3aed", "#2563eb", "#0891b2", "#059669", "#ca8a04", "#dc2626"]; +const DATA = Array.from({ length: 80 }, (_, index) => ({ + color: COLORS[index % COLORS.length], + height: 96 + ((index * 47) % 180), + id: String(index), +})); + +export default function MasonryExample() { + return ( + item.id} + numColumns={3} + recycleItems + renderItem={({ item, index }) => ( +
+ + CARD {String(index + 1).padStart(2, "0")} + + {item.height}px +
+ )} + style={{ height: "100%" }} + /> + ); +} diff --git a/example-web/src/fixtures/routes.tsx b/example-web/src/fixtures/routes.tsx index 1434dabeb..387f8f12e 100644 --- a/example-web/src/fixtures/routes.tsx +++ b/example-web/src/fixtures/routes.tsx @@ -19,6 +19,7 @@ import HeaderMvcpExample from "./HeaderMvcpExample"; import InitialScrollAtEndExample from "./InitialScrollAtEndExample"; import InitialScrollIndexExample from "./InitialScrollIndexExample"; import LazyListExample from "./LazyListExample"; +import MasonryExample from "./MasonryExample"; import MutableCellsExample from "./MutableCellsExample"; import MVCPTestExample from "./MVCPTestExample"; import PrependLargeItemsJumpExample from "./PrependLargeItemsJumpExample"; @@ -84,6 +85,13 @@ export const FIXTURE_ROUTES: FixtureRoute[] = [ path: "columns", title: "Columns", }, + { + description: "Balances dynamically sized cards into the shortest available column.", + element: () => , + group: "Data & Layout", + path: "masonry", + title: "Masonry", + }, { description: "Searchable directory with dynamic filtering.", element: () => , diff --git a/example-web/tsconfig.json b/example-web/tsconfig.json index 336ac0864..aaecd5f10 100644 --- a/example-web/tsconfig.json +++ b/example-web/tsconfig.json @@ -33,6 +33,12 @@ "@legendapp/list/react": [ "../src/react.ts" ], + "@legendapp/list/react-native": [ + "../src/react.ts" + ], + "@legendapp/list/masonry": [ + "../src/integrations/masonry.tsx" + ], "react": [ "./node_modules/@types/react" ], diff --git a/example-web/vite.config.ts b/example-web/vite.config.ts index 261572eca..9b80c6766 100644 --- a/example-web/vite.config.ts +++ b/example-web/vite.config.ts @@ -28,7 +28,9 @@ export default defineConfig(({ command, mode }) => { alias: { "@": path.resolve(__dirname, "../src"), "@examples": path.resolve(__dirname, "../examples-shared"), + "@legendapp/list/masonry": path.resolve(__dirname, "../src/integrations/masonry.tsx"), "@legendapp/list/react": path.resolve(__dirname, "../src/react.ts"), + "@legendapp/list/react-native": path.resolve(__dirname, "../src/react.ts"), }, // Deduplicate React to avoid multiple copies dedupe: ["react", "react-dom"], diff --git a/example/screens/fixtures/masonry.tsx b/example/screens/fixtures/masonry.tsx new file mode 100644 index 000000000..f758b3021 --- /dev/null +++ b/example/screens/fixtures/masonry.tsx @@ -0,0 +1,59 @@ +import { StyleSheet, Text, View } from "react-native"; + +import { MasonryLegendList } from "@legendapp/list/masonry"; + +const COLORS = ["#7c3aed", "#2563eb", "#0891b2", "#059669", "#ca8a04", "#dc2626"]; +const DATA = Array.from({ length: 80 }, (_, index) => ({ + color: COLORS[index % COLORS.length], + height: 96 + ((index * 47) % 180), + id: String(index), +})); + +export default function Masonry() { + return ( + + item.id} + numColumns={2} + recycleItems + renderItem={({ item, index }) => ( + + CARD {String(index + 1).padStart(2, "0")} + {item.height}px + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + card: { + borderRadius: 18, + justifyContent: "space-between", + padding: 16, + }, + container: { + backgroundColor: "#f8fafc", + flex: 1, + }, + content: { + columnGap: 12, + padding: 12, + rowGap: 12, + }, + eyebrow: { + color: "rgba(255, 255, 255, 0.78)", + fontSize: 11, + fontWeight: "800", + letterSpacing: 1.2, + }, + height: { + color: "#fff", + fontSize: 24, + fontWeight: "800", + }, +}); diff --git a/example/screens/routes.tsx b/example/screens/routes.tsx index 51ad4fa73..be2364b75 100644 --- a/example/screens/routes.tsx +++ b/example/screens/routes.tsx @@ -54,6 +54,7 @@ import InitialScrollStartAtTheEndFixture from "~/screens/fixtures/initial-scroll import LargeListRenderTimeFixture from "~/screens/fixtures/large-list-render-time"; import LayoutAnimationFixture from "~/screens/fixtures/layout-animation"; import LazyListFixture from "~/screens/fixtures/lazy-list"; +import MasonryFixture from "~/screens/fixtures/masonry"; import MoviesFlashListFixture from "~/screens/fixtures/movies-flashlist"; import MoviesLFixture from "~/screens/fixtures/moviesL"; import MoviesLRFixture from "~/screens/fixtures/moviesLR"; @@ -393,6 +394,15 @@ export const FIXTURE_ROUTES: FixtureRouteDefinition[] = [ slug: "columns", title: "Columns", }, + { + component: MasonryFixture, + description: "Balances dynamically sized cards into the shortest available column.", + groupKey: "data", + groupTitle: "Data & Layout", + kind: "fixture", + slug: "masonry", + title: "Masonry", + }, { component: CardsColumnsFixture, description: "Renders card-style content in a multi-column layout.", diff --git a/example/tsconfig.json b/example/tsconfig.json index aa9a6610b..2a6954927 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -34,6 +34,9 @@ "@legendapp/list/keyboard": [ "../src/integrations/keyboard" ], + "@legendapp/list/masonry": [ + "../src/integrations/masonry" + ], "@legendapp/list/reanimated": [ "../src/integrations/reanimated" ], diff --git a/package.json b/package.json index f4fdae0a7..f912b0a29 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,12 @@ "require": "./keyboard-legacy.js", "default": "./keyboard-legacy.js" }, + "./masonry": { + "types": "./masonry.d.ts", + "import": "./masonry.mjs", + "require": "./masonry.js", + "default": "./masonry.js" + }, "./reanimated": { "types": "./reanimated.d.ts", "import": "./reanimated.mjs", diff --git a/postbuild.ts b/postbuild.ts index 1d0605607..53fb19b7c 100644 --- a/postbuild.ts +++ b/postbuild.ts @@ -11,7 +11,7 @@ const RUNTIME_ENTRY_FILES = [ "dist/react.js", "dist/react.mjs", ]; -const INTEGRATION_ENTRYPOINTS = ["animated", "keyboard", "keyboard-legacy", "reanimated"] as const; +const INTEGRATION_ENTRYPOINTS = ["animated", "keyboard", "keyboard-legacy", "masonry", "reanimated"] as const; const INTEGRATION_BUNDLE_EXTENSIONS = [".js", ".mjs"] as const; const LIST_SUBPATH_IMPORT_REGEX = /@legendapp\/list\/(animated|react-native|reanimated)/; const INTEGRATION_REPACKAGED_CORE_PATTERNS = [ diff --git a/src/components/LegendList.tsx b/src/components/LegendList.tsx index c46751096..efbf9592a 100644 --- a/src/components/LegendList.tsx +++ b/src/components/LegendList.tsx @@ -187,11 +187,14 @@ const LegendListInner = typedForwardRef(function LegendListInner( } = props; const animatedPropsInternal = (props as any).animatedPropsInternal as StylesAsSharedValue; + const layoutStrategyInternal = (props as any) + .layoutStrategyInternal as InternalState["props"]["layoutStrategyInternal"]; const positionComponentInternal = (props as any).positionComponentInternal as React.ComponentType | undefined; const stickyPositionComponentInternal = (props as any).stickyPositionComponentInternal as | React.ComponentType | undefined; const { + layoutStrategyInternal: _layoutStrategyInternal, positionComponentInternal: _positionComponentInternal, stickyPositionComponentInternal: _stickyPositionComponentInternal, ...restProps @@ -463,6 +466,7 @@ const LegendListInner = typedForwardRef(function LegendListInner( horizontal: !!horizontal, itemsAreEqual, keyExtractor: useWrapIfItem(keyExtractor), + layoutStrategyInternal, maintainScrollAtEnd: maintainScrollAtEndConfig, maintainScrollAtEndThreshold, maintainVisibleContentPosition: maintainVisibleContentPositionConfig, diff --git a/src/core/updateItemPositions.ts b/src/core/updateItemPositions.ts index 76103bd55..0991814cf 100644 --- a/src/core/updateItemPositions.ts +++ b/src/core/updateItemPositions.ts @@ -1,22 +1,15 @@ +import { addTotalSize } from "@/core/addTotalSize"; import { prepareColumnStartState } from "@/core/prepareColumnStartState"; import { updateTotalSize } from "@/core/updateTotalSize"; import { Platform } from "@/platform/Platform"; import { notifyPosition$, peek$, type StateContext } from "@/state/state"; +import type { ItemPositioningOptions } from "@/types.internal"; import { IS_DEV } from "@/utils/devEnvironment"; import { getId } from "@/utils/getId"; import { getItemSize } from "@/utils/getItemSize"; import { getScrollVelocity } from "@/utils/getScrollVelocity"; import { updateSnapToOffsets } from "@/utils/updateSnapToOffsets"; -interface Options { - doMVCP: boolean | undefined; - forceFullUpdate?: boolean; - optimizeForVisibleWindow?: boolean; - scrollBottomBuffered: number; - scrollVelocity?: number; - startIndex: number; -} - export function updateItemPositions( ctx: StateContext, dataChanged: boolean | undefined, @@ -27,7 +20,7 @@ export function updateItemPositions( scrollBottomBuffered, scrollVelocity, startIndex, - }: Options = { + }: ItemPositioningOptions = { doMVCP: false, forceFullUpdate: false, optimizeForVisibleWindow: false, @@ -36,6 +29,34 @@ export function updateItemPositions( }, ) { const state = ctx.state; + const layoutStrategy = state.props.layoutStrategyInternal; + if (layoutStrategy) { + layoutStrategy( + ctx, + dataChanged, + { + doMVCP, + forceFullUpdate, + optimizeForVisibleWindow, + scrollBottomBuffered, + scrollVelocity, + startIndex, + }, + { + getId, + getItemSize, + getScrollVelocity, + isDev: IS_DEV, + notifyPosition: notifyPosition$, + setTotalSize: (strategyContext, totalSize) => addTotalSize(strategyContext, null, totalSize), + }, + ); + if (state.props.snapToIndices) { + updateSnapToOffsets(ctx); + } + return; + } + const hasPositionListeners = ctx.positionListeners.size > 0; const { columns, diff --git a/src/integrations/masonry.tsx b/src/integrations/masonry.tsx new file mode 100644 index 000000000..373fabf79 --- /dev/null +++ b/src/integrations/masonry.tsx @@ -0,0 +1,230 @@ +import * as React from "react"; + +import { LegendList, type LegendListProps, type LegendListRef } from "@legendapp/list/react-native"; + +type MasonryLegendListProps = Omit, "horizontal" | "overrideItemLayout"> & { + numColumns: number; +}; + +type MasonryLegendListComponentType = ( + props: MasonryLegendListProps & React.RefAttributes, +) => React.ReactElement | null; + +type MasonryLayoutState = { + columns: number[]; + columnSpans: number[]; + idCache: string[]; + indexByKey: Map; + positions: number[]; + props: { + data: readonly unknown[]; + }; + scrollAdjustHandler: { + getAdjust: () => number; + }; + scrollingTo?: unknown; + sizesKnown: Map; +}; + +type MasonryLayoutContext = { + positionListeners: { + size: number; + }; + state: MasonryLayoutState; + values: { + get: (key: string) => unknown; + }; +}; + +type MasonryLayoutDependencies = { + getId: (state: MasonryLayoutState, index: number) => string; + getItemSize: ( + ctx: MasonryLayoutContext, + key: string, + index: number, + item: unknown, + useAverageSize?: boolean, + preferCachedSize?: boolean, + notifyTotalSize?: boolean, + ) => number; + getScrollVelocity: (state: MasonryLayoutState) => number; + isDev: boolean; + notifyPosition: (ctx: MasonryLayoutContext, key: string, position: number) => void; + setTotalSize: (ctx: MasonryLayoutContext, totalSize: number) => void; +}; + +function updateMasonryItemPositions( + ctx: MasonryLayoutContext, + dataChanged: boolean | undefined, + options: { + doMVCP?: boolean; + forceFullUpdate?: boolean; + optimizeForVisibleWindow?: boolean; + scrollBottomBuffered: number; + scrollVelocity?: number; + startIndex: number; + }, + dependencies: MasonryLayoutDependencies, +) { + const state = ctx.state; + const { + columns, + columnSpans, + idCache, + indexByKey, + positions, + props: { data }, + sizesKnown, + } = state; + const dataLength = data.length; + const numColumnsValue = ctx.values.get("numColumns"); + const numColumns = + typeof numColumnsValue === "number" && Number.isFinite(numColumnsValue) + ? Math.max(1, Math.floor(numColumnsValue)) + : 1; + const pendingScrollAdjust = ctx.values.get("scrollAdjustPending"); + const useAverageSize = true; + const preferCachedSize = + !options.doMVCP || + dataChanged || + state.scrollAdjustHandler.getAdjust() !== 0 || + (typeof pendingScrollAdjust === "number" ? pendingScrollAdjust : 0) !== 0; + const notifyTotalSizeWhileCachingSizes = false; + + if (dataLength === 0) { + columns.length = 0; + columnSpans.length = 0; + positions.length = 0; + dependencies.setTotalSize(ctx, 0); + return; + } + + let startIndex = options.forceFullUpdate || dataChanged ? 0 : Math.max(0, options.startIndex); + const columnHeights = Array(numColumns).fill(0); + + if (startIndex > 0) { + const foundColumns = new Set(); + for (let index = startIndex - 1; index >= 0 && foundColumns.size < numColumns; index--) { + const column = columns[index]; + const position = positions[index]; + if (column === undefined || position === undefined) { + startIndex = 0; + columnHeights.fill(0); + break; + } + if (!foundColumns.has(column)) { + const key = idCache[index] ?? dependencies.getId(state, index); + const size = + sizesKnown.get(key) ?? + dependencies.getItemSize( + ctx, + key, + index, + data[index], + useAverageSize, + preferCachedSize, + notifyTotalSizeWhileCachingSizes, + ); + columnHeights[column - 1] = position + size; + foundColumns.add(column); + } + } + } + + const hasPositionListeners = ctx.positionListeners.size > 0; + const needsIndexByKey = dataChanged || indexByKey.size === 0; + const indexByKeyForChecking = dependencies.isDev && needsIndexByKey ? new Map() : undefined; + const velocity = options.scrollVelocity ?? dependencies.getScrollVelocity(state); + const shouldOptimize = + !options.forceFullUpdate && !dataChanged && (options.optimizeForVisibleWindow || Math.abs(velocity) > 0); + const maxVisibleArea = options.scrollBottomBuffered + 1000; + let breakAt: number | undefined; + let didBreakEarly = false; + + for (let index = startIndex; index < dataLength; index++) { + if (shouldOptimize && breakAt !== undefined && index > breakAt) { + didBreakEarly = true; + break; + } + + let columnIndex = 0; + let position = columnHeights[0]; + for (let candidate = 1; candidate < numColumns; candidate++) { + if (columnHeights[candidate] < position) { + columnIndex = candidate; + position = columnHeights[candidate]; + } + } + + if ( + shouldOptimize && + breakAt === undefined && + !state.scrollingTo && + !dataChanged && + position > maxVisibleArea + ) { + breakAt = index + numColumns + 10; + } + + const key = idCache[index] ?? dependencies.getId(state, index); + const size = + sizesKnown.get(key) ?? + dependencies.getItemSize( + ctx, + key, + index, + data[index], + useAverageSize, + preferCachedSize, + notifyTotalSizeWhileCachingSizes, + ); + + if (indexByKeyForChecking) { + if (indexByKeyForChecking.has(key)) { + console.error( + `[legend-list] Error: Detected overlapping key (${key}) which causes missing items and gaps and other terrrible things. Check that keyExtractor returns unique values.`, + ); + } + indexByKeyForChecking.set(key, index); + } + + if (positions[index] !== position) { + positions[index] = position; + if (hasPositionListeners) { + dependencies.notifyPosition(ctx, key, position); + } + } + columns[index] = columnIndex + 1; + columnSpans[index] = 1; + columnHeights[columnIndex] = position + size; + + if (needsIndexByKey) { + indexByKey.set(key, index); + } + } + + if (!didBreakEarly) { + dependencies.setTotalSize(ctx, Math.max(...columnHeights)); + } +} + +const MasonryLegendList = React.forwardRef(function MasonryLegendListComponent( + { numColumns, ...rest }: MasonryLegendListProps, + ref: React.ForwardedRef, +) { + const resolvedNumColumns = Number.isFinite(numColumns) ? Math.max(1, Math.floor(numColumns)) : 1; + + return ( + + ); +}) as MasonryLegendListComponentType; + +export { MasonryLegendList }; +export type { MasonryLegendListProps }; diff --git a/src/types.internal.ts b/src/types.internal.ts index be32c6d10..04adbd7ae 100644 --- a/src/types.internal.ts +++ b/src/types.internal.ts @@ -2,6 +2,7 @@ import type { Key } from "react"; import * as React from "react"; import type { ScrollAdjustHandler } from "@/core/ScrollAdjustHandler"; +import type { StateContext } from "@/state/state"; import type { AlwaysRenderConfig, AnchoredEndSpaceConfig, @@ -21,6 +22,39 @@ import type { DrawDistanceMode } from "@/utils/getEffectiveDrawDistance"; export type { BaseScrollViewProps, LegendListPropsBase } from "@/types.base"; +export interface ItemPositioningOptions { + doMVCP: boolean | undefined; + forceFullUpdate?: boolean; + optimizeForVisibleWindow?: boolean; + scrollBottomBuffered: number; + scrollVelocity?: number; + startIndex: number; +} + +export interface LayoutStrategyDependencies { + getId: (state: InternalState, index: number) => string; + getItemSize: ( + ctx: StateContext, + key: string, + index: number, + item: unknown, + useAverageSize?: boolean, + preferCachedSize?: boolean, + notifyTotalSize?: boolean, + ) => number; + getScrollVelocity: (state: InternalState) => number; + isDev: boolean; + notifyPosition: (ctx: StateContext, key: string, position: number) => void; + setTotalSize: (ctx: StateContext, totalSize: number) => void; +} + +export type LayoutStrategy = ( + ctx: StateContext, + dataChanged: boolean | undefined, + options: ItemPositioningOptions, + dependencies: LayoutStrategyDependencies, +) => void; + export interface ScrollEventTargetLike { addEventListener(type: string, listener: (...args: any[]) => void): void; removeEventListener(type: string, listener: (...args: any[]) => void): void; @@ -300,6 +334,7 @@ export interface InternalState { maintainScrollAtEnd: MaintainScrollAtEndNormalized | undefined; maintainScrollAtEndThreshold: number | undefined; maintainVisibleContentPosition: MaintainVisibleContentPositionNormalized; + layoutStrategyInternal: LayoutStrategy | undefined; numColumns: number; onEndReached: LegendListPropsInternal["onEndReached"]; onEndReachedThreshold: number | null | undefined; diff --git a/tsup.config.ts b/tsup.config.ts index 5ae38f0ea..28d6cce65 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -20,6 +20,7 @@ const nativeEntryPoints = { animated: "src/integrations/animated.tsx", keyboard: "src/integrations/keyboard.tsx", "keyboard-legacy": "src/integrations/keyboard-legacy.tsx", + masonry: "src/integrations/masonry.tsx", "react-native": "src/react-native.ts", reanimated: "src/integrations/reanimated.tsx", "section-list": "src/section-list/index.ts", From 2cad11a595fe16bbe001c069cff09b8d05bf8f4d Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Sat, 18 Jul 2026 17:12:25 +1200 Subject: [PATCH 2/2] fix: resolve masonry in native fixture Map the optional masonry entrypoint in the Expo example Metro resolver so the native fixture bundles the local source instead of relying only on TypeScript paths. --- example/metro.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/example/metro.config.js b/example/metro.config.js index ccf8e2b29..6db2249f0 100644 --- a/example/metro.config.js +++ b/example/metro.config.js @@ -16,6 +16,7 @@ config.resolver.nodeModulesPaths = [path.resolve(projectRoot, 'node_modules'), p const defaultResolveRequest = config.resolver.resolveRequest; const listEntrypoints = { '@legendapp/list/keyboard': path.join(listRoot, 'integrations/keyboard'), + '@legendapp/list/masonry': path.join(listRoot, 'integrations/masonry'), '@legendapp/list/react': path.join(listRoot, 'react'), '@legendapp/list/react-native': path.join(listRoot, 'react-native'), '@legendapp/list/reanimated': path.join(listRoot, 'integrations/reanimated'),