diff --git a/packages/react-native-sortables/src/components/SortableGrid.tsx b/packages/react-native-sortables/src/components/SortableGrid.tsx index b8a494cb..b462d58e 100644 --- a/packages/react-native-sortables/src/components/SortableGrid.tsx +++ b/packages/react-native-sortables/src/components/SortableGrid.tsx @@ -29,6 +29,7 @@ function SortableGrid(props: SortableGridProps) { columns, data, keyExtractor = defaultKeyExtractor, + masonry, onActiveItemDropped, onDragEnd: _onDragEnd, onDragMove, @@ -78,6 +79,7 @@ function SortableGrid(props: SortableGridProps) { groups={groups} isVertical={isVertical} key={useStrategyKey(strategy)} + masonry={masonry} rowHeight={rowHeight} // must be specified for horizontal grids strategy={strategy} onDragEnd={onDragEnd} @@ -108,6 +110,7 @@ const SortableGridInner = typedMemo(function SortableGridInner({ isVertical, itemEntering, itemExiting, + masonry, overflow, rowGap: _rowGap, rowHeight, @@ -141,6 +144,7 @@ const SortableGridInner = typedMemo(function SortableGridInner({ controlledItemDimensions={controlledItemDimensions} debug={debug} isVertical={isVertical} + masonry={masonry} numGroups={groups} rowGap={rowGap} rowHeight={rowHeight} diff --git a/packages/react-native-sortables/src/constants/props.ts b/packages/react-native-sortables/src/constants/props.ts index a4d9abe1..5bd70859 100644 --- a/packages/react-native-sortables/src/constants/props.ts +++ b/packages/react-native-sortables/src/constants/props.ts @@ -73,7 +73,8 @@ export const DEFAULT_SORTABLE_GRID_PROPS = { columns: 1, keyExtractor: defaultKeyExtractor, rowGap: 0, - strategy: 'insert' + strategy: 'insert', + masonry: false } satisfies DefaultSortableGridProps; export const DEFAULT_SORTABLE_FLEX_PROPS = { diff --git a/packages/react-native-sortables/src/providers/grid/AutoOffsetAdjustmentProvider.tsx b/packages/react-native-sortables/src/providers/grid/AutoOffsetAdjustmentProvider.tsx index f437c09e..6e6192e3 100644 --- a/packages/react-native-sortables/src/providers/grid/AutoOffsetAdjustmentProvider.tsx +++ b/packages/react-native-sortables/src/providers/grid/AutoOffsetAdjustmentProvider.tsx @@ -207,6 +207,7 @@ const { AutoOffsetAdjustmentProvider, useAutoOffsetAdjustmentContext } = isVertical, itemHeights, itemWidths, + masonry, numGroups } = props; const crossItemSizes = isVertical ? itemHeights : itemWidths; @@ -226,6 +227,7 @@ const { AutoOffsetAdjustmentProvider, useAutoOffsetAdjustmentContext } = crossGap: gaps.cross, crossItemSizes, indexToKey: indexToKey, + masonry, numGroups } as const; diff --git a/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/GridLayoutProvider.tsx b/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/GridLayoutProvider.tsx index eacba213..116aaff5 100644 --- a/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/GridLayoutProvider.tsx +++ b/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/GridLayoutProvider.tsx @@ -39,6 +39,7 @@ export type GridLayoutProviderProps = PropsWithChildren<{ rowGap: SharedValue; columnGap: SharedValue; rowHeight?: number; + masonry?: boolean; }>; const { GridLayoutProvider, useGridLayoutContext } = createProvider( @@ -46,6 +47,7 @@ const { GridLayoutProvider, useGridLayoutContext } = createProvider( )(({ columnGap, isVertical, + masonry, numGroups, rowGap, rowHeight @@ -170,6 +172,7 @@ const { GridLayoutProvider, useGridLayoutContext } = createProvider( isVertical, itemHeights: itemHeights.value, itemWidths: itemWidths.value, + masonry, numGroups, requestId: layoutRequestId.value // Helper to force layout re-calculation }), diff --git a/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/utils/layout.ts b/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/utils/layout.ts index cd5ed659..4942a49d 100644 --- a/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/utils/layout.ts +++ b/packages/react-native-sortables/src/providers/grid/GridLayoutProvider/utils/layout.ts @@ -8,7 +8,103 @@ import type { import { resolveDimension } from '../../../../utils'; import { getCrossIndex, getMainIndex } from './helpers'; -export const calculateLayout = ({ +/** + * Calculates masonry-style layout where items stack within each column. + * Items maintain their sequential grid order (respecting columns). Vertical spacing + * between items in a column is controlled by gaps.cross (rowGap when vertical). + */ +const calculateMasonryLayout = ({ + gaps, + indexToKey, + isVertical, + itemHeights, + itemWidths, + numGroups, + startCrossOffset +}: GridLayoutProps): GridLayout | null => { + 'worklet'; + const mainGroupSize = (isVertical ? itemWidths : itemHeights) as + | null + | number; + + if (!mainGroupSize) { + return null; + } + + const itemPositions: Record = {}; + + let mainCoordinate: Coordinate; + let crossCoordinate: Coordinate; + let crossItemSizes; + + if (isVertical) { + // grid with specified number of columns (vertical orientation) + mainCoordinate = 'x'; + crossCoordinate = 'y'; + crossItemSizes = itemHeights; + } else { + // grid with specified number of rows (horizontal orientation) + mainCoordinate = 'y'; + crossCoordinate = 'x'; + crossItemSizes = itemWidths; + } + + // Track the current height/position of each column independently + // Each column stacks its items, separated by the configured cross gap + const columnHeights = new Array(numGroups).fill(startCrossOffset ?? 0); + + for (const [itemIndex, itemKey] of indexToKey.entries()) { + const crossItemSize = resolveDimension(crossItemSizes, itemKey); + + if (crossItemSize === null) { + return null; + } + + // Determine which column this item belongs to based on grid order + const mainIndex = getMainIndex(itemIndex, numGroups); + const crossAxisOffset = columnHeights[mainIndex]!; + + // Update item position - place it at the current column height + itemPositions[itemKey] = { + [crossCoordinate]: crossAxisOffset, + [mainCoordinate]: mainIndex * (mainGroupSize + gaps.main) + } as Vector; + + // Update column height - advance by item size plus cross gap + columnHeights[mainIndex] = crossAxisOffset + crossItemSize + gaps.cross; + } + + // Container size is determined by the tallest column + const rawMaxColumnHeight = Math.max(...columnHeights); + const baseCrossOffset = startCrossOffset ?? 0; + // Remove the trailing cross gap from the tallest column if at least one item exists + const maxColumnHeight = + rawMaxColumnHeight > baseCrossOffset + ? Math.max(rawMaxColumnHeight - gaps.cross, baseCrossOffset) + : rawMaxColumnHeight; + const mainSize = (mainGroupSize + gaps.main) * numGroups - gaps.main; + + return { + containerCrossSize: maxColumnHeight, + contentBounds: [ + { + [crossCoordinate]: startCrossOffset ?? 0, + [mainCoordinate]: 0 + } as Vector, + { + [crossCoordinate]: maxColumnHeight, + [mainCoordinate]: mainSize + } as Vector + ], + crossAxisOffsets: columnHeights, + itemPositions + }; +}; + +/** + * Calculates standard grid layout where items in the same row align vertically + */ +const calculateStandardLayout = ({ gaps, indexToKey, isVertical, @@ -95,14 +191,59 @@ export const calculateLayout = ({ }; }; +export const calculateLayout = (props: GridLayoutProps): GridLayout | null => { + 'worklet'; + return props.masonry + ? calculateMasonryLayout(props) + : calculateStandardLayout(props); +}; + export const calculateItemCrossOffset = ({ crossGap, crossItemSizes, indexToKey, itemKey, + masonry, numGroups }: AutoOffsetAdjustmentProps): number => { 'worklet'; + + if (masonry) { + // Masonry layout: calculate offset within the same group (column for vertical, row for horizontal) + // Find the target item's index and group + let targetItemIndex = -1; + for (let i = 0; i < indexToKey.length; i++) { + if (indexToKey[i] === itemKey) { + targetItemIndex = i; + break; + } + } + + if (targetItemIndex === -1) { + return 0; + } + + const targetGroup = getMainIndex(targetItemIndex, numGroups); + let offset = 0; + + // Sum cross-axis sizes of all items in the same group that come before the target item + // For vertical grids: sums heights of items in the same column + // For horizontal grids: sums widths of items in the same row + for (let i = 0; i < targetItemIndex; i++) { + const group = getMainIndex(i, numGroups); + if (group === targetGroup) { + const key = indexToKey[i]!; + const itemSize = resolveDimension(crossItemSizes, key); + if (itemSize !== null) { + offset += itemSize + crossGap; + } + } + } + + return offset; + } + + // Standard grid layout: calculate offset using row-based logic let activeItemCrossOffset = 0; let currentGroupCrossSize = 0; let currentGroupCrossIndex = 0; diff --git a/packages/react-native-sortables/src/providers/grid/GridProvider.tsx b/packages/react-native-sortables/src/providers/grid/GridProvider.tsx index 15dc284a..2c21832f 100644 --- a/packages/react-native-sortables/src/providers/grid/GridProvider.tsx +++ b/packages/react-native-sortables/src/providers/grid/GridProvider.tsx @@ -30,6 +30,7 @@ export default function GridProvider({ children, columnGap: columnGap_, isVertical, + masonry, numGroups, rowGap: rowGap_, rowHeight, @@ -42,6 +43,7 @@ export default function GridProvider({ const sharedGridProviderProps = { columnGap, isVertical, + masonry, numGroups, rowGap }; diff --git a/packages/react-native-sortables/src/types/layout/grid.ts b/packages/react-native-sortables/src/types/layout/grid.ts index 6682205f..d96f4e37 100644 --- a/packages/react-native-sortables/src/types/layout/grid.ts +++ b/packages/react-native-sortables/src/types/layout/grid.ts @@ -14,6 +14,7 @@ export type GridLayoutProps = { shouldAnimateLayout?: boolean; requestNextLayout?: boolean; startCrossOffset?: Maybe; + masonry?: boolean; }; export type GridLayout = { @@ -29,4 +30,5 @@ export type AutoOffsetAdjustmentProps = { crossItemSizes: ItemSizes; indexToKey: Array; numGroups: number; + masonry?: boolean; }; diff --git a/packages/react-native-sortables/src/types/props/grid.ts b/packages/react-native-sortables/src/types/props/grid.ts index 0579d259..f4cecf85 100644 --- a/packages/react-native-sortables/src/types/props/grid.ts +++ b/packages/react-native-sortables/src/types/props/grid.ts @@ -136,6 +136,13 @@ export type SortableGridProps = Simplify< * @important Works only for horizontal grids. Requires the rows property to be set. */ rowHeight?: number; + /** When true, renders the grid in masonry-style layout, allowing items of different sizes to stack without gaps, maintaining the sequential grid order. + * + * RowGap and columnGap still apply + * + * @default false + */ + masonry?: boolean; } >;