@mpxjs/webpack-plugin
Version:
mpx compile core
520 lines (519 loc) • 21.1 kB
JSX
/**
* ✔ direction
* ✔ inertia
* ✔ out-of-bounds
* ✔ x
* ✔ y
* ✘ damping
* ✘ friction
* ✔ disabled
* ✘ scale
* ✘ scale-min
* ✘ scale-max
* ✘ scale-value
* ✔ animation
* ✔ bindchange
* ✘ bindscale
* ✔ htouchmove
* ✔ vtouchmove
*/
import { useEffect, forwardRef, useContext, useCallback, useRef, useMemo, createElement } from 'react';
import { StyleSheet } from 'react-native';
import useInnerProps, { getCustomEvent } from './getInnerListeners';
import useNodesRef from './useNodesRef';
import { MovableAreaContext } from './context';
import { useTransformStyle, splitProps, splitStyle, HIDDEN_STYLE, wrapChildren, flatGesture, extendObject, omit, useNavigation } from './utils';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withDecay, runOnJS, runOnUI, withSpring } from 'react-native-reanimated';
import { collectDataset, noop } from '@mpxjs/utils';
const styles = StyleSheet.create({
container: {
position: 'absolute',
left: 0,
top: 0
}
});
const _MovableView = forwardRef((movableViewProps, ref) => {
const { textProps, innerProps: props = {} } = splitProps(movableViewProps);
const movableGestureRef = useRef();
const layoutRef = useRef({});
const changeSource = useRef('');
const hasLayoutRef = useRef(false);
const propsRef = useRef({});
propsRef.current = (props || {});
const { x = 0, y = 0, inertia = false, disabled = false, animation = true, 'out-of-bounds': outOfBounds = false, 'enable-var': enableVar, 'external-var-context': externalVarContext, 'parent-font-size': parentFontSize, 'parent-width': parentWidth, 'parent-height': parentHeight, direction = 'none', 'disable-event-passthrough': disableEventPassthrough = false, 'simultaneous-handlers': originSimultaneousHandlers = [], 'wait-for': waitFor = [], style = {}, changeThrottleTime = 60, bindtouchstart, catchtouchstart, bindhtouchmove, bindvtouchmove, bindtouchmove, catchhtouchmove, catchvtouchmove, catchtouchmove, bindtouchend, catchtouchend, bindchange } = props;
const { hasSelfPercent, normalStyle, hasVarDec, varContextRef, setWidth, setHeight } = useTransformStyle(Object.assign({}, style, styles.container), { enableVar, externalVarContext, parentFontSize, parentWidth, parentHeight });
const navigation = useNavigation();
const prevSimultaneousHandlersRef = useRef(originSimultaneousHandlers || []);
const prevWaitForHandlersRef = useRef(waitFor || []);
const gestureSwitch = useRef(false);
const { textStyle, innerStyle } = splitStyle(normalStyle);
const offsetX = useSharedValue(x);
const offsetY = useSharedValue(y);
const startPosition = useSharedValue({
x: 0,
y: 0
});
const draggableXRange = useSharedValue([0, 0]);
const draggableYRange = useSharedValue([0, 0]);
const isMoving = useSharedValue(false);
const xInertialMotion = useSharedValue(false);
const yInertialMotion = useSharedValue(false);
const isFirstTouch = useSharedValue(true);
const touchEvent = useSharedValue('');
const initialViewPosition = useSharedValue({ x: x || 0, y: y || 0 });
const lastChangeTime = useSharedValue(0);
const MovableAreaLayout = useContext(MovableAreaContext);
const simultaneousHandlers = flatGesture(originSimultaneousHandlers);
const waitForHandlers = flatGesture(waitFor);
const nodeRef = useRef(null);
useNodesRef(props, ref, nodeRef, {
style: normalStyle,
gestureRef: movableGestureRef
});
const hasSimultaneousHandlersChanged = prevSimultaneousHandlersRef.current.length !== (originSimultaneousHandlers?.length || 0) ||
(originSimultaneousHandlers || []).some((handler, index) => handler !== prevSimultaneousHandlersRef.current[index]);
const hasWaitForHandlersChanged = prevWaitForHandlersRef.current.length !== (waitFor?.length || 0) ||
(waitFor || []).some((handler, index) => handler !== prevWaitForHandlersRef.current[index]);
if (hasSimultaneousHandlersChanged || hasWaitForHandlersChanged) {
gestureSwitch.current = !gestureSwitch.current;
}
prevSimultaneousHandlersRef.current = originSimultaneousHandlers || [];
prevWaitForHandlersRef.current = waitFor || [];
const handleTriggerChange = useCallback(({ x, y, type }) => {
const { bindchange } = propsRef.current;
if (!bindchange)
return;
let source = '';
if (type !== 'setData') {
source = getTouchSource(x, y);
}
else {
changeSource.current = '';
}
bindchange(getCustomEvent('change', {}, {
detail: {
x,
y,
source
},
layoutRef
}, propsRef.current));
}, []);
// 节流版本的 change 事件触发
const handleTriggerChangeThrottled = useCallback(({ x, y, type }) => {
'worklet';
const now = Date.now();
if (now - lastChangeTime.value >= changeThrottleTime) {
lastChangeTime.value = now;
runOnJS(handleTriggerChange)({ x, y, type });
}
}, [changeThrottleTime]);
useEffect(() => {
runOnUI(() => {
if (offsetX.value !== x || offsetY.value !== y) {
const { x: newX, y: newY } = checkBoundaryPosition({ positionX: Number(x), positionY: Number(y) });
if (direction === 'horizontal' || direction === 'all') {
offsetX.value = animation
? withSpring(newX, {
duration: 1500,
dampingRatio: 0.8
})
: newX;
}
if (direction === 'vertical' || direction === 'all') {
offsetY.value = animation
? withSpring(newY, {
duration: 1500,
dampingRatio: 0.8
})
: newY;
}
if (bindchange) {
runOnJS(handleTriggerChange)({
x: newX,
y: newY,
type: 'setData'
});
}
}
})();
}, [x, y]);
useEffect(() => {
const { width, height } = layoutRef.current;
if (width && height) {
resetBoundaryAndCheck({ width, height });
}
}, [MovableAreaLayout.height, MovableAreaLayout.width]);
const getTouchSource = useCallback((offsetX, offsetY) => {
const hasOverBoundary = offsetX < draggableXRange.value[0] || offsetX > draggableXRange.value[1] ||
offsetY < draggableYRange.value[0] || offsetY > draggableYRange.value[1];
let source = changeSource.current;
if (hasOverBoundary) {
if (isMoving.value) {
source = 'touch-out-of-bounds';
}
else {
source = 'out-of-bounds';
}
}
else {
if (isMoving.value) {
source = 'touch';
}
else if ((xInertialMotion.value || yInertialMotion.value) && (changeSource.current === 'touch' || changeSource.current === 'friction')) {
source = 'friction';
}
}
changeSource.current = source;
return source;
}, []);
const setBoundary = useCallback(({ width, height }) => {
const top = (style.position === 'absolute' && style.top) || 0;
const left = (style.position === 'absolute' && style.left) || 0;
const scaledWidth = width || 0;
const scaledHeight = height || 0;
const maxY = MovableAreaLayout.height - scaledHeight - top;
const maxX = MovableAreaLayout.width - scaledWidth - left;
let xRange;
let yRange;
if (MovableAreaLayout.width < scaledWidth) {
xRange = [maxX, 0];
}
else {
xRange = [left === 0 ? 0 : -left, maxX < 0 ? 0 : maxX];
}
if (MovableAreaLayout.height < scaledHeight) {
yRange = [maxY, 0];
}
else {
yRange = [top === 0 ? 0 : -top, maxY < 0 ? 0 : maxY];
}
draggableXRange.value = xRange;
draggableYRange.value = yRange;
}, [MovableAreaLayout.height, MovableAreaLayout.width, style.position, style.top, style.left]);
const checkBoundaryPosition = useCallback(({ positionX, positionY }) => {
'worklet';
let x = positionX;
let y = positionY;
// 计算边界限制
if (x > draggableXRange.value[1]) {
x = draggableXRange.value[1];
}
else if (x < draggableXRange.value[0]) {
x = draggableXRange.value[0];
}
if (y > draggableYRange.value[1]) {
y = draggableYRange.value[1];
}
else if (y < draggableYRange.value[0]) {
y = draggableYRange.value[0];
}
return { x, y };
}, []);
const resetBoundaryAndCheck = ({ width, height }) => {
setBoundary({ width, height });
runOnUI(() => {
const positionX = offsetX.value;
const positionY = offsetY.value;
const { x: newX, y: newY } = checkBoundaryPosition({ positionX, positionY });
if (positionX !== newX) {
offsetX.value = newX;
}
if (positionY !== newY) {
offsetY.value = newY;
}
})();
};
const onLayout = (e) => {
hasLayoutRef.current = true;
if (hasSelfPercent) {
const { width, height } = e?.nativeEvent?.layout || {};
setWidth(width || 0);
setHeight(height || 0);
}
nodeRef.current?.measure((x, y, width, height) => {
const { top: navigationY = 0 } = navigation?.layout || {};
layoutRef.current = { x, y: y - navigationY, width, height, offsetLeft: 0, offsetTop: 0 };
resetBoundaryAndCheck({ width, height });
});
props.onLayout && props.onLayout(e);
};
const extendEvent = useCallback((e, type) => {
const { top: navigationY = 0 } = navigation?.layout || {};
const touchArr = [e.changedTouches, e.allTouches];
touchArr.forEach(touches => {
touches && touches.forEach((item) => {
item.pageX = item.absoluteX;
item.pageY = item.absoluteY - navigationY;
item.clientX = item.absoluteX;
item.clientY = item.absoluteY - navigationY;
});
});
Object.assign(e, {
touches: type === 'end' ? [] : e.allTouches,
currentTarget: {
id: props.id || '',
dataset: collectDataset(props),
offsetLeft: 0,
offsetTop: 0
},
detail: {}
});
}, []);
const triggerStartOnJS = ({ e }) => {
const { bindtouchstart, catchtouchstart } = propsRef.current;
extendEvent(e, 'start');
bindtouchstart && bindtouchstart(e);
catchtouchstart && catchtouchstart(e);
};
const triggerMoveOnJS = ({ e, hasTouchmove, hasCatchTouchmove, touchEvent }) => {
const { bindhtouchmove, bindvtouchmove, bindtouchmove, catchhtouchmove, catchvtouchmove, catchtouchmove } = propsRef.current;
extendEvent(e, 'move');
if (hasTouchmove) {
if (touchEvent === 'htouchmove') {
bindhtouchmove && bindhtouchmove(e);
}
else if (touchEvent === 'vtouchmove') {
bindvtouchmove && bindvtouchmove(e);
}
bindtouchmove && bindtouchmove(e);
}
if (hasCatchTouchmove) {
if (touchEvent === 'htouchmove') {
catchhtouchmove && catchhtouchmove(e);
}
else if (touchEvent === 'vtouchmove') {
catchvtouchmove && catchvtouchmove(e);
}
catchtouchmove && catchtouchmove(e);
}
};
const triggerEndOnJS = ({ e }) => {
const { bindtouchend, catchtouchend } = propsRef.current;
extendEvent(e, 'end');
bindtouchend && bindtouchend(e);
catchtouchend && catchtouchend(e);
};
const gesture = useMemo(() => {
const handleTriggerMove = (e) => {
'worklet';
const hasTouchmove = !!bindhtouchmove || !!bindvtouchmove || !!bindtouchmove;
const hasCatchTouchmove = !!catchhtouchmove || !!catchvtouchmove || !!catchtouchmove;
if (hasTouchmove || hasCatchTouchmove) {
runOnJS(triggerMoveOnJS)({
e,
touchEvent: touchEvent.value,
hasTouchmove,
hasCatchTouchmove
});
}
};
const gesturePan = Gesture.Pan()
.onTouchesDown((e) => {
'worklet';
const changedTouches = e.changedTouches[0] || { x: 0, y: 0 };
isMoving.value = false;
startPosition.value = {
x: changedTouches.x,
y: changedTouches.y
};
if (bindtouchstart || catchtouchstart) {
runOnJS(triggerStartOnJS)({ e });
}
})
.onStart(() => {
'worklet';
initialViewPosition.value = {
x: offsetX.value,
y: offsetY.value
};
})
.onTouchesMove((e) => {
'worklet';
const changedTouches = e.changedTouches[0] || { x: 0, y: 0 };
isMoving.value = true;
if (isFirstTouch.value) {
touchEvent.value = Math.abs(changedTouches.x - startPosition.value.x) > Math.abs(changedTouches.y - startPosition.value.y) ? 'htouchmove' : 'vtouchmove';
isFirstTouch.value = false;
}
handleTriggerMove(e);
})
.onUpdate((e) => {
'worklet';
if (disabled)
return;
if (direction === 'horizontal' || direction === 'all') {
const newX = initialViewPosition.value.x + e.translationX;
if (!outOfBounds) {
const { x } = checkBoundaryPosition({ positionX: newX, positionY: offsetY.value });
offsetX.value = x;
}
else {
offsetX.value = newX;
}
}
if (direction === 'vertical' || direction === 'all') {
const newY = initialViewPosition.value.y + e.translationY;
if (!outOfBounds) {
const { y } = checkBoundaryPosition({ positionX: offsetX.value, positionY: newY });
offsetY.value = y;
}
else {
offsetY.value = newY;
}
}
if (bindchange) {
// 使用节流版本减少 runOnJS 调用
handleTriggerChangeThrottled({
x: offsetX.value,
y: offsetY.value
});
}
})
.onTouchesUp((e) => {
'worklet';
isFirstTouch.value = true;
isMoving.value = false;
if (bindtouchend || catchtouchend) {
runOnJS(triggerEndOnJS)({ e });
}
})
.onEnd((e) => {
'worklet';
isMoving.value = false;
if (disabled)
return;
// 处理没有惯性且超出边界的回弹
if (!inertia && outOfBounds) {
const { x, y } = checkBoundaryPosition({ positionX: offsetX.value, positionY: offsetY.value });
if (x !== offsetX.value || y !== offsetY.value) {
if (x !== offsetX.value) {
offsetX.value = animation
? withSpring(x, {
duration: 1500,
dampingRatio: 0.8
})
: x;
}
if (y !== offsetY.value) {
offsetY.value = animation
? withSpring(y, {
duration: 1500,
dampingRatio: 0.8
})
: y;
}
if (bindchange) {
runOnJS(handleTriggerChange)({
x,
y
});
}
}
}
else if (inertia) {
// 惯性处理
if (direction === 'horizontal' || direction === 'all') {
xInertialMotion.value = true;
offsetX.value = withDecay({
velocity: e.velocityX / 10,
rubberBandEffect: outOfBounds,
clamp: draggableXRange.value
}, () => {
xInertialMotion.value = false;
if (bindchange) {
runOnJS(handleTriggerChange)({
x: offsetX.value,
y: offsetY.value
});
}
});
}
if (direction === 'vertical' || direction === 'all') {
yInertialMotion.value = true;
offsetY.value = withDecay({
velocity: e.velocityY / 10,
rubberBandEffect: outOfBounds,
clamp: draggableYRange.value
}, () => {
yInertialMotion.value = false;
if (bindchange) {
runOnJS(handleTriggerChange)({
x: offsetX.value,
y: offsetY.value
});
}
});
}
}
})
.withRef(movableGestureRef);
if (!disableEventPassthrough) {
if (direction === 'horizontal') {
gesturePan.activeOffsetX([-5, 5]).failOffsetY([-5, 5]);
}
else if (direction === 'vertical') {
gesturePan.activeOffsetY([-5, 5]).failOffsetX([-5, 5]);
}
}
if (simultaneousHandlers && simultaneousHandlers.length) {
gesturePan.simultaneousWithExternalGesture(...simultaneousHandlers);
}
if (waitForHandlers && waitForHandlers.length) {
gesturePan.requireExternalGestureToFail(...waitForHandlers);
}
return gesturePan;
}, [disabled, direction, inertia, outOfBounds, gestureSwitch.current]);
const animatedStyles = useAnimatedStyle(() => {
return {
transform: [
{ translateX: offsetX.value },
{ translateY: offsetY.value }
]
};
});
const rewriteCatchEvent = () => {
const handlers = {};
const events = [
{ type: 'touchstart' },
{ type: 'touchmove', alias: ['vtouchmove', 'htouchmove'] },
{ type: 'touchend' }
];
events.forEach(({ type, alias = [] }) => {
const hasCatchEvent = props[`catch${type}`] ||
alias.some(name => props[`catch${name}`]);
if (hasCatchEvent)
handlers[`catch${type}`] = noop;
});
return handlers;
};
const layoutStyle = !hasLayoutRef.current && hasSelfPercent ? HIDDEN_STYLE : {};
// bind 相关 touch 事件直接由 gesture 触发,无须重复挂载
// catch 相关 touch 事件需要重写并通过 useInnerProps 注入阻止冒泡逻辑
const filterProps = omit(props, [
'bindtouchstart',
'bindtouchmove',
'bindvtouchmove',
'bindhtouchmove',
'bindtouchend',
'catchtouchstart',
'catchtouchmove',
'catchvtouchmove',
'catchhtouchmove',
'catchtouchend'
]);
const innerProps = useInnerProps(extendObject({}, filterProps, {
ref: nodeRef,
onLayout: onLayout,
style: [innerStyle, animatedStyles, layoutStyle]
}, rewriteCatchEvent()));
return createElement(GestureDetector, { gesture: gesture }, createElement(Animated.View, innerProps, wrapChildren(props, {
hasVarDec,
varContext: varContextRef.current,
textStyle,
textProps
})));
});
_MovableView.displayName = 'MpxMovableView';
export default _MovableView;