Skip to content

Commit

Permalink
Improve/fix drag-and-drop (#175)
Browse files Browse the repository at this point in the history
* Install rn-gesture-handler and rn-reanimated

* Update drag behavior with rn-gesture-handler

Leave out event-press and grid-press for now

* Update event-press and long-press with rn-gh

Leave out grid touches for now

* Update grid press and long-press with rn-gh

Create ViewWithTouchable, patches TouchableWithoutFeedback from RNGH
Reduce from multiple touchables to just one touchable per week

* Improve callbacks in Events (minor)

Do not recreate callbacks in each render
Names used: "handleX" for component methods, "onX" for props

* Update lib requirements

* Update README (minor)

* Remove extra whitespace in package.json

* Fix prettier issues

(only the issues introduced in this PR)

* Setup testing with rn-gh and rn-reanimated libs

* Rename tests/config to tests/setup

Use standard name

* Add event-drag test

* Fix linter in utils-gestures.js

* Fix merge issues

* Update docs

Mention breaking change in changelog
Tab formatting in readme (automatic)

* Improve test var names

Improve ViewWithTouchable docs
Remove unnecessary key
  • Loading branch information
pdpino authored Jun 8, 2022
1 parent 4bda886 commit 321ef92
Show file tree
Hide file tree
Showing 17 changed files with 821 additions and 187 deletions.
103 changes: 56 additions & 47 deletions README.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
'react-native-reanimated/plugin', // Must be the last one in the list
],
};
4 changes: 3 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Changelog
## Unreleased
- Breaking change: add [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/docs/installation/) v2 and [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation) v2 as peer dependencies.
# 0.16.0 - 2022-06-04
- Improve performance [#210](https://github.com/hoangnm/react-native-week-view/pull/210)
## 0.15.0 - 2022-05-27
Expand All @@ -24,7 +26,7 @@
## 0.7.0 - 2021-11-11
- Add isRefreshing and RefreshComponent props [#112](https://github.com/hoangnm/react-native-week-view/pull/112)
- Fix events break when changing hoursInDisplay [#133](https://github.com/hoangnm/react-native-week-view/pull/133)
- Allow to drag-drop event [#143](https://github.com/hoangnm/react-native-week-view/pull/143)
- Allow to drag-drop event [#143](https://github.com/hoangnm/react-native-week-view/pull/143)
- Support react-native above v0.59.0 only
## 0.6.1 - 2021-08-22

Expand Down
3 changes: 3 additions & 0 deletions example/android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@
# Add any project specific keep options here:
-keep class com.facebook.hermes.unicode.** { *; }
-keep class com.facebook.jni.** { *; }

-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
import com.facebook.soloader.SoLoader;
import com.facebook.react.bridge.JSIModulePackage;
import com.swmansion.reanimated.ReanimatedJSIModulePackage;
import java.lang.reflect.InvocationTargetException;
import java.util.List;

Expand All @@ -33,6 +35,10 @@ protected List<ReactPackage> getPackages() {
protected String getJSMainModuleName() {
return "index";
}
@Override
protected JSIModulePackage getJSIModulePackage() {
return new ReanimatedJSIModulePackage();
}
};

@Override
Expand Down
3 changes: 3 additions & 0 deletions example/babel.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
'react-native-reanimated/plugin', // Must be the last one in the list
],
};
2 changes: 2 additions & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"dependencies": {
"react": "17.0.1",
"react-native": "0.64.2",
"react-native-gesture-handler": "^2.4.1",
"react-native-reanimated": "^2.8.0",
"react-native-week-view": "file:../"
},
"devDependencies": {
Expand Down
251 changes: 249 additions & 2 deletions example/yarn.lock

Large diffs are not rendered by default.

19 changes: 14 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"homepage": "https://github.com/hoangnm/react-native-week-view#readme",
"scripts": {
"lint": "eslint ./src *.js",
"test": "jest ./src"
"test": "jest"
},
"dependencies": {
"memoize-one": "^5.1.1",
Expand All @@ -34,6 +34,7 @@
"devDependencies": {
"@babel/core": "^7.13.16",
"@babel/preset-env": "^7.13.15",
"@testing-library/react-native": "^9.1.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.6.3",
"eslint": "^7.24.0",
Expand All @@ -49,21 +50,29 @@
"jest-extended": "^0.11.5",
"prettier": "^2.0.5",
"react": "17.0.1",
"react-test-renderer": "17.0.1",
"react-native": "0.64.2"
"react-native": "0.64.2",
"react-native-gesture-handler": "^2.4.1",
"react-native-reanimated": "^2.8.0",
"react-test-renderer": "17.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0",
"react-native": "^0.59.0"
"react-native": "^0.60.0",
"react-native-gesture-handler": "^2.3.2",
"react-native-reanimated": "^2.4.1"
},
"jest": {
"preset": "react-native",
"setupFiles": [
"<rootDir>/src/__tests__/config.js"
"<rootDir>/src/__tests__/setup.js",
"<rootDir>/node_modules/react-native-gesture-handler/jestSetup.js"
],
"setupFilesAfterEnv": [
"jest-extended"
],
"transformIgnorePatterns": [
"node_modules/(?!(jest-)?react-native|@react-native|@react-navigation)"
],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.test.js"
]
Expand Down
236 changes: 153 additions & 83 deletions src/Event/Event.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { Animated, PanResponder, Text, TouchableOpacity } from 'react-native';
import { Text } from 'react-native';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useAnimatedReaction,
useSharedValue,
withTiming,
withSpring,
runOnJS,
useDerivedValue,
} from 'react-native-reanimated';
import styles from './Event.styles';

const UPDATE_EVENT_ANIMATION_DURATION = 150;

const hasMovedEnough = (gestureState) => {
const { dx, dy } = gestureState;
return Math.abs(dx) > 2 || Math.abs(dy) > 2;
};

const Event = ({
event,
onPress,
Expand All @@ -21,7 +26,16 @@ const Event = ({
}) => {
const isDragEnabled = !!onDrag;

const isPressDisabled = !onPress && !onLongPress;
// Wrappers are needed due to RN-reanimated runOnJS behavior. See docs:
// https://docs.swmansion.com/react-native-reanimated/docs/api/miscellaneous/runOnJS
const onPressWrapper = useCallback(() => onPress && onPress(event), [
event,
onPress,
]);
const onLongPressWrapper = useCallback(
() => onLongPress && onLongPress(event),
[event, onLongPress],
);

const onDragRelease = useCallback(
(dx, dy) => {
Expand All @@ -36,90 +50,146 @@ const Event = ({
[event, position, onDrag],
);

const translatedByDrag = useRef(new Animated.ValueXY()).current;
const currentWidth = useRef(new Animated.Value(position.width)).current;
const currentLeft = useRef(new Animated.Value(position.left)).current;

useEffect(() => {
translatedByDrag.setValue({ x: 0, y: 0 });
const { left, width } = position;
const animations = [
Animated.timing(currentWidth, {
toValue: width,
duration: UPDATE_EVENT_ANIMATION_DURATION,
useNativeDriver: false,
}),
Animated.timing(currentLeft, {
toValue: left,
duration: UPDATE_EVENT_ANIMATION_DURATION,
useNativeDriver: false,
}),
];
Animated.parallel(animations).start();
}, [position, currentLeft, currentWidth, translatedByDrag]);

const panResponder = useMemo(() => {
return PanResponder.create({
onStartShouldSetPanResponder: () => isDragEnabled,
onStartShouldSetPanResponderCapture: () =>
isPressDisabled && isDragEnabled,
onMoveShouldSetPanResponder: (_, gestureState) =>
isDragEnabled && hasMovedEnough(gestureState),
onMoveShouldSetPanResponderCapture: (_, gestureState) =>
isPressDisabled && isDragEnabled && hasMovedEnough(gestureState),
onPanResponderMove: Animated.event(
[
null,
{
dx: translatedByDrag.x,
dy: translatedByDrag.y,
},
],
{
useNativeDriver: false,
},
),
onPanResponderTerminationRequest: () => false,
onPanResponderRelease: (_, gestureState) => {
const { dx, dy } = gestureState;
onDragRelease(dx, dy);
},
onPanResponderTerminate: () => {
translatedByDrag.setValue({ x: 0, y: 0 });
},
const translatedByDrag = useSharedValue({ x: 0, y: 0 });
const currentWidth = useSharedValue(position.width);
const currentLeft = useSharedValue(position.left);
const currentTop = useSharedValue(position.top);
const currentHeight = useSharedValue(position.height);

const isDragging = useSharedValue(false);
const isPressing = useSharedValue(false);
const isLongPressing = useSharedValue(false);

const currentOpacity = useDerivedValue(() => {
if (isDragging.value || isPressing.value || isLongPressing.value) {
return 0.5;
}
return 1;
});

const animatedStyles = useAnimatedStyle(() => {
return {
transform: [
{ translateX: translatedByDrag.value.x },
{ translateY: translatedByDrag.value.y },
],
width: currentWidth.value,
left: currentLeft.value,
top: currentTop.value,
height: currentHeight.value,
opacity: withSpring(currentOpacity.value),
};
});

useAnimatedReaction(
() => position,
({ top, left, height, width }) => {
if (currentTop.value !== top) {
currentTop.value = withTiming(top, {
duration: UPDATE_EVENT_ANIMATION_DURATION,
});
}
if (currentLeft.value !== left) {
currentLeft.value = withTiming(left, {
duration: UPDATE_EVENT_ANIMATION_DURATION,
});
}
if (currentHeight.value !== height) {
currentHeight.value = withTiming(height, {
duration: UPDATE_EVENT_ANIMATION_DURATION,
});
}
if (currentWidth.value !== width) {
currentWidth.value = withTiming(width, {
duration: UPDATE_EVENT_ANIMATION_DURATION,
});
}
},
);

const dragGesture = Gesture.Pan()
.enabled(isDragEnabled)
.withTestId('dragGesture')
.onTouchesDown(() => {
isDragging.value = true;
})
.onUpdate((e) => {
translatedByDrag.value = {
x: e.translationX,
y: e.translationY,
};
})
.onEnd((evt, success) => {
if (!success) {
translatedByDrag.value = { x: 0, y: 0 };
return;
}
const { translationX, translationY } = evt;

currentTop.value += translationY;
currentLeft.value += translationX;
translatedByDrag.value = { x: 0, y: 0 };

runOnJS(onDragRelease)(translationX, translationY);
})
.onFinalize(() => {
isDragging.value = false;
});

const longPressGesture = Gesture.LongPress()
.enabled(!!onLongPress)
.maxDistance(20)
.onTouchesDown(() => {
isLongPressing.value = true;
})
.onEnd((evt, success) => {
if (success) {
runOnJS(onLongPressWrapper)();
}
})
.onFinalize(() => {
isLongPressing.value = false;
});
}, [onDragRelease, isDragEnabled, isPressDisabled, translatedByDrag]);

const pressGesture = Gesture.Tap()
.enabled(!!onPress)
.onTouchesDown(() => {
isPressing.value = true;
})
.onEnd((evt, success) => {
if (success) {
runOnJS(onPressWrapper)();
}
})
.onFinalize(() => {
isPressing.value = false;
});

const composedGesture = Gesture.Race(
dragGesture,
longPressGesture,
pressGesture,
);

return (
<Animated.View
style={[
styles.container,
{
top: position.top,
left: currentLeft,
height: position.height,
width: currentWidth,
backgroundColor: event.color,
transform: translatedByDrag.getTranslateTransform(),
},
containerStyle,
]}
/* eslint-disable react/jsx-props-no-spreading */
{...panResponder.panHandlers}
>
<TouchableOpacity
onPress={() => onPress && onPress(event)}
onLongPress={() => onLongPress && onLongPress(event)}
style={styles.touchableContainer}
disabled={!onPress && !onLongPress}
<GestureDetector gesture={composedGesture}>
<Animated.View
style={[
styles.container,
{
backgroundColor: event.color,
},
containerStyle,
animatedStyles,
]}
>
{EventComponent ? (
<EventComponent event={event} position={position} />
) : (
<Text style={styles.description}>{event.description}</Text>
)}
</TouchableOpacity>
</Animated.View>
</Animated.View>
</GestureDetector>
);
};

Expand Down
4 changes: 0 additions & 4 deletions src/Event/Event.styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ const styles = StyleSheet.create({
flex: 1,
overflow: 'hidden',
},
touchableContainer: {
flex: 1,
alignSelf: 'stretch',
},
description: {
marginVertical: 8,
marginHorizontal: 2,
Expand Down
Loading

0 comments on commit 321ef92

Please sign in to comment.