UNPKG

@mpxjs/webpack-plugin

Version:

mpx compile core

520 lines (519 loc) 21.1 kB
/** * ✔ 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;