react-native-draggable-container
Version:
A simple React Native container component to add drag and drop functionalities to your code.
408 lines (371 loc) • 15.3 kB
JavaScript
import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { View, PanResponder, Pressable, Dimensions, StyleSheet } from 'react-native';
import { Move, RotateCcw, Trash2 } from "react-native-feather";
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
export const DraggableContainer = (
{
x,
y,
height,
minHeight,
maxHeight,
width,
minWidth,
maxWidth,
rotation,
resizeMode,
index,
selected = false,
draggable = true,
rotable = true,
resizable = true,
onSelect,
onDelete,
onDragStart,
onDragRelease,
onRotateStart,
onRotateRelease,
onResizeStart,
onResizeRelease,
children
}) => {
const isDevelopment = process.env.NODE_ENV === 'development';
const FOUR_SQUARES = "4-squares";
const ONE_SQUARE = "1-square";
const item = useMemo(() => {
return {
height: height || minHeight || 100,
width: width || minWidth || 100,
rotation: rotation || 0,
x: x || 0,
y: y || 0,
};
}, [height, minHeight, width, minWidth, rotation]);
const position = {
x: useSharedValue(-1000),
y: useSharedValue(-1000),
};
const originOffset = useRef({ oX: 0, oY: 0 });
const contentView = {
height: useSharedValue(item.height),
width: useSharedValue(item.width),
rotation: useSharedValue(item.rotation)
};
const initHeight = useSharedValue(0);
const initWidth = useSharedValue(0);
const initRotation = useSharedValue(0);
const buttonsSize = 30;
const [layoutKey, setLayoutKey] = useState(0);
const dimensions = { width: useSharedValue(0), height: useSharedValue(0) };
const windowDimensions = useRef(Dimensions.get('window'));
const [buttonsAbove, setButtonsAbove] = useState(false);
const checkButtonsPosition = useCallback(() => {
const containerBottom = position.y.value + contentView.height.value;
const windowHeight = windowDimensions.current.height;
const threshold = windowHeight - 100;
setButtonsAbove(containerBottom > threshold);
}, [position.y, contentView.height]);
const getNewPosition = useCallback((gestureState) => {
let { moveX, moveY } = gestureState;
let { height } = dimensions;
let { oX, oY } = originOffset.current;
return { x: moveX - oX, y: moveY - oY - height.value / 2 - buttonsSize * 1.5 };
}, [dimensions]);
const handleDrag = useCallback((gestureState) => {
const { x, y } = getNewPosition(gestureState);
position.x.value = x;
position.y.value = y;
if (!!item) {
item.x = x;
item.y = y;
}
checkButtonsPosition();
}, [position, getNewPosition, checkButtonsPosition]);
const dragViewpanResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderStart: (_, gestureState) => {
if(!!onDragStart) onDragStart(gestureState);
},
onPanResponderMove: (_, gestureState) => handleDrag(gestureState),
onPanResponderRelease: (_, gestureState) => {
if(!!onDragRelease) onDragRelease(gestureState);
},
})
).current;
const handleResizeY = useCallback((gestureState, y0) => {
let newValue = y0 ? gestureState.y0 - gestureState.moveY : gestureState.moveY - gestureState.y0;
const newHeight = Math.max(newValue + initHeight.value, 25);
if (!!minHeight && newHeight < minHeight) return;
if (!!maxHeight && newHeight > maxHeight) return;
contentView.height.value = newHeight;
if (newHeight > 25 && y0)
position.y.value = gestureState.moveY - (y0 ? -1 * buttonsSize / 2 : buttonsSize / 2);
if (!!item)
item.height = newHeight;
checkButtonsPosition();
}, [contentView, initHeight, checkButtonsPosition]);
const resizeYViewpanResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderStart: (_, gestureState) => {
if(onResizeStart) onResizeStart(gestureState);
},
onPanResponderMove: (_, gestureState) => handleResizeY(gestureState, false),
onPanResponderRelease: (_, gestureState) => {
if(onResizeRelease) onResizeRelease(gestureState);
initHeight.value = contentView.height.value;
setLayoutKey((prevKey) => prevKey + 1);
},
})
).current;
/* Resize pandhandlers */
const resizeYFinalViewpanResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderStart: (_, gestureState) => {
if(onResizeStart) onResizeStart(gestureState);
},
onPanResponderMove: (_, gestureState) => handleResizeY(gestureState, true),
onPanResponderRelease: (_, gestureState) => {
if(onResizeRelease) onResizeRelease(gestureState);
initHeight.value = contentView.height.value;
setLayoutKey((prevKey) => prevKey + 1);
},
})
).current;
const handleResizeX = useCallback((gestureState, x0) => {
let newValue = x0 ? gestureState.x0 - gestureState.moveX : gestureState.moveX - gestureState.x0;
const newWidth = Math.max(newValue + initWidth.value, 25);
if (!!minWidth && newWidth < minWidth) return;
if (!!maxWidth && newWidth > maxWidth) return;
contentView.width.value = newWidth;
if (newWidth > 25 && x0)
position.x.value = gestureState.moveX - (x0 ? -1 * buttonsSize / 2 : buttonsSize / 2);
if (!!item)
item.width = newWidth;
checkButtonsPosition();
}, [contentView, initWidth, checkButtonsPosition]);
const resizeXViewpanResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderStart: (_, gestureState) => {
if(onResizeStart) onResizeStart(gestureState);
},
onPanResponderMove: (_, gestureState) => handleResizeX(gestureState, false),
onPanResponderRelease: (_, gestureState) => {
if(onResizeRelease) onResizeRelease(gestureState);
initWidth.value = contentView.width.value;
setLayoutKey((prevKey) => prevKey + 1);
},
})
).current;
const resizeXFinalViewpanResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderStart: (_, gestureState) => {
if(onResizeStart) onResizeStart(gestureState);
},
onPanResponderMove: (_, gestureState) => handleResizeX(gestureState, true),
onPanResponderRelease: (_, gestureState) => {
if(onResizeRelease) onResizeRelease(gestureState);
initWidth.value = contentView.width.value;
setLayoutKey((prevKey) => prevKey + 1);
},
})
).current;
const resizeXYFinalViewpanResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderMove: (_, gestureState) => {
handleResizeY(gestureState, false);
handleResizeX(gestureState, false);
},
onPanResponderRelease: (_, gestureState) => {
initHeight.value = contentView.height.value;
initWidth.value = contentView.width.value;
setLayoutKey((prevKey) => prevKey + 1);
},
})
).current;
/* Rotate functions */
const onRotate = useCallback((gestureState) => {
let newValue = gestureState.x0 - gestureState.moveX;
contentView.rotation.value = newValue + initRotation.value;
if (!!item)
item.rotation = contentView.rotation.value;
}, [contentView, initRotation]);
const rotateViewpanResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderStart: (_, gestureState) => {
if(onRotateStart) onRotateStart(gestureState);
},
onPanResponderMove: (_, gestureState) => onRotate(gestureState),
onPanResponderRelease: (_, gestureState) => {
if(onRotateRelease) onRotateRelease(gestureState);
initRotation.value = contentView.rotation.value;
},
})
).current;
const dragAnimationStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: position.x.value },
{ translateY: position.y.value },
],
position: 'absolute',
}));
useEffect(() => {
position.x.value = item.x;
position.y.value = item.y;
initHeight.value = contentView.height.value;
initWidth.value = contentView.width.value;
initRotation.value = contentView.rotation.value;
checkButtonsPosition();
}, [item, checkButtonsPosition]);
const onComponentLayout = useCallback((event) => {
const { width, height, x, y, top, left } = event.nativeEvent.layout;
originOffset.current = { oX: x + (left | 0) + width / 2, oY: y + (top | 0) + height / 2 };
dimensions.width.value = width;
dimensions.height.value = height;
checkButtonsPosition();
}, [dimensions, checkButtonsPosition]);
// animated size style for the inner component shown
const resizeAnimationStyle = useAnimatedStyle(() => ({
height: contentView.height.value,
width: contentView.width.value,
maxWidth: contentView.width.value,
maxHeight: contentView.height.value,
backgroundColor: 'transparent',
zIndex: 15,
}));
// animated rotation style for the inner component shown
const rotationAnimationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: contentView.rotation.value + 'deg' }],
}));
// Componente hijo que queremos memorizar
const childMemo = useMemo(() => children, []);
const styles = useMemo(
() =>
StyleSheet.create({
container: {
borderWidth: 1,
borderStyle: 'dashed',
alignItems: 'center',
justifyContent: 'center',
},
buttonsContainer: {
position: 'absolute',
bottom: -60,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
paddingHorizontal: 10,
zIndex: 16,
},
buttonsAbove: {
bottom: 'auto',
top: -60,
},
button: {
borderRadius: 20,
backgroundColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
resizeHandles: {
position: 'absolute',
width: '100%',
height: '100%',
},
resizeHandle: {
position: 'absolute',
width: 30,
height: 30,
backgroundColor: '#fa7f7c',
borderRadius: 20,
zIndex: 16,
},
leftHandle: {
left: -30,
top: '50%',
transform: [{ translateY: -10 }],
zIndex: 16,
},
rightHandle: {
right: -30,
top: '50%',
transform: [{ translateY: -10 }],
zIndex: 16,
},
topHandle: {
top: -30,
left: '50%',
transform: [{ translateX: -10 }],
zIndex: 16,
},
bottomHandle: {
bottom: -30,
left: '50%',
transform: [{ translateX: -10 }],
zIndex: 16,
},
botRightHandle: {
bottom: -15,
right: -15,
zIndex: 16,
}
}),
[]
);
return (
<Animated.View
selectable={false}
draggable={false}
style={[dragAnimationStyle, { zIndex: selected ? 15 : 3 }]}
key={`dragable-text-${index}-layoutKey-${layoutKey}`}
onLayout={onComponentLayout}>
<Pressable
onPress={() => onSelect(index)}
style={[styles.container, { borderColor: selected ? '#fa7f7c' : 'transparent' }]}
draggable={false}
selectable={false}>
<Animated.View style={[rotationAnimationStyle, resizeAnimationStyle]}>
{childMemo}
</Animated.View>
{/* Drag buttons */}
{
selected &&
<View style={[{ visibility: selected ? 'visible' : 'hidden', opacity: selected ? 1 : 0 }, styles.buttonsContainer, buttonsAbove && styles.buttonsAbove]}>
<View {...(isDevelopment ? { testID: "rotateButton" } : {})} {...rotateViewpanResponder.panHandlers} style={[{ disabled: !selected, visibility: (!!rotable ? 'visible' : 'hidden'), width: buttonsSize, height: buttonsSize }, styles.button]}>
<RotateCcw stroke="black" width={buttonsSize / 2} height={buttonsSize / 2} />
</View>
<View {...(isDevelopment ? { testID: "moveButton" } : {})} {...dragViewpanResponder.panHandlers} style={[{ disabled: !selected, visibility: (draggable ? 'visible' : 'hidden'), width: buttonsSize, height: buttonsSize }, styles.button]}>
<Move stroke="black" width={buttonsSize / 2} height={buttonsSize / 2} />
</View>
<Pressable {...(isDevelopment ? { testID: "deleteButton" } : {})} onPress={() => onDelete(index)} style={[{ disabled: !selected, visibility: (!!onDelete ? 'visible' : 'hidden'), width: buttonsSize, height: buttonsSize }, styles.button]}>
<Trash2 stroke="black" width={buttonsSize / 2} height={buttonsSize / 2} />
</Pressable>
</View>
}
{/* Resize 4 squares buttons */}
{selected && resizeMode === FOUR_SQUARES && <View {...(isDevelopment ? { testID: "resizableButton-xf-square" } : {})} {...resizeXFinalViewpanResponder.panHandlers} style={[{ disabled: !selected, visibility: (selected && resizable ? 'visible' : 'hidden'), opacity: selected ? 1 : 0, width: buttonsSize, height: buttonsSize }, styles.resizeHandle, styles.leftHandle]} />}
{selected && resizeMode === FOUR_SQUARES && <View {...(isDevelopment ? { testID: "resizableButton-y-square" } : {})} {...resizeYViewpanResponder.panHandlers} style={[{ disabled: !selected, visibility: (selected && resizable ? 'visible' : 'hidden'), opacity: selected ? 1 : 0, width: buttonsSize, height: buttonsSize }, styles.resizeHandle, styles.bottomHandle]} />}
{selected && resizeMode === FOUR_SQUARES && <View {...(isDevelopment ? { testID: "resizableButton-x-square" } : {})} {...resizeXViewpanResponder.panHandlers} style={[{ disabled: !selected, visibility: (selected && resizable ? 'visible' : 'hidden'), opacity: selected ? 1 : 0, width: buttonsSize, height: buttonsSize }, styles.resizeHandle, styles.rightHandle]} />}
{selected && resizeMode === FOUR_SQUARES && <View {...(isDevelopment ? { testID: "resizableButton-yf-square" } : {})} {...resizeYFinalViewpanResponder.panHandlers} style={[{ disabled: !selected, visibility: (selected && resizable ? 'visible' : 'hidden'), opacity: selected ? 1 : 0, width: buttonsSize, height: buttonsSize }, styles.resizeHandle, styles.topHandle]} />}
{/* Resize 1 square button */}
{selected && resizeMode === ONE_SQUARE && <View {...(isDevelopment ? { testID: "resizableButton-1-square" } : {})} {...resizeXYFinalViewpanResponder.panHandlers} style={[{ disabled: !selected, visibility: (selected && resizable ? 'visible' : 'hidden'), opacity: selected ? 1 : 0, width: buttonsSize, height: buttonsSize }, styles.resizeHandle, styles.botRightHandle]} />}
</Pressable>
</Animated.View>
);
};