react-native-sortable-dynamic 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,175 @@
1
+ {
2
+ "name": "react-native-sortable-dynamic",
3
+ "version": "0.1.0",
4
+ "description": "This package provides a highly customizable, animated, and performant sortable grid list component for React Native. It allows users to easily reorder grid items through drag-and-drop gestures, with smooth animations powered by react-native-reanimated and gesture handling by react-native-gesture-handler.",
5
+ "source": "./src/index.js",
6
+ "main": "./lib/commonjs/index.js",
7
+ "module": "./lib/module/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./lib/module/index.js",
11
+ "require": "./lib/commonjs/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "lib",
17
+ "android",
18
+ "ios",
19
+ "cpp",
20
+ "*.podspec",
21
+ "!ios/build",
22
+ "!android/build",
23
+ "!android/gradle",
24
+ "!android/gradlew",
25
+ "!android/gradlew.bat",
26
+ "!android/local.properties",
27
+ "!**/__tests__",
28
+ "!**/__fixtures__",
29
+ "!**/__mocks__",
30
+ "!**/.*"
31
+ ],
32
+ "scripts": {
33
+ "example": "yarn workspace react-native-sortable-dynamic-example",
34
+ "test": "jest",
35
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
36
+ "clean": "del-cli lib",
37
+ "prepare": "bob build",
38
+ "release": "release-it"
39
+ },
40
+ "keywords": [
41
+ "react-native",
42
+ "ios",
43
+ "android"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/AdamLee321/react-native-sortable-dynamic.git"
48
+ },
49
+ "author": "Adam Lee <adam@hkc.ie> (https://github.com/AdamLee321)",
50
+ "license": "MIT",
51
+ "bugs": {
52
+ "url": "https://github.com/AdamLee321/react-native-sortable-dynamic/issues"
53
+ },
54
+ "homepage": "https://github.com/AdamLee321/react-native-sortable-dynamic#readme",
55
+ "publishConfig": {
56
+ "registry": "https://registry.npmjs.org/"
57
+ },
58
+ "devDependencies": {
59
+ "@commitlint/config-conventional": "^17.0.2",
60
+ "@evilmartians/lefthook": "^1.5.0",
61
+ "@react-native/eslint-config": "^0.73.1",
62
+ "@release-it/conventional-changelog": "^5.0.0",
63
+ "@types/jest": "^29.5.5",
64
+ "@types/react": "^18.2.44",
65
+ "commitlint": "^17.0.2",
66
+ "del-cli": "^5.1.0",
67
+ "eslint": "^8.51.0",
68
+ "eslint-config-prettier": "^9.0.0",
69
+ "eslint-plugin-prettier": "^5.0.1",
70
+ "jest": "^29.7.0",
71
+ "prettier": "^3.0.3",
72
+ "react": "18.2.0",
73
+ "react-native": "0.74.5",
74
+ "react-native-builder-bob": "^0.30.2",
75
+ "release-it": "^15.0.0",
76
+ "typescript": "^5.2.2",
77
+ "react-native-reanimated": "^3.0.0",
78
+ "react-native-gesture-handler": "^2.0.0"
79
+ },
80
+ "resolutions": {
81
+ "@types/react": "^18.2.44"
82
+ },
83
+ "peerDependencies": {
84
+ "react": "*",
85
+ "react-native": "*",
86
+ "react-native-reanimated": "^2.0.0",
87
+ "react-native-gesture-handler": "^1.10.0"
88
+ },
89
+ "workspaces": [
90
+ "example"
91
+ ],
92
+ "packageManager": "yarn@3.6.1",
93
+ "jest": {
94
+ "preset": "react-native",
95
+ "modulePathIgnorePatterns": [
96
+ "<rootDir>/example/node_modules",
97
+ "<rootDir>/lib/"
98
+ ]
99
+ },
100
+ "commitlint": {
101
+ "extends": [
102
+ "@commitlint/config-conventional"
103
+ ]
104
+ },
105
+ "release-it": {
106
+ "git": {
107
+ "commitMessage": "chore: release ${version}",
108
+ "tagName": "v${version}"
109
+ },
110
+ "npm": {
111
+ "publish": true
112
+ },
113
+ "github": {
114
+ "release": true
115
+ },
116
+ "plugins": {
117
+ "@release-it/conventional-changelog": {
118
+ "preset": "angular"
119
+ }
120
+ }
121
+ },
122
+ "eslintConfig": {
123
+ "root": true,
124
+ "extends": [
125
+ "@react-native",
126
+ "prettier"
127
+ ],
128
+ "rules": {
129
+ "react/react-in-jsx-scope": "off",
130
+ "prettier/prettier": [
131
+ "error",
132
+ {
133
+ "quoteProps": "consistent",
134
+ "singleQuote": true,
135
+ "tabWidth": 2,
136
+ "trailingComma": "es5",
137
+ "useTabs": false
138
+ }
139
+ ]
140
+ }
141
+ },
142
+ "eslintIgnore": [
143
+ "node_modules/",
144
+ "lib/"
145
+ ],
146
+ "prettier": {
147
+ "quoteProps": "consistent",
148
+ "singleQuote": true,
149
+ "tabWidth": 2,
150
+ "trailingComma": "es5",
151
+ "useTabs": false
152
+ },
153
+ "react-native-builder-bob": {
154
+ "source": "src",
155
+ "output": "lib",
156
+ "targets": [
157
+ [
158
+ "commonjs",
159
+ {
160
+ "esm": true
161
+ }
162
+ ],
163
+ [
164
+ "module",
165
+ {
166
+ "esm": true
167
+ }
168
+ ]
169
+ ]
170
+ },
171
+ "create-react-native-library": {
172
+ "type": "library",
173
+ "version": "0.41.2"
174
+ }
175
+ }
package/src/Config.js ADDED
@@ -0,0 +1,81 @@
1
+ import { Dimensions } from 'react-native';
2
+ import { Easing } from 'react-native-reanimated';
3
+ import React, { createContext, useContext } from 'react';
4
+
5
+ // Get screen width to calculate dynamic sizes
6
+ const { width } = Dimensions.get('window');
7
+
8
+ // Default configuration for the sortable list
9
+ const defaultConfig = {
10
+ MARGIN: 10, // Default margin between items
11
+ COL: 2, // Default number of columns
12
+ // Default size for each item, calculated based on the number of columns and margin
13
+ SIZE: width / 2 - 10, // (width / COL - MARGIN)
14
+ };
15
+
16
+ // Create a Context for the sortable list configuration
17
+ const ConfigContext = createContext(defaultConfig);
18
+
19
+ // Custom hook to use the sortable configuration context
20
+ export const useSortableConfig = () => useContext(ConfigContext);
21
+
22
+ // Configuration for animation settings
23
+ export const animationConfig = {
24
+ easing: Easing.inOut(Easing.ease),
25
+ duration: 350,
26
+ };
27
+
28
+ // Helper function to calculate the item's position based on its index
29
+ // This is used to position items in a grid layout
30
+ export const getPosition = (position, COL, SIZE) => {
31
+ 'worklet'; // Necessary for Reanimated 2 to run this function on the UI thread
32
+ return {
33
+ x: position % COL === 0 ? 0 : SIZE * (position % COL),
34
+ y: Math.floor(position / COL) * SIZE,
35
+ };
36
+ };
37
+
38
+ // Helper function to determine the new order of items during drag-and-drop
39
+ export const getOrder = (tx, ty, max, COL, SIZE) => {
40
+ 'worklet'; // Necessary for Reanimated 2 to run this function on the UI thread
41
+ const x = Math.round(tx / SIZE) * SIZE;
42
+ const y = Math.round(ty / SIZE) * SIZE;
43
+ const row = Math.max(y, 0) / SIZE;
44
+ const col = Math.max(x, 0) / SIZE;
45
+ return Math.min(row * COL + col, max);
46
+ };
47
+
48
+ /**
49
+ * SortableListProvider component
50
+ *
51
+ * Wrap your sortable list components with this provider to set custom configuration.
52
+ *
53
+ * @param {Object} config - Custom configuration to override the default settings.
54
+ * @param {number} config.MARGIN - Margin between items.
55
+ * @param {number} config.COL - Number of columns in the grid.
56
+ * @param {React.ReactNode} children - Child components that will use this configuration.
57
+ *
58
+ * Usage:
59
+ *
60
+ * <SortableListProvider config={{ MARGIN: 15, COL: 3 }}>
61
+ * <YourSortableList />
62
+ * </SortableListProvider>
63
+ */
64
+ const SortableListProvider = ({ children, config }) => {
65
+ // Merge custom config with the default configuration
66
+ const mergedConfig = {
67
+ ...defaultConfig,
68
+ ...config,
69
+ };
70
+
71
+ // Recalculate SIZE based on COL and MARGIN
72
+ mergedConfig.SIZE = width / mergedConfig.COL - mergedConfig.MARGIN;
73
+
74
+ return (
75
+ <ConfigContext.Provider value={mergedConfig}>
76
+ {children}
77
+ </ConfigContext.Provider>
78
+ );
79
+ };
80
+
81
+ export default SortableListProvider;
package/src/Item.js ADDED
@@ -0,0 +1,196 @@
1
+ import React, { useEffect } from 'react';
2
+ import { Dimensions, StyleSheet } from 'react-native';
3
+ import Animated, {
4
+ useAnimatedGestureHandler,
5
+ useAnimatedStyle,
6
+ useAnimatedReaction,
7
+ withSpring,
8
+ scrollTo,
9
+ withTiming,
10
+ useSharedValue,
11
+ runOnJS,
12
+ } from 'react-native-reanimated';
13
+ import { PanGestureHandler } from 'react-native-gesture-handler';
14
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
15
+ import { animationConfig, getOrder, getPosition } from './Config';
16
+ import { useSortableConfig } from './Config';
17
+
18
+ /**
19
+ * Item Component
20
+ *
21
+ * This component represents a draggable item in the sortable grid. It manages the gesture handling
22
+ * and animations needed to drag and reorder the item within the grid.
23
+ *
24
+ * Props:
25
+ * @param {React.ReactNode} children - The content to render inside the item.
26
+ * @param {object} positions - Shared value that contains the current positions of all items.
27
+ * @param {number} id - Unique identifier for the item.
28
+ * @param {function} onDragEnd - Callback function triggered when dragging ends.
29
+ * @param {object} scrollView - Reference to the scroll view for scrolling during drag.
30
+ * @param {object} scrollY - Shared value representing the current scroll position.
31
+ * @param {boolean} editing - Whether the list is in editing mode.
32
+ * @param {boolean} draggable - Whether the item is draggable (default: true).
33
+ * @param {Array} tiles - Array of tile items used to check reorderable state.
34
+ */
35
+
36
+ const Item = ({
37
+ children,
38
+ positions,
39
+ id,
40
+ onDragEnd,
41
+ scrollView,
42
+ scrollY,
43
+ editing,
44
+ draggable = true,
45
+ tiles,
46
+ }) => {
47
+ // Get safe area insets for accurate height calculation
48
+ const inset = useSafeAreaInsets();
49
+ const containerHeight =
50
+ Dimensions.get('window').height - inset.top - inset.bottom;
51
+
52
+ // Get the configuration for columns and size
53
+ const { COL, SIZE } = useSortableConfig();
54
+
55
+ // Calculate content height based on the number of items
56
+ const contentHeight = (Object.keys(positions.value).length / COL) * SIZE;
57
+ const isGestureActive = useSharedValue(false); // Whether the item is being actively dragged
58
+
59
+ // Calculate initial position of the item
60
+ const position = getPosition(positions.value[id], COL, SIZE);
61
+ const translateX = useSharedValue(position.x);
62
+ const translateY = useSharedValue(position.y);
63
+
64
+ // Effect to reset isGestureActive when not in editing mode
65
+ useEffect(() => {
66
+ if (!editing) {
67
+ isGestureActive.value = false;
68
+ }
69
+ }, [editing, isGestureActive]);
70
+
71
+ // React to changes in the positions object
72
+ useAnimatedReaction(
73
+ () => positions.value[id], // Track changes to this item's position
74
+ (newOrder) => {
75
+ if (!isGestureActive.value) {
76
+ const pos = getPosition(newOrder, COL, SIZE);
77
+ translateX.value = withTiming(pos.x, animationConfig);
78
+ translateY.value = withTiming(pos.y, animationConfig);
79
+ }
80
+ }
81
+ );
82
+
83
+ // Gesture handler for dragging
84
+ const onGestureEvent = useAnimatedGestureHandler({
85
+ onStart: (_, ctx) => {
86
+ if (editing && draggable) {
87
+ // Store the starting position
88
+ ctx.x = translateX.value;
89
+ ctx.y = translateY.value;
90
+ isGestureActive.value = false; // TODO: Set to false when grouping is implemented
91
+ }
92
+ },
93
+ onActive: ({ translationX, translationY }, ctx) => {
94
+ if (editing && draggable) {
95
+ // Calculate new position
96
+ translateX.value = ctx.x + translationX;
97
+ translateY.value = ctx.y + translationY;
98
+
99
+ // Calculate new order based on position
100
+ const newOrder = getOrder(
101
+ translateX.value,
102
+ translateY.value,
103
+ Object.keys(positions.value).length - 1,
104
+ COL,
105
+ SIZE
106
+ );
107
+
108
+ const oldOrder = positions.value[id];
109
+ if (newOrder !== oldOrder) {
110
+ // Find the item to swap positions with
111
+ const idToSwap = Object.keys(positions.value).find(
112
+ (key) => positions.value[key] === newOrder
113
+ );
114
+
115
+ // Only swap if the target item is reorderable
116
+ const targetItem = tiles.find((tile) => tile.id === Number(idToSwap));
117
+ if (idToSwap && targetItem?.reorderable !== false) {
118
+ const newPositions = { ...positions.value };
119
+ newPositions[id] = newOrder;
120
+ newPositions[idToSwap] = oldOrder;
121
+ positions.value = newPositions;
122
+ }
123
+ }
124
+
125
+ // Handle scrolling during drag
126
+ const lowerBound = scrollY.value;
127
+ const upperBound = lowerBound + containerHeight - SIZE;
128
+ const maxScroll = contentHeight - containerHeight;
129
+ const leftToScrollDown = maxScroll - scrollY.value;
130
+
131
+ // Scroll up
132
+ if (translateY.value < lowerBound) {
133
+ const diff = Math.min(lowerBound - translateY.value, lowerBound);
134
+ scrollY.value -= diff;
135
+ scrollTo(scrollView, 0, scrollY.value, false);
136
+ ctx.y -= diff;
137
+ translateY.value = ctx.y + translationY;
138
+ }
139
+ // Scroll down
140
+ if (translateY.value > upperBound) {
141
+ const diff = Math.min(
142
+ translateY.value - upperBound,
143
+ leftToScrollDown
144
+ );
145
+ scrollY.value += diff;
146
+ scrollTo(scrollView, 0, scrollY.value, false);
147
+ ctx.y += diff;
148
+ translateY.value = ctx.y + translationY;
149
+ }
150
+ }
151
+ },
152
+ onEnd: () => {
153
+ if (draggable) {
154
+ // Snap the item back into its place when the drag ends
155
+ const newPosition = getPosition(positions.value[id], COL, SIZE);
156
+ translateX.value = withTiming(newPosition.x, animationConfig, () => {
157
+ isGestureActive.value = false; // Set gesture to inactive
158
+ runOnJS(onDragEnd)(positions.value); // Call onDragEnd on the JS thread
159
+ });
160
+ translateY.value = withTiming(newPosition.y, animationConfig);
161
+ }
162
+ },
163
+ });
164
+
165
+ // Animated style for the item
166
+ const style = useAnimatedStyle(() => {
167
+ const zIndex = isGestureActive.value ? 100 : 0; // Bring the item to front when active
168
+ const scale =
169
+ editing && isGestureActive.value ? withSpring(1.05) : withSpring(1); // Slightly enlarge the item when dragging
170
+ return {
171
+ position: 'absolute',
172
+ top: 0,
173
+ left: 0,
174
+ width: SIZE,
175
+ height: SIZE,
176
+ zIndex,
177
+ transform: [
178
+ { translateX: translateX.value },
179
+ { translateY: translateY.value },
180
+ { scale },
181
+ ],
182
+ };
183
+ });
184
+
185
+ return (
186
+ <Animated.View style={style}>
187
+ <PanGestureHandler enabled={editing} onGestureEvent={onGestureEvent}>
188
+ <Animated.View style={StyleSheet.absoluteFill}>
189
+ {children}
190
+ </Animated.View>
191
+ </PanGestureHandler>
192
+ </Animated.View>
193
+ );
194
+ };
195
+
196
+ export default Item;
@@ -0,0 +1,86 @@
1
+ import React, { memo } from 'react';
2
+ import Animated, {
3
+ useAnimatedRef,
4
+ useAnimatedScrollHandler,
5
+ useSharedValue,
6
+ } from 'react-native-reanimated';
7
+ import Item from './Item';
8
+ import { useSortableConfig } from './Config';
9
+
10
+ /**
11
+ * SortableList Component
12
+ *
13
+ * This component renders a scrollable list of items that can be reordered using drag-and-drop gestures.
14
+ * It manages the overall layout of the items and provides the necessary props to each child `Item` component
15
+ * to enable dragging, scrolling, and reordering functionality.
16
+ *
17
+ * Props:
18
+ * @param {React.ReactNode[]} children - The list of items to render inside the sortable list.
19
+ * @param {boolean} editing - Whether the list is in editing mode, enabling drag-and-drop.
20
+ * @param {Array} tiles - Array of tile data to manage the reordering state.
21
+ * @param {function} onDragEnd - Callback function called with the updated positions when the drag ends.
22
+ *
23
+ * Usage:
24
+ *
25
+ * <SortableList editing={isEditing} tiles={tiles} onDragEnd={handleDragEnd}>
26
+ * {tiles.map((tile) => (
27
+ * <YourTileComponent key={tile.id} id={tile.id} />
28
+ * ))}
29
+ * </SortableList>
30
+ */
31
+
32
+ const SortableList = ({ children, editing, tiles, onDragEnd }) => {
33
+ // Get the configuration for columns and size from context
34
+ const { COL, SIZE } = useSortableConfig();
35
+
36
+ // Shared values to track scrolling and item positions
37
+ const scrollY = useSharedValue(0); // Current scroll position
38
+ const scrollView = useAnimatedRef(); // Reference to the scroll view
39
+ const positions = useSharedValue(
40
+ children.reduce(
41
+ (acc, child, index) => ({ ...acc, [child.props.id]: index }),
42
+ {}
43
+ )
44
+ );
45
+
46
+ // Scroll event handler to update scrollY shared value
47
+ const onScroll = useAnimatedScrollHandler({
48
+ onScroll: ({ contentOffset: { y } }) => {
49
+ scrollY.value = y;
50
+ },
51
+ });
52
+
53
+ return (
54
+ <Animated.ScrollView
55
+ onScroll={onScroll}
56
+ ref={scrollView}
57
+ contentContainerStyle={{
58
+ // Calculate the total height needed for the scroll view content
59
+ height: Math.ceil(children.length / COL) * SIZE,
60
+ }}
61
+ showsVerticalScrollIndicator={false}
62
+ bounces={false}
63
+ scrollEventThrottle={16} // Control scroll event frequency
64
+ >
65
+ {/* Render each child wrapped in an Item component */}
66
+ {children.map((child) => (
67
+ <Item
68
+ key={child.props.id}
69
+ id={child.props.id}
70
+ positions={positions}
71
+ editing={editing}
72
+ draggable={child.props.draggable}
73
+ reorderable={child.props.reorderable}
74
+ tiles={tiles}
75
+ onDragEnd={onDragEnd}
76
+ scrollView={scrollView}
77
+ scrollY={scrollY}
78
+ >
79
+ {child}
80
+ </Item>
81
+ ))}
82
+ </Animated.ScrollView>
83
+ );
84
+ };
85
+
86
+ export default memo(SortableList);
package/src/Tile.js ADDED
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { TouchableOpacity } from 'react-native';
3
+ import { useSortableConfig } from './Config';
4
+
5
+ /**
6
+ * Tile Component
7
+ *
8
+ * This component represents a single tile in the sortable grid. It is designed to be flexible and
9
+ * allows for user interactions such as press and long press. The size of each tile is dynamically
10
+ * adjusted based on the configuration provided by `SortableListProvider`.
11
+ *
12
+ * Props:
13
+ * @param {function} onPress - Callback function called when the tile is pressed.
14
+ * @param {function} onLongPress - Callback function called when the tile is long-pressed.
15
+ * @param {number} activeOpacity - The opacity of the tile when it is pressed. Default is 0.7.
16
+ * @param {React.ReactNode} children - The content to render inside the tile.
17
+ *
18
+ * Usage:
19
+ *
20
+ * <Tile onPress={handlePress} onLongPress={handleLongPress}>
21
+ * <Text>Tile Content</Text>
22
+ * </Tile>
23
+ */
24
+
25
+ const Tile = ({ onPress, onLongPress, activeOpacity = 0.7, children }) => {
26
+ // Get the size of the tile from the sortable config context
27
+ const { SIZE } = useSortableConfig();
28
+
29
+ // Define the style for the tile, ensuring that its size is consistent across the grid
30
+ const containerStyle = {
31
+ width: SIZE,
32
+ height: SIZE,
33
+ };
34
+
35
+ return (
36
+ <TouchableOpacity
37
+ style={containerStyle}
38
+ onPress={onPress}
39
+ onLongPress={onLongPress}
40
+ activeOpacity={activeOpacity} // Control the opacity when the tile is pressed
41
+ >
42
+ {children}
43
+ </TouchableOpacity>
44
+ );
45
+ };
46
+
47
+ export default Tile;
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default as SortableListProvider } from './Config';
2
+ export { default as SortableList } from './SortableList';
3
+ export { default as Tile } from './Tile';