Skip to content

Commit c278394

Browse files
committed
feat: add per-item lock support to prevent dragging locked items
1 parent b135e10 commit c278394

12 files changed

Lines changed: 284 additions & 8022 deletions

File tree

Example/navigation/index.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@ import {
1212
} from "@react-navigation/native";
1313
import { createNativeStackNavigator } from "@react-navigation/native-stack";
1414
import * as React from "react";
15-
import { ColorSchemeName, Pressable } from "react-native";
15+
import { ColorSchemeName } from "react-native";
1616

1717
import Colors from "../constants/Colors";
1818
import useColorScheme from "../hooks/useColorScheme";
19-
import NotFoundScreen from "../screens/NotFoundScreen";
2019
import BasicScreen from "../screens/BasicScreen";
2120
import SwipeableScreen from "../screens/SwipeableScreen";
2221
import {
@@ -27,6 +26,7 @@ import {
2726
import LinkingConfiguration from "./LinkingConfiguration";
2827
import NestedScreen from "../screens/NestedScreen";
2928
import HorizontalScreen from "../screens/HorizontalScreen";
29+
import LockedScreen from "../screens/LockedScreen";
3030

3131
export default function Navigation({
3232
colorScheme,
@@ -113,6 +113,14 @@ function BottomTabNavigator() {
113113
),
114114
}}
115115
/>
116+
<BottomTab.Screen
117+
name="Locked"
118+
component={LockedScreen}
119+
options={{
120+
title: "Locked",
121+
tabBarIcon: ({ color }) => <TabBarIcon name="lock" color={color} />,
122+
}}
123+
/>
116124
</BottomTab.Navigator>
117125
);
118126
}

Example/screens/LockedScreen.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React, { useCallback, useState } from "react";
2+
import { Text, StyleSheet, TouchableOpacity } from "react-native";
3+
import DraggableFlatList, {
4+
ScaleDecorator,
5+
RenderItemParams,
6+
} from "react-native-draggable-flatlist";
7+
8+
import { mapIndexToData } from "../utils";
9+
10+
type Item = ReturnType<typeof mapIndexToData> & { locked: boolean };
11+
12+
const NUM_ITEMS = 100;
13+
14+
const initialData: Item[] = [...Array(NUM_ITEMS)].map((_, index, arr) => ({
15+
...mapIndexToData(_, index, arr),
16+
locked: index % 4 === 0 && index !== 0,
17+
}));
18+
19+
export default function LockedScreen() {
20+
const [data, setData] = useState(initialData);
21+
22+
const renderItem = useCallback(
23+
({ item, drag, isActive, isLocked }: RenderItemParams<Item>) => {
24+
return (
25+
<ScaleDecorator>
26+
<TouchableOpacity
27+
activeOpacity={1}
28+
onLongPress={drag}
29+
disabled={isActive || isLocked}
30+
style={[
31+
styles.rowItem,
32+
{ backgroundColor: isActive ? "blue" : item.backgroundColor },
33+
isLocked && styles.lockedItem,
34+
]}
35+
>
36+
<Text style={styles.text}>{item.text}</Text>
37+
{isLocked && <Text style={styles.lockIcon}>🔒</Text>}
38+
</TouchableOpacity>
39+
</ScaleDecorator>
40+
);
41+
},
42+
[]
43+
);
44+
45+
return (
46+
<DraggableFlatList
47+
data={data}
48+
onDragEnd={({ data }) => setData(data)}
49+
keyExtractor={(item) => item.key}
50+
renderItem={renderItem}
51+
isItemLocked={(item) => item.locked}
52+
/>
53+
);
54+
}
55+
56+
const styles = StyleSheet.create({
57+
rowItem: {
58+
height: 100,
59+
flexDirection: "row",
60+
alignItems: "center",
61+
justifyContent: "center",
62+
gap: 8,
63+
},
64+
lockedItem: {
65+
opacity: 0.6,
66+
height: 150,
67+
borderWidth: 2,
68+
borderColor: "rgba(255,255,255,0.5)",
69+
borderStyle: "dashed",
70+
},
71+
text: {
72+
color: "white",
73+
fontSize: 24,
74+
fontWeight: "bold",
75+
textAlign: "center",
76+
},
77+
lockIcon: {
78+
fontSize: 20,
79+
},
80+
});

