Skip to content

Commit d0cb59e

Browse files
authored
feat: Fixed items support for Sortable.Grid (#310)
## Description This PR adds support for fixed items for the `Sortable.Grid` component. Thanks @tpaksu for a proposed solution in #305 ## Example recordings - Example 1 - without data change - Example 2 - with data change (added/removed/inserted items) | Example 1 | Example 2 | |-|-| | <video src="https://github.com/user-attachments/assets/8b0999d1-5a2d-4684-96cc-8162b4e233c3" /> | <video src="https://github.com/user-attachments/assets/5ac8f434-3766-4962-8279-a4e69e066f10" /> | <details> <summary>Example 1 code snippet</summary> ```tsx import { useCallback } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { SortableGridRenderItem } from 'react-native-sortables'; import Sortable from 'react-native-sortables'; import { ScrollScreen } from '@/components'; import { colors, radius, sizes, spacing, text } from '@/theme'; const DATA = Array.from({ length: 12 }, (_, index) => `Item ${index + 1}`); export default function PlaygroundExample() { const renderItem = useCallback<SortableGridRenderItem<string>>( ({ item, index }) => { const fixed = index === 0 || index === 4 || index === 9 || index === DATA.length - 1; return ( <Sortable.Handle mode={fixed ? 'fixed' : 'draggable'}> <View style={[ styles.card, { backgroundColor: fixed ? colors.secondary : colors.primary } ]}> <Text style={styles.text}>{item}</Text> </View> </Sortable.Handle> ); }, [] ); return ( <ScrollScreen contentContainerStyle={styles.container} includeNavBarHeight> <Sortable.Grid columnGap={10} columns={3} data={DATA} customHandle renderItem={renderItem} rowGap={10} /> </ScrollScreen> ); } const styles = StyleSheet.create({ card: { alignItems: 'center', borderRadius: radius.md, height: sizes.xl, justifyContent: 'center' }, container: { padding: spacing.md }, text: { ...text.label2, color: colors.white } }); ``` </details> <details> <summary>Example 2 code snippet</summary> ```tsx import { memo, useCallback, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import Animated, { useAnimatedRef } from 'react-native-reanimated'; import Sortable, { type SortableGridRenderItem } from 'react-native-sortables'; import { Button, GridCard, Group, Screen, Section, Stagger } from '@/components'; import { IS_WEB } from '@/constants'; import { colors, flex, spacing, text } from '@/theme'; import { getItems } from '@/utils'; const AVAILABLE_DATA = getItems(18); const COLUMNS = 4; const FIXED_KEYS = new Set(['Item 1', 'Item 2', 'Item 3', 'Item 4']); export default function DataChangeExample() { const scrollableRef = useAnimatedRef<Animated.ScrollView>(); const [data, setData] = useState(AVAILABLE_DATA.slice(0, 12)); const getNewItemName = useCallback((currentData: Array<string>) => { if (currentData.length >= AVAILABLE_DATA.length) { return null; } for (const item of AVAILABLE_DATA) { if (!currentData.includes(item)) { return item; } } return null; }, []); const prependItem = useCallback(() => { setData(prevData => { const newItem = getNewItemName(prevData); if (newItem) { return [newItem, ...prevData]; } return prevData; }); }, [getNewItemName]); const insertItem = useCallback(() => { setData(prevData => { const newItem = getNewItemName(prevData); if (newItem) { const index = Math.floor(Math.random() * (prevData.length - 1)); return [...prevData.slice(0, index), newItem, ...prevData.slice(index)]; } return prevData; }); }, [getNewItemName]); const appendItem = useCallback(() => { setData(prevData => { const newItem = getNewItemName(prevData); if (newItem) { return [...prevData, newItem]; } return prevData; }); }, [getNewItemName]); const shuffleItems = useCallback(() => { setData(prevData => { const shuffledData = [...prevData]; for (let i = shuffledData.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffledData[i], shuffledData[j]] = [ shuffledData[j]!, shuffledData[i]! ]; } return shuffledData; }); }, []); const sortItems = useCallback(() => { setData(prevData => [...prevData].sort((a, b) => +a.split(' ')[1]! - +b.split(' ')[1]!) ); }, []); const onRemoveItem = useCallback((item: string) => { setData(prevData => prevData.filter(i => i !== item)); }, []); const renderItem = useCallback<SortableGridRenderItem<string>>( ({ item }) => ( <Sortable.Handle mode={FIXED_KEYS.has(item) ? 'fixed' : 'draggable'}> <GridItem item={item} onRemoveItem={onRemoveItem} fixed={FIXED_KEYS.has(item)} /> </Sortable.Handle> ), [onRemoveItem] ); const additionDisabled = data.length >= AVAILABLE_DATA.length; const reorderDisabled = data.length < 2; const menuSections = [ { buttons: [ { disabled: additionDisabled, onPress: prependItem, title: 'Prepend' }, { disabled: additionDisabled, onPress: insertItem, title: 'Insert' }, { disabled: additionDisabled, onPress: appendItem, title: 'Append' } ], description: 'Prepend/Insert/Append items to the list', title: 'Modify number of items' }, { buttons: [ { disabled: reorderDisabled, onPress: shuffleItems, title: 'Shuffle' }, { disabled: reorderDisabled, onPress: sortItems, title: 'Sort' } ], description: 'Reorder items in the list', title: 'Change order of items' } ]; return ( <Screen includeNavBarHeight> {/* Need to set flex: 1 for the ScrollView parent component in order // to ensure that it occupies the entire available space */} <Stagger wrapperStye={index => index === 2 ? (IS_WEB ? flex.shrink : flex.fill) : {} }> {menuSections.map(({ buttons, description, title }) => ( <Section description={description} key={title} title={title}> <View style={styles.row}> {buttons.map(btnProps => ( <Button {...btnProps} key={btnProps.title} /> ))} </View> </Section> ))} <Group padding='none' style={[flex.fill, styles.scrollViewGroup]}> <Animated.ScrollView contentContainerStyle={styles.scrollViewContent} ref={scrollableRef} // @ts-expect-error - overflowY is needed for proper behavior on web style={[flex.fill, IS_WEB && { overflowY: 'scroll' }]}> <Group withMargin={false} bordered center> <Text style={styles.title}>Above SortableGrid</Text> </Group> <Sortable.Grid columnGap={spacing.sm} columns={COLUMNS} data={data} renderItem={renderItem} rowGap={spacing.xs} scrollableRef={scrollableRef} animateHeight customHandle hapticsEnabled onDragEnd={({ data: newData }) => setData(newData)} /> <Group withMargin={false} bordered center> <Text style={styles.title}>Below SortableGrid</Text> </Group> </Animated.ScrollView> </Group> </Stagger> </Screen> ); } type GridItemProps = { item: string; fixed: boolean; onRemoveItem: (item: string) => void; }; // It is recommended to use memo for items to prevent re-renders of the entire grid // on item order changes (renderItem takes and index argument, thus it must be called // after every order change) const GridItem = memo(function GridItem({ item, onRemoveItem, fixed }: GridItemProps) { return ( <Sortable.Pressable onPress={onRemoveItem.bind(null, item)}> <GridCard style={fixed && { backgroundColor: '#999' }}>{item}</GridCard> </Sortable.Pressable> ); }); const styles = StyleSheet.create({ row: { columnGap: spacing.sm, flexDirection: 'row', flexWrap: 'wrap', rowGap: spacing.xs }, scrollViewContent: { gap: spacing.sm, padding: spacing.sm }, scrollViewGroup: { overflow: 'hidden', paddingHorizontal: spacing.none, paddingVertical: spacing.none }, title: { ...text.subHeading2, color: colors.foreground3 } }); ``` </details>
1 parent 2355424 commit d0cb59e

File tree

20 files changed

+253
-193
lines changed

20 files changed

+253
-193
lines changed

packages/react-native-sortables/src/components/shared/DraggableView/DraggableView.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ function DraggableView({
4545
}: DraggableViewProps) {
4646
const hasPortal = !!usePortalContext();
4747
const commonValuesContext = useCommonValuesContext();
48-
const { handleItemMeasurement, handleItemRemoval } = useMeasurementsContext();
48+
const { handleItemMeasurement, removeItemMeasurements } =
49+
useMeasurementsContext();
4950
const { activeItemKey, customHandle, itemsOverridesStyle } =
5051
commonValuesContext;
5152

@@ -59,8 +60,8 @@ function DraggableView({
5960
);
6061

6162
useEffect(() => {
62-
return () => handleItemRemoval(key);
63-
}, [key, handleItemRemoval]);
63+
return () => removeItemMeasurements(key);
64+
}, [key, removeItemMeasurements]);
6465

6566
const sharedCellProps = {
6667
decorationStyle,

packages/react-native-sortables/src/components/shared/SortableHandle.tsx

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type PropsWithChildren, useCallback, useMemo } from 'react';
1+
import { type PropsWithChildren, useCallback, useEffect, useMemo } from 'react';
22
import { View } from 'react-native';
33
import { GestureDetector } from 'react-native-gesture-handler';
44
import {
@@ -18,10 +18,13 @@ import { error } from '../../utils';
1818

1919
/** Props for the Sortable Handle component */
2020
export type SortableHandleProps = PropsWithChildren<{
21-
/** When true, the handle will not activate drag gesture
22-
* @default false
21+
/** Controls how the item behaves in the sortable component
22+
* - 'draggable': Item can be dragged and moves with reordering (default)
23+
* - 'non-draggable': Item cannot be dragged but moves with reordering
24+
* - 'fixed': Item stays in place and cannot be dragged
25+
* @default 'draggable'
2326
*/
24-
disabled?: boolean;
27+
mode?: 'draggable' | 'fixed' | 'non-draggable';
2528
}>;
2629

2730
export function SortableHandle(props: SortableHandleProps) {
@@ -41,7 +44,7 @@ export function SortableHandle(props: SortableHandleProps) {
4144

4245
function SortableHandleComponent({
4346
children,
44-
disabled = false
47+
mode = 'draggable'
4548
}: SortableHandleProps) {
4649
const { activeItemKey, activeItemPosition, containerRef } =
4750
useCommonValuesContext();
@@ -54,7 +57,13 @@ function SortableHandleComponent({
5457
);
5558
}
5659

57-
const { handleDimensions, handleOffset } = customHandleContext;
60+
const {
61+
activeHandleDimensions,
62+
activeHandleOffset,
63+
makeItemFixed,
64+
removeFixedItem
65+
} = customHandleContext;
66+
const dragEnabled = mode === 'draggable';
5867

5968
const viewRef = useAnimatedRef<View>();
6069
const gesture = useItemPanGesture(
@@ -63,6 +72,14 @@ function SortableHandleComponent({
6372
viewRef
6473
);
6574

75+
useEffect(() => {
76+
if (mode === 'fixed') {
77+
makeItemFixed(itemKey);
78+
}
79+
80+
return () => removeFixedItem(itemKey);
81+
}, [mode, itemKey, makeItemFixed, removeFixedItem]);
82+
6683
const measureHandle = useCallback(() => {
6784
'worklet';
6885
if (activeItemKey.value !== itemKey) {
@@ -85,8 +102,8 @@ function SortableHandleComponent({
85102
containerMeasurements;
86103
const { x: activeX, y: activeY } = activeItemPosition.value;
87104

88-
handleDimensions.value = { height, width };
89-
handleOffset.value = {
105+
activeHandleDimensions.value = { height, width };
106+
activeHandleOffset.value = {
90107
x: pageX - containerPageX - activeX,
91108
y: pageY - containerPageY - activeY
92109
};
@@ -95,22 +112,22 @@ function SortableHandleComponent({
95112
activeItemPosition,
96113
containerRef,
97114
itemKey,
98-
handleDimensions,
99-
handleOffset,
115+
activeHandleDimensions,
116+
activeHandleOffset,
100117
viewRef
101118
]);
102119

103120
// Measure the handle when the active item key changes
104121
useAnimatedReaction(() => activeItemKey.value, measureHandle);
105122

106123
const adjustedGesture = useMemo(
107-
() => gesture.enabled(!disabled),
108-
[disabled, gesture]
124+
() => gesture.enabled(dragEnabled),
125+
[dragEnabled, gesture]
109126
);
110127

111128
return (
112129
<GestureDetector gesture={adjustedGesture} userSelect='none'>
113-
<View ref={viewRef} onLayout={disabled ? undefined : measureHandle}>
130+
<View ref={viewRef} onLayout={dragEnabled ? measureHandle : undefined}>
114131
{children}
115132
</View>
116133
</GestureDetector>

packages/react-native-sortables/src/providers/layout/flex/updates/insert/index.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
FlexLayout,
1212
SortableFlexStrategyFactory
1313
} from '../../../../../types';
14-
import { reorderInsert } from '../../../../../utils';
14+
import { gt as gt_, lt as lt_, reorderInsert } from '../../../../../utils';
1515
import {
1616
getAdditionalSwapOffset,
1717
useDebugBoundingBox
@@ -39,14 +39,8 @@ const useInsertStrategy: SortableFlexStrategyFactory = ({
3939
const isColumn = flexDirection.startsWith('column');
4040
const isReverse = flexDirection.endsWith('reverse');
4141

42-
const gt = (a: number, b: number) => {
43-
'worklet';
44-
return isReverse ? a < b : a > b;
45-
};
46-
const lt = (a: number, b: number) => {
47-
'worklet';
48-
return isReverse ? a > b : a < b;
49-
};
42+
const gt = isReverse ? lt_ : gt_;
43+
const lt = isReverse ? gt_ : lt_;
5044

5145
let mainCoordinate: Coordinate;
5246
let crossCoordinate: Coordinate;
@@ -273,7 +267,8 @@ const useInsertStrategy: SortableFlexStrategyFactory = ({
273267
if (activeIndex === lastItemIndex) {
274268
return null;
275269
}
276-
return reorderInsert(indexToKey.value, activeIndex, lastItemIndex);
270+
// TODO - add fixed items support in flex
271+
return reorderInsert(indexToKey.value, activeIndex, lastItemIndex, {});
277272
}
278273
const mainAxisPosition = position[mainCoordinate];
279274

@@ -463,7 +458,13 @@ const useInsertStrategy: SortableFlexStrategyFactory = ({
463458

464459
if (newActiveIndex === activeIndex) return;
465460

466-
return reorderInsert(indexToKey.value, activeIndex, newActiveIndex);
461+
return reorderInsert(
462+
indexToKey.value,
463+
activeIndex,
464+
newActiveIndex,
465+
{}
466+
// TODO - add fixed items support in flex
467+
);
467468
};
468469
};
469470

packages/react-native-sortables/src/providers/layout/flex/updates/insert/utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,8 @@ export const getSwappedToGroupBeforeIndices = (
232232
indexToKey: reorderInsert(
233233
props.indexToKey,
234234
props.activeItemIndex,
235-
indexes.itemIndex
235+
indexes.itemIndex,
236+
{} // TODO - add fixed items support in flex
236237
)
237238
};
238239
};
@@ -249,7 +250,8 @@ export const getSwappedToGroupAfterIndices = (
249250
indexToKey: reorderInsert(
250251
props.indexToKey,
251252
props.activeItemIndex,
252-
indexes.itemIndex
253+
indexes.itemIndex,
254+
{} // TODO - add fixed items support in flex
253255
)
254256
};
255257
};

packages/react-native-sortables/src/providers/layout/grid/updates/common.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { SharedValue } from 'react-native-reanimated';
33
import type {
44
Coordinate,
55
Dimension,
6+
ReorderFunction,
67
SortableGridStrategyFactory
78
} from '../../../../types';
89
import { getAdditionalSwapOffset, useDebugBoundingBox } from '../../../shared';
@@ -11,16 +12,13 @@ import { getCrossIndex, getMainIndex } from '../utils';
1112
export const createGridStrategy =
1213
(
1314
useInactiveIndexToKey: () => SharedValue<Array<string>>,
14-
reorder: (
15-
indexToKey: Array<string>,
16-
activeIndex: number,
17-
newIndex: number
18-
) => Array<string>
15+
reorder: ReorderFunction
1916
): SortableGridStrategyFactory =>
2017
({
2118
containerHeight,
2219
containerWidth,
2320
crossGap,
21+
fixedItemKeys,
2422
indexToKey,
2523
isVertical,
2624
mainGap,
@@ -236,7 +234,8 @@ export const createGridStrategy =
236234
}
237235

238236
// Swap the active item with the item at the new index
239-
const itemsCount = indexToKey.value.length;
237+
const idxToKey = indexToKey.value;
238+
const itemsCount = idxToKey.length;
240239
const limitedCrossIndex = Math.max(
241240
0,
242241
Math.min(crossIndex, Math.floor((itemsCount - 1) / numGroups))
@@ -245,11 +244,14 @@ export const createGridStrategy =
245244
0,
246245
Math.min(limitedCrossIndex * numGroups + mainIndex, itemsCount - 1)
247246
);
248-
if (newIndex === activeIndex) {
247+
if (
248+
newIndex === activeIndex ||
249+
fixedItemKeys?.value[idxToKey[newIndex]!]
250+
) {
249251
return;
250252
}
251253

252254
// return the new order of items
253-
return reorder(indexToKey.value, activeIndex, newIndex);
255+
return reorder(idxToKey, activeIndex, newIndex, fixedItemKeys?.value);
254256
};
255257
};

packages/react-native-sortables/src/providers/layout/grid/updates/insert.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { useAnimatedReaction, useSharedValue } from 'react-native-reanimated';
22

33
import { EMPTY_ARRAY } from '../../../../constants';
44
import { areArraysDifferent, reorderInsert } from '../../../../utils';
5-
import { useCommonValuesContext } from '../../../shared';
5+
import {
6+
useCommonValuesContext,
7+
useCustomHandleContext
8+
} from '../../../shared';
69
import { createGridStrategy } from './common';
710

811
/**
@@ -20,21 +23,43 @@ import { createGridStrategy } from './common';
2023
*/
2124
function useInactiveIndexToKey() {
2225
const { activeItemKey, indexToKey } = useCommonValuesContext();
26+
const { fixedItemKeys } = useCustomHandleContext() ?? {};
2327
const result = useSharedValue<Array<string>>(EMPTY_ARRAY);
2428

2529
useAnimatedReaction(
2630
() => ({
27-
excluded: activeItemKey.value,
31+
excludedKey: activeItemKey.value,
32+
fixedKeys: fixedItemKeys?.value,
2833
idxToKey: indexToKey.value
2934
}),
30-
({ excluded, idxToKey }) => {
31-
if (excluded === null) {
35+
({ excludedKey, fixedKeys, idxToKey }) => {
36+
if (excludedKey === null) {
3237
result.value = EMPTY_ARRAY;
33-
} else {
34-
const othersArray = idxToKey.filter(key => key !== excluded);
35-
if (areArraysDifferent(othersArray, result.value)) {
36-
result.value = othersArray;
38+
return;
39+
}
40+
41+
let othersArray: Array<string>;
42+
43+
if (fixedKeys) {
44+
othersArray = [...idxToKey];
45+
let emptyIndex = idxToKey.indexOf(excludedKey);
46+
47+
for (let i = emptyIndex + 1; i < idxToKey.length; i++) {
48+
const itemKey = idxToKey[i]!;
49+
if (!fixedKeys[itemKey]) {
50+
othersArray[emptyIndex] = itemKey;
51+
emptyIndex = i;
52+
}
3753
}
54+
55+
// Remove the last empty slot and move all remaining items to the left
56+
othersArray.splice(emptyIndex, 1);
57+
} else {
58+
othersArray = idxToKey.filter(key => key !== excludedKey);
59+
}
60+
61+
if (areArraysDifferent(result.value, othersArray)) {
62+
result.value = othersArray;
3863
}
3964
}
4065
);

packages/react-native-sortables/src/providers/layout/grid/updates/swap.ts

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -30,41 +30,40 @@ function useInactiveIndexToKey() {
3030

3131
useAnimatedReaction(
3232
() => ({
33-
activeKey: activeItemKey.value,
33+
excludedKey: activeItemKey.value,
3434
idxToKey: indexToKey.value,
3535
keyToIdx: keyToIndex.value
3636
}),
37-
({ activeKey, idxToKey, keyToIdx }) => {
38-
const activeIndex = activeKey && keyToIdx[activeKey];
39-
40-
if (activeKey === null || activeIndex === undefined) {
37+
({ excludedKey, idxToKey, keyToIdx }) => {
38+
const excludedIndex = excludedKey ? keyToIdx[excludedKey] : undefined;
39+
if (excludedIndex === undefined) {
4140
result.value = EMPTY_ARRAY;
42-
} else {
43-
const othersArray = [...idxToKey];
44-
const activeIdx = activeIndex as number;
41+
return;
42+
}
4543

46-
for (
47-
let i = activeIdx;
48-
i + numGroups < othersArray.length;
49-
i += numGroups
50-
) {
51-
othersArray[i] = othersArray[i + numGroups]!;
52-
}
44+
const othersArray = [...idxToKey];
5345

54-
const activeColumnIndex = getMainIndex(activeIdx, numGroups);
55-
const lastRowIndex = Math.floor((othersArray.length - 1) / numGroups);
56-
for (
57-
let i = lastRowIndex * numGroups + activeColumnIndex;
58-
i < othersArray.length;
59-
i++
60-
) {
61-
othersArray[i] = othersArray[i + 1]!;
62-
}
63-
othersArray.pop();
46+
for (
47+
let i = excludedIndex;
48+
i + numGroups < othersArray.length;
49+
i += numGroups
50+
) {
51+
othersArray[i] = othersArray[i + numGroups]!;
52+
}
53+
54+
const activeColumnIndex = getMainIndex(excludedIndex, numGroups);
55+
const lastRowIndex = Math.floor((othersArray.length - 1) / numGroups);
56+
for (
57+
let i = lastRowIndex * numGroups + activeColumnIndex;
58+
i < othersArray.length;
59+
i++
60+
) {
61+
othersArray[i] = othersArray[i + 1]!;
62+
}
63+
othersArray.pop();
6464

65-
if (areArraysDifferent(othersArray, result.value)) {
66-
result.value = othersArray;
67-
}
65+
if (areArraysDifferent(othersArray, result.value)) {
66+
result.value = othersArray;
6867
}
6968
}
7069
);

0 commit comments

Comments
 (0)