Skip to content

Commit c6a4052

Browse files
committed
add masonry based layout, allowing items of different sizes to render without gaps
1 parent 90cdc0d commit c6a4052

File tree

8 files changed

+164
-2
lines changed

8 files changed

+164
-2
lines changed

packages/react-native-sortables/src/components/SortableGrid.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function SortableGrid<I>(props: SortableGridProps<I>) {
2929
columns,
3030
data,
3131
keyExtractor = defaultKeyExtractor,
32+
masonry,
3233
onActiveItemDropped,
3334
onDragEnd: _onDragEnd,
3435
onDragMove,
@@ -78,6 +79,7 @@ function SortableGrid<I>(props: SortableGridProps<I>) {
7879
groups={groups}
7980
isVertical={isVertical}
8081
key={useStrategyKey(strategy)}
82+
masonry={masonry}
8183
rowHeight={rowHeight} // must be specified for horizontal grids
8284
strategy={strategy}
8385
onDragEnd={onDragEnd}
@@ -108,6 +110,7 @@ const SortableGridInner = typedMemo(function SortableGridInner<I>({
108110
isVertical,
109111
itemEntering,
110112
itemExiting,
113+
masonry,
111114
overflow,
112115
rowGap: _rowGap,
113116
rowHeight,
@@ -141,6 +144,7 @@ const SortableGridInner = typedMemo(function SortableGridInner<I>({
141144
controlledItemDimensions={controlledItemDimensions}
142145
debug={debug}
143146
isVertical={isVertical}
147+
masonry={masonry}
144148
numGroups={groups}
145149
rowGap={rowGap}
146150
rowHeight={rowHeight}

packages/react-native-sortables/src/constants/props.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ export const DEFAULT_SORTABLE_GRID_PROPS = {
7373
columns: 1,
7474
keyExtractor: defaultKeyExtractor,
7575
rowGap: 0,
76-
strategy: 'insert'
76+
strategy: 'insert',
77+
masonry: false
7778
} satisfies DefaultSortableGridProps;
7879

7980
export const DEFAULT_SORTABLE_FLEX_PROPS = {

packages/react-native-sortables/src/providers/grid/AutoOffsetAdjustmentProvider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ const { AutoOffsetAdjustmentProvider, useAutoOffsetAdjustmentContext } =
207207
isVertical,
208208
itemHeights,
209209
itemWidths,
210+
masonry,
210211
numGroups
211212
} = props;
212213
const crossItemSizes = isVertical ? itemHeights : itemWidths;
@@ -226,6 +227,7 @@ const { AutoOffsetAdjustmentProvider, useAutoOffsetAdjustmentContext } =
226227
crossGap: gaps.cross,
227228
crossItemSizes,
228229
indexToKey: indexToKey,
230+
masonry,
229231
numGroups
230232
} as const;
231233

packages/react-native-sortables/src/providers/grid/GridLayoutProvider/GridLayoutProvider.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,15 @@ export type GridLayoutProviderProps = PropsWithChildren<{
3939
rowGap: SharedValue<number>;
4040
columnGap: SharedValue<number>;
4141
rowHeight?: number;
42+
masonry?: boolean;
4243
}>;
4344

4445
const { GridLayoutProvider, useGridLayoutContext } = createProvider(
4546
'GridLayout'
4647
)<GridLayoutProviderProps, GridLayoutContextType>(({
4748
columnGap,
4849
isVertical,
50+
masonry,
4951
numGroups,
5052
rowGap,
5153
rowHeight
@@ -170,6 +172,7 @@ const { GridLayoutProvider, useGridLayoutContext } = createProvider(
170172
isVertical,
171173
itemHeights: itemHeights.value,
172174
itemWidths: itemWidths.value,
175+
masonry,
173176
numGroups,
174177
requestId: layoutRequestId.value // Helper to force layout re-calculation
175178
}),

packages/react-native-sortables/src/providers/grid/GridLayoutProvider/utils/layout.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,103 @@ import type {
88
import { resolveDimension } from '../../../../utils';
99
import { getCrossIndex, getMainIndex } from './helpers';
1010

11-
export const calculateLayout = ({
11+
/**
12+
* Calculates masonry-style layout where items stack within each column.
13+
* Items maintain their sequential grid order (respecting columns). Vertical spacing
14+
* between items in a column is controlled by gaps.cross (rowGap when vertical).
15+
*/
16+
const calculateMasonryLayout = ({
17+
gaps,
18+
indexToKey,
19+
isVertical,
20+
itemHeights,
21+
itemWidths,
22+
numGroups,
23+
startCrossOffset
24+
}: GridLayoutProps): GridLayout | null => {
25+
'worklet';
26+
const mainGroupSize = (isVertical ? itemWidths : itemHeights) as
27+
| null
28+
| number;
29+
30+
if (!mainGroupSize) {
31+
return null;
32+
}
33+
34+
const itemPositions: Record<string, Vector> = {};
35+
36+
let mainCoordinate: Coordinate;
37+
let crossCoordinate: Coordinate;
38+
let crossItemSizes;
39+
40+
if (isVertical) {
41+
// grid with specified number of columns (vertical orientation)
42+
mainCoordinate = 'x';
43+
crossCoordinate = 'y';
44+
crossItemSizes = itemHeights;
45+
} else {
46+
// grid with specified number of rows (horizontal orientation)
47+
mainCoordinate = 'y';
48+
crossCoordinate = 'x';
49+
crossItemSizes = itemWidths;
50+
}
51+
52+
// Track the current height/position of each column independently
53+
// Each column stacks its items, separated by the configured cross gap
54+
const columnHeights = new Array(numGroups).fill(startCrossOffset ?? 0);
55+
56+
for (const [itemIndex, itemKey] of indexToKey.entries()) {
57+
const crossItemSize = resolveDimension(crossItemSizes, itemKey);
58+
59+
if (crossItemSize === null) {
60+
return null;
61+
}
62+
63+
// Determine which column this item belongs to based on grid order
64+
const mainIndex = getMainIndex(itemIndex, numGroups);
65+
const crossAxisOffset = columnHeights[mainIndex]!;
66+
67+
// Update item position - place it at the current column height
68+
itemPositions[itemKey] = {
69+
[crossCoordinate]: crossAxisOffset,
70+
[mainCoordinate]: mainIndex * (mainGroupSize + gaps.main)
71+
} as Vector;
72+
73+
// Update column height - advance by item size plus cross gap
74+
columnHeights[mainIndex] = crossAxisOffset + crossItemSize + gaps.cross;
75+
}
76+
77+
// Container size is determined by the tallest column
78+
const rawMaxColumnHeight = Math.max(...columnHeights);
79+
const baseCrossOffset = startCrossOffset ?? 0;
80+
// Remove the trailing cross gap from the tallest column if at least one item exists
81+
const maxColumnHeight =
82+
rawMaxColumnHeight > baseCrossOffset
83+
? Math.max(rawMaxColumnHeight - gaps.cross, baseCrossOffset)
84+
: rawMaxColumnHeight;
85+
const mainSize = (mainGroupSize + gaps.main) * numGroups - gaps.main;
86+
87+
return {
88+
containerCrossSize: maxColumnHeight,
89+
contentBounds: [
90+
{
91+
[crossCoordinate]: startCrossOffset ?? 0,
92+
[mainCoordinate]: 0
93+
} as Vector,
94+
{
95+
[crossCoordinate]: maxColumnHeight,
96+
[mainCoordinate]: mainSize
97+
} as Vector
98+
],
99+
crossAxisOffsets: columnHeights,
100+
itemPositions
101+
};
102+
};
103+
104+
/**
105+
* Calculates standard grid layout where items in the same row align vertically
106+
*/
107+
const calculateStandardLayout = ({
12108
gaps,
13109
indexToKey,
14110
isVertical,
@@ -95,14 +191,59 @@ export const calculateLayout = ({
95191
};
96192
};
97193

194+
export const calculateLayout = (props: GridLayoutProps): GridLayout | null => {
195+
'worklet';
196+
return props.masonry
197+
? calculateMasonryLayout(props)
198+
: calculateStandardLayout(props);
199+
};
200+
98201
export const calculateItemCrossOffset = ({
99202
crossGap,
100203
crossItemSizes,
101204
indexToKey,
102205
itemKey,
206+
masonry,
103207
numGroups
104208
}: AutoOffsetAdjustmentProps): number => {
105209
'worklet';
210+
211+
if (masonry) {
212+
// Masonry layout: calculate offset within the same group (column for vertical, row for horizontal)
213+
// Find the target item's index and group
214+
let targetItemIndex = -1;
215+
for (let i = 0; i < indexToKey.length; i++) {
216+
if (indexToKey[i] === itemKey) {
217+
targetItemIndex = i;
218+
break;
219+
}
220+
}
221+
222+
if (targetItemIndex === -1) {
223+
return 0;
224+
}
225+
226+
const targetGroup = getMainIndex(targetItemIndex, numGroups);
227+
let offset = 0;
228+
229+
// Sum cross-axis sizes of all items in the same group that come before the target item
230+
// For vertical grids: sums heights of items in the same column
231+
// For horizontal grids: sums widths of items in the same row
232+
for (let i = 0; i < targetItemIndex; i++) {
233+
const group = getMainIndex(i, numGroups);
234+
if (group === targetGroup) {
235+
const key = indexToKey[i]!;
236+
const itemSize = resolveDimension(crossItemSizes, key);
237+
if (itemSize !== null) {
238+
offset += itemSize + crossGap;
239+
}
240+
}
241+
}
242+
243+
return offset;
244+
}
245+
246+
// Standard grid layout: calculate offset using row-based logic
106247
let activeItemCrossOffset = 0;
107248
let currentGroupCrossSize = 0;
108249
let currentGroupCrossIndex = 0;

packages/react-native-sortables/src/providers/grid/GridProvider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export default function GridProvider({
3030
children,
3131
columnGap: columnGap_,
3232
isVertical,
33+
masonry,
3334
numGroups,
3435
rowGap: rowGap_,
3536
rowHeight,
@@ -42,6 +43,7 @@ export default function GridProvider({
4243
const sharedGridProviderProps = {
4344
columnGap,
4445
isVertical,
46+
masonry,
4547
numGroups,
4648
rowGap
4749
};

packages/react-native-sortables/src/types/layout/grid.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type GridLayoutProps = {
1414
shouldAnimateLayout?: boolean;
1515
requestNextLayout?: boolean;
1616
startCrossOffset?: Maybe<number>;
17+
masonry?: boolean;
1718
};
1819

1920
export type GridLayout = {
@@ -29,4 +30,5 @@ export type AutoOffsetAdjustmentProps = {
2930
crossItemSizes: ItemSizes;
3031
indexToKey: Array<string>;
3132
numGroups: number;
33+
masonry?: boolean;
3234
};

packages/react-native-sortables/src/types/props/grid.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ export type SortableGridProps<I> = Simplify<
136136
* @important Works only for horizontal grids. Requires the rows property to be set.
137137
*/
138138
rowHeight?: number;
139+
/** When true, renders the grid in masonry-style layout, allowing items of different sizes to stack without gaps, maintaining the sequential grid order.
140+
*
141+
* RowGap and columnGap still apply
142+
*
143+
* @default false
144+
*/
145+
masonry?: boolean;
139146
}
140147
>;
141148

0 commit comments

Comments
 (0)