Example/tsconfig.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{
22
"extends": "expo/tsconfig.base",
33
"compilerOptions": {
4-
"strict": true
4+
"strict": true,
5+
"paths": {
6+
"react-native-draggable-flatlist": ["../src/index.tsx"]
7+
}
58
}
69
}

Example/types.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,19 @@ export type RootStackParamList = {
2121
NotFound: undefined;
2222
};
2323

24-
export type RootStackScreenProps<
25-
Screen extends keyof RootStackParamList
26-
> = NativeStackScreenProps<RootStackParamList, Screen>;
24+
export type RootStackScreenProps<Screen extends keyof RootStackParamList> =
25+
NativeStackScreenProps<RootStackParamList, Screen>;
2726

2827
export type RootTabParamList = {
2928
Basic: undefined;
3029
Nested: undefined;
3130
Swipeable: undefined;
3231
Horizontal: undefined;
32+
Locked: undefined;
3333
};
3434

35-
export type RootTabScreenProps<
36-
Screen extends keyof RootTabParamList
37-
> = CompositeScreenProps<
38-
BottomTabScreenProps<RootTabParamList, Screen>,
39-
NativeStackScreenProps<RootStackParamList>
40-
>;
35+
export type RootTabScreenProps<Screen extends keyof RootTabParamList> =
36+
CompositeScreenProps<
37+
BottomTabScreenProps<RootTabParamList, Screen>,
38+
NativeStackScreenProps<RootStackParamList>
39+
>;

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ All props are spread onto underlying [FlatList](https://facebook.github.io/react
3535
| :------------------------- | :---------------------------------------------------------------------------------------- | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
3636
| `data` | `T[]` | Items to be rendered. |
3737
| `ref` | `React.RefObject<FlatList<T>>` | FlatList ref to be forwarded to the underlying FlatList. |
38-
| `renderItem` | `(params: { item: T, getIndex: () => number \| undefined, drag: () => void, isActive: boolean}) => JSX.Element` | Call `drag` when the row should become active (i.e. in an `onLongPress` or `onPressIn`). |
38+
| `renderItem` | `(params: { item: T, getIndex: () => number \| undefined, drag: () => void, isActive: boolean, isLocked: boolean}) => JSX.Element` | Call `drag` when the row should become active (i.e. in an `onLongPress` or `onPressIn`). `isLocked` is `true` when the item is locked via `isItemLocked`. |
3939
| `renderPlaceholder` | `(params: { item: T, index: number }) => React.ReactNode` | Component to be rendered underneath the hovering component |
4040
| `keyExtractor` | `(item: T, index: number) => string` | Unique key for each item (required) |
4141
| `onDragBegin` | `(index: number) => void` | Called when row becomes active. |
@@ -49,6 +49,7 @@ All props are spread onto underlying [FlatList](https://facebook.github.io/react
4949
| `onPlaceholderIndexChange` | `(index: number) => void` | Called when the index of the placeholder changes |
5050
| `dragItemOverflow` | `boolean` | If true, dragged item follows finger beyond list boundary. |
5151
| `dragHitSlop` | `object: {top: number, left: number, bottom: number, right: number}` | Enables control over what part of the connected view area can be used to begin recognizing the gesture. Numbers need to be non-positive (only possible to reduce responsive area). |
52+
| `isItemLocked` | `(item: T, index: number) => boolean` | If returns `true` for an item, that item cannot be dragged and will not move when other items are dragged past it. Other items reorder around locked items, which maintain their absolute position in the array. |
5253
| `debug` | `boolean` | Enables debug logging and animation debugger. |
5354
| `containerStyle` | `StyleProp<ViewStyle>` | Style of the main component. |
5455
| `simultaneousHandlers` | `React.Ref<any>` or `React.Ref<any>[]` | References to other gesture handlers, mainly useful when using this component within a `ScrollView`. See [Cross handler interactions](https://docs.swmansion.com/react-native-gesture-handler/docs/interactions/). |
@@ -59,6 +60,31 @@ All props are spread onto underlying [FlatList](https://facebook.github.io/react
5960

6061

6162

63+
## Locked Items
64+
65+
Use the `isItemLocked` prop to fix items in place. Locked items cannot be dragged and stay at their absolute position while other items reorder around them.
66+
67+
```tsx
68+
<DraggableFlatList
69+
data={data}
70+
keyExtractor={(item) => item.id}
71+
isItemLocked={(item) => item.fixed === true}
72+
renderItem={({ item, drag, isActive, isLocked }) => (
73+
<ScaleDecorator>
74+
<TouchableOpacity
75+
onLongPress={drag}
76+
disabled={isActive || isLocked}
77+
style={styles.rowItem}
78+
>
79+
<Text>{item.label}</Text>
80+
{isLocked && <Text>📌</Text>}
81+
</TouchableOpacity>
82+
</ScaleDecorator>
83+
)}
84+
onDragEnd={({ data }) => setData(data)}
85+
/>
86+
```
87+
6288
## Cell Decorators
6389

6490
Cell Decorators are an easy way to add common hover animations. For example, wrapping `renderItem` in the `<ScaleDecorator>` component will automatically scale up the active item while hovering (see example below).

src/components/CellRendererComponent.tsx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,9 @@ function CellRendererComponent<T>(props: Props<T>) {
3434
const viewRef = useRef<Animated.View>(null);
3535
const { cellDataRef, propsRef, containerRef } = useRefs<T>();
3636

37-
const { horizontalAnim, scrollOffset } = useAnimatedValues();
38-
const {
39-
activeKey,
40-
keyExtractor,
41-
horizontal,
42-
layoutAnimationDisabled,
43-
} = useDraggableFlatListContext<T>();
37+
const { horizontalAnim, scrollOffset, cellSizesAnim } = useAnimatedValues();
38+
const { activeKey, keyExtractor, horizontal, layoutAnimationDisabled } =
39+
useDraggableFlatListContext<T>();
4440

4541
const key = keyExtractor(item, index);
4642
const offset = useSharedValue(-1);
@@ -80,6 +76,12 @@ function CellRendererComponent<T>(props: Props<T>) {
8076

8177
size.value = cellSize;
8278
offset.value = cellOffset;
79+
runOnUI(() => {
80+
"worklet";
81+
const newSizes = [...cellSizesAnim.value];
82+
newSizes[index] = cellSize;
83+
cellSizesAnim.value = newSizes;
84+
})();
8385
};
8486

8587
const onFail = () => {
@@ -121,11 +123,8 @@ function CellRendererComponent<T>(props: Props<T>) {
121123
};
122124
}, [isActive, horizontal]);
123125

124-
const {
125-
itemEnteringAnimation,
126-
itemExitingAnimation,
127-
itemLayoutAnimation,
128-
} = propsRef.current;
126+
const { itemEnteringAnimation, itemExitingAnimation, itemLayoutAnimation } =
127+
propsRef.current;
129128

130129
useEffect(() => {
131130
// NOTE: Keep an eye on reanimated LayoutAnimation refactor:

src/components/DraggableFlatList.tsx

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,59 @@ type OnViewableItemsChangedCallback<T> = Exclude<
5252
undefined | null
5353
>;
5454

55-
const AnimatedFlatList = (Animated.createAnimatedComponent(
55+
const AnimatedFlatList = Animated.createAnimatedComponent(
5656
FlatList
57-
) as unknown) as <T>(props: RNGHFlatListProps<T>) => React.ReactElement;
57+
) as unknown as <T>(props: RNGHFlatListProps<T>) => React.ReactElement;
58+
59+
function reorderWithLockedItems<T>(
60+
data: T[],
61+
from: number,
62+
to: number,
63+
isItemLocked?: (item: T, index: number) => boolean
64+
): T[] {
65+
if (from === to) return [...data];
66+
67+
if (!isItemLocked) {
68+
const newData = [...data];
69+
newData.splice(from, 1);
70+
newData.splice(to, 0, data[from]);
71+
return newData;
72+
}
73+
74+
const lockedAt = new Map<number, T>();
75+
data.forEach((item, i) => {
76+
if (isItemLocked(item, i)) lockedAt.set(i, item);
77+
});
78+
79+
const movable = data.filter((_, i) => !lockedAt.has(i));
80+
81+
let movableFrom = 0;
82+
for (let i = 0; i < from; i++) {
83+
if (!lockedAt.has(i)) movableFrom++;
84+
}
85+
86+
let count = 0;
87+
for (let i = 0; i < data.length; i++) {
88+
if (i === from || lockedAt.has(i)) continue;
89+
const gapIndex = i < from ? i : i - 1;
90+
if (gapIndex < to) count++;
91+
}
92+
const movableTo = count;
93+
94+
const reordered = [...movable];
95+
const [movedItem] = reordered.splice(movableFrom, 1);
96+
reordered.splice(movableTo, 0, movedItem);
97+
98+
const result = new Array(data.length) as T[];
99+
lockedAt.forEach((item, i) => {
100+
result[i] = item;
101+
});
102+
let idx = 0;
103+
for (let i = 0; i < result.length; i++) {
104+
if (!lockedAt.has(i)) result[i] = reordered[idx++];
105+
}
106+
return result;
107+
}
58108

59109
function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
60110
const {
@@ -82,6 +132,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
82132
viewableIndexMin,
83133
viewableIndexMax,
84134
disabled,
135+
lockedIndicesAnim,
85136
} = useAnimatedValues();
86137

87138
const reset = useStableCallback(() => {
@@ -96,7 +147,8 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
96147
const {
97148
dragHitSlop = DEFAULT_PROPS.dragHitSlop,
98149
scrollEnabled = DEFAULT_PROPS.scrollEnabled,
99-
activationDistance: activationDistanceProp = DEFAULT_PROPS.activationDistance,
150+
activationDistance:
151+
activationDistanceProp = DEFAULT_PROPS.activationDistance,
100152
} = props;
101153

102154
let [activeKey, setActiveKey] = useState<string | null>(null);
@@ -144,9 +196,22 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
144196
});
145197
}, [props.data, keyExtractor, keyToIndexRef]);
146198

199+
useLayoutEffect(() => {
200+
const { isItemLocked, data } = propsRef.current;
201+
lockedIndicesAnim.value = isItemLocked
202+
? data.map((item, index) => isItemLocked(item, index))
203+
: [];
204+
}, [props.data, props.isItemLocked]);
205+
147206
const drag = useStableCallback((activeKey: string) => {
148207
if (disabled.value) return;
149208
const index = keyToIndexRef.current.get(activeKey);
209+
if (
210+
index !== undefined &&
211+
propsRef.current.isItemLocked?.(propsRef.current.data[index], index)
212+
) {
213+
return;
214+
}
150215
const cellData = cellDataRef.current.get(activeKey);
151216
if (cellData) {
152217
activeCellOffset.value = cellData.measurements.offset;
@@ -200,18 +265,19 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
200265
if (index !== keyToIndexRef.current.get(key)) {
201266
keyToIndexRef.current.set(key, index);
202267
}
203-
268+
const isLocked = props.isItemLocked?.(item, index) ?? false;
204269
return (
205270
<RowItem
206271
item={item}
207272
itemKey={key}
208273
renderItem={props.renderItem}
209274
drag={drag}
210275
extraData={props.extraData}
276+
isLocked={isLocked}
211277
/>
212278
);
213279
},
214-
[props.renderItem, props.extraData, drag, keyExtractor]
280+
[props.renderItem, props.extraData, props.isItemLocked, drag, keyExtractor]
215281
);
216282

217283
const onRelease = useStableCallback((index: number) => {
@@ -220,16 +286,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
220286

221287
const onDragEnd = useStableCallback(
222288
({ from, to }: { from: number; to: number }) => {
223-
const { onDragEnd, data } = props;
224-
225-
const newData = [...data];
226-
if (from !== to) {
227-
newData.splice(from, 1);
228-
newData.splice(to, 0, data[from]);
229-
}
230-
289+
const { onDragEnd, data, isItemLocked } = props;
290+
const newData = reorderWithLockedItems(data, from, to, isItemLocked);
231291
onDragEnd?.({ from, to, data: newData });
232-
233292
setActiveKey(null);
234293
}
235294
);

0 commit comments

Comments
 (0)