Skip to content
Open
2 changes: 2 additions & 0 deletions .agents/skills/mpx2rn/references/rn-template-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,7 @@ movable-view 的可移动区域。
| animation | boolean | `true` | 是否使用动画 |
| damping | number | `20` | 阻尼系数,用于控制 x 或 y 改变时的动画和过界回弹的动画,值越大移动越快 |
| friction | number | `2` | 摩擦系数,用于控制惯性滑动的动画,值越大摩擦力越大,滑动越快停止 |
| workletChange | function | | RN 环境特有属性,拖动位置变化时在 UI 线程立即触发,回调参数为 `{x, y, source}`,不受 `changeThrottleTime` 影响 |
| simultaneous-handlers | array\<object> | `[]` | RN 环境特有属性,主要用于组件嵌套场景,允许多个手势同时识别和处理并触发,这个属性可以指定一个或多个手势处理器,处理器支持使用 this.$refs.xxx 获取组件实例来作为数组参数传递给 movable-view 组件 |
| wait-for | array\<object> | `[]` | RN 环境特有属性,主要用于组件嵌套场景,允许延迟激活处理某些手势,这个属性可以指定一个或多个手势处理器,处理器支持使用 this.$refs.xxx 获取组件实例来作为数组参数传递给 movable-view 组件 |
| disable-event-passthrough | boolean | `false` | RN 环境特有属性,有时候我们希望 movable-view 在水平方向滑动,并且竖直方向的手势也希望被 movable-view 组件消费掉,不被其他组件响应,可以将这个属性设置为 true) |
Expand All @@ -871,6 +872,7 @@ movable-view 的可移动区域。

- simultaneous-handlers 为 RN 环境特有属性,具体含义可参考[react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#simultaneouswithexternalgesture)
- wait-for 为 RN 环境特有属性,具体含义可参考[react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#requireexternalgesturetofail)
- workletChange 为 RN 环境特有属性,回调会在 UI 线程执行,函数体需包含 `'worklet'` 指令,且只接收 `{x, y, source}` 参数,不是完整 Mpx 事件对象
- RN 环境 movable 相关组件暂不支持缩放能力

### image
Expand Down
2 changes: 2 additions & 0 deletions docs-vitepress/guide/rn/component.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ movable-view的可移动区域。
| animation | boolean | `true` | 是否使用动画 |
| damping | number | `20` | 阻尼系数,用于控制x或y改变时的动画和过界回弹的动画,值越大移动越快 |
| friction | number | `2` | 摩擦系数,用于控制惯性滑动的动画,值越大摩擦力越大,滑动越快停止 |
| workletChange | function | | RN 环境特有属性,拖动位置变化时在 UI 线程立即触发,回调参数为 `{x, y, source}`,不受 `changeThrottleTime` 影响 |
| simultaneous-handlers | array\<object> | `[]` | RN 环境特有属性,主要用于组件嵌套场景,允许多个手势同时识别和处理并触发,这个属性可以指定一个或多个手势处理器,处理器支持使用 this.$refs.xxx 获取组件实例来作为数组参数传递给 movable-view 组件 |
| wait-for | array\<object> | `[]` | RN 环境特有属性,主要用于组件嵌套场景,允许延迟激活处理某些手势,这个属性可以指定一个或多个手势处理器,处理器支持使用 this.$refs.xxx 获取组件实例来作为数组参数传递给 movable-view 组件 |
| disable-event-passthrough | boolean | `false` | RN 环境特有属性,有时候我们希望movable-view 在水平方向滑动,并且竖直方向的手势也希望被 movable-view 组件消费掉,不被其他组件响应,可以将这个属性设置为true) |
Expand All @@ -229,6 +230,7 @@ movable-view的可移动区域。
>
> - simultaneous-handlers 为 RN 环境特有属性,具体含义可参考[react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#simultaneouswithexternalgesture)
> - wait-for 为 RN 环境特有属性,具体含义可参考[react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/gesture-composition/#requireexternalgesturetofail)
> - workletChange 为 RN 环境特有属性,回调会在 UI 线程执行,函数体需包含 `'worklet'` 指令,且只接收 `{x, y, source}` 参数,不是完整 Mpx 事件对象
> - RN 环境 movable 相关组件暂不支持缩放能力

### image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* ✘ scale-value
* ✔ animation
* ✔ bindchange
* ✔ workletChange
* ✘ bindscale
* ✔ htouchmove
* ✔ vtouchmove
Expand Down Expand Up @@ -116,7 +117,7 @@ const withWechatDecay = (
currentPosition: number,
clampRange: [min: number, max: number],
frictionValue = 2,
callback?: () => void
callback?: (finished?: boolean) => void
) => {
'worklet'

Expand Down Expand Up @@ -146,6 +147,10 @@ const withWechatDecay = (
}, callback)
}

type ChangeSource = '' | 'touch' | 'touch-out-of-bounds' | 'out-of-bounds' | 'friction'
type ChangePayload = { x: number; y: number; source?: ChangeSource }
type ChangeDetail = { x: number; y: number; source: ChangeSource }

interface MovableViewProps {
children: ReactNode
style?: Record<string, any>
Expand All @@ -159,6 +164,7 @@ interface MovableViewProps {
id?: string
changeThrottleTime?:number
bindchange?: (event: unknown) => void
workletChange?: (detail: ChangeDetail) => void
bindtouchstart?: (event: GestureTouchEvent) => void
catchtouchstart?: (event: GestureTouchEvent) => void
bindtouchmove?: (event: GestureTouchEvent) => void
Expand Down Expand Up @@ -192,12 +198,22 @@ const styles = StyleSheet.create({
top: 0
}
})
const getTouchChangeSource = (
offsetX: number,
offsetY: number,
xRange: [min: number, max: number],
yRange: [min: number, max: number]
): ChangeSource => {
'worklet'
const hasOverBoundary = offsetX < xRange[0] || offsetX > xRange[1] ||
offsetY < yRange[0] || offsetY > yRange[1]
return hasOverBoundary ? 'touch-out-of-bounds' : 'touch'
}

const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewProps>((movableViewProps: MovableViewProps, ref): JSX.Element => {
const { textProps, innerProps: props = {} } = splitProps(movableViewProps)
const movableGestureRef = useRef<PanGesture>()
const layoutRef = useRef<any>({})
const changeSource = useRef<any>('')
const hasLayoutRef = useRef(false)
const propsRef = useRef<any>({})
propsRef.current = (props || {}) as MovableViewProps
Expand Down Expand Up @@ -231,7 +247,8 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
catchtouchmove,
bindtouchend,
catchtouchend,
bindchange
bindchange,
workletChange
} = props

const {
Expand Down Expand Up @@ -261,9 +278,6 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP

const draggableXRange = useSharedValue<[min: number, max: number]>([0, 0])
const draggableYRange = useSharedValue<[min: number, max: number]>([0, 0])
const isMoving = useSharedValue(false)
const xInertialMotion = useSharedValue(false)
const yInertialMotion = useSharedValue(false)
const isFirstTouch = useSharedValue(true)
const touchEvent = useSharedValue<string>('')
const initialViewPosition = useSharedValue({ x: x || 0, y: y || 0 })
Expand Down Expand Up @@ -294,15 +308,9 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
prevSimultaneousHandlersRef.current = originSimultaneousHandlers || []
prevWaitForHandlersRef.current = waitFor || []

const handleTriggerChange = useCallback(({ x, y, type }: { x: number; y: number; type?: string }) => {
const handleTriggerChange = useCallback(({ x, y, source = '' }: ChangePayload) => {
const { bindchange } = propsRef.current
if (!bindchange) return
let source = ''
if (type !== 'setData') {
source = getTouchSource(x, y)
} else {
changeSource.current = ''
}
bindchange(
getCustomEvent('change', {}, {
detail: {
Expand All @@ -315,6 +323,19 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
)
}, [])

const handleTriggerWorkletChange = useCallback(({ x, y, source }: ChangePayload) => {
'worklet'
const changeSource = source === undefined
? getTouchChangeSource(
x,
y,
draggableXRange.value,
draggableYRange.value
)
: source
workletChange && workletChange({ x, y, source: changeSource })
}, [workletChange])

useEffect(() => {
runOnUI(() => {
if (offsetX.value !== x || offsetY.value !== y) {
Expand All @@ -329,11 +350,18 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
? withWechatSpring(newY, damping)
: newY
}
if (workletChange) {
handleTriggerWorkletChange({
x: newX,
y: newY,
source: ''
})
}
if (bindchange) {
runOnJS(runOnJSCallback)('handleTriggerChange', {
x: newX,
y: newY,
type: 'setData'
source: ''
})
}
}
Expand All @@ -347,27 +375,6 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
}
}, [MovableAreaLayout.height, MovableAreaLayout.width])

const getTouchSource = useCallback((offsetX: number, offsetY: number) => {
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 }: { width: number; height: number }) => {
const top = (style.position === 'absolute' && style.top) || 0
const left = (style.position === 'absolute' && style.left) || 0
Expand Down Expand Up @@ -514,12 +521,18 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
const runOnJSCallback = useRunOnJSCallback(runOnJSCallbackRef)

// 节流版本的change事件触发
const handleTriggerChangeThrottled = useCallback(({ x, y, type }: { x: number; y: number; type?: string }) => {
const handleTriggerChangeThrottled = useCallback(({ x, y }: { x: number; y: number }) => {
'worklet'
const now = Date.now()
if (now - lastChangeTime.value >= changeThrottleTime) {
lastChangeTime.value = now
runOnJS(runOnJSCallback)('handleTriggerChange', { x, y, type })
const source = getTouchChangeSource(
x,
y,
draggableXRange.value,
draggableYRange.value
)
runOnJS(runOnJSCallback)('handleTriggerChange', { x, y, source })
}
}, [changeThrottleTime])

Expand All @@ -542,7 +555,6 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
.onTouchesDown((e: GestureTouchEvent) => {
'worklet'
const changedTouches = e.changedTouches[0] || { x: 0, y: 0 }
isMoving.value = false
startPosition.value = {
x: changedTouches.x,
y: changedTouches.y
Expand All @@ -561,7 +573,6 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
.onTouchesMove((e: GestureTouchEvent) => {
'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
Expand Down Expand Up @@ -589,8 +600,13 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
offsetY.value = applyBoundaryDecline(newY, draggableYRange.value)
}
Comment thread
yandadaFreedom marked this conversation as resolved.
}
if (workletChange) {
handleTriggerWorkletChange({
x: offsetX.value,
y: offsetY.value
})
}
if (bindchange) {
// 使用节流版本减少 runOnJS 调用
handleTriggerChangeThrottled({
x: offsetX.value,
y: offsetY.value
Expand All @@ -600,14 +616,12 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
.onTouchesUp((e: GestureTouchEvent) => {
'worklet'
isFirstTouch.value = true
isMoving.value = false
if (bindtouchend || catchtouchend) {
runOnJS(runOnJSCallback)('triggerEndOnJS', { e })
}
})
.onEnd((e: GestureStateChangeEvent<PanGestureHandlerEventPayload>) => {
'worklet'
isMoving.value = false
if (disabled) return
// 处理没有惯性且超出边界的回弹
if (!inertia && outOfBounds) {
Expand All @@ -623,46 +637,68 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
? withWechatSpring(y, damping)
: y
}
if (workletChange) {
handleTriggerWorkletChange({
x,
y,
source: 'out-of-bounds'
})
}
if (bindchange) {
runOnJS(runOnJSCallback)('handleTriggerChange', {
x,
y
y,
source: 'out-of-bounds'
})
}
}
} else if (inertia) {
// 惯性处理 - 使用微信小程序friction算法
if (direction === 'horizontal' || direction === 'all') {
xInertialMotion.value = true
offsetX.value = withWechatDecay(
e.velocityX / 10,
offsetX.value,
draggableXRange.value,
friction,
() => {
xInertialMotion.value = false
(finished) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 这里只在 withTiming 完成后触发一次 workletChange,惯性动画期间 offsetX/offsetY 持续变化却没有回调;上面的越界回弹和受控 x/y 动画则会在动画刚开始时直接回调目标坐标。依赖该回调同步其他 UI 的场景会在动画期间冻结或提前跳到终点,与“位置变化时在 UI 线程立即触发”的语义不符。建议通过 useAnimatedReaction(或统一的共享位置观察路径)监听 offsetX/offsetY 的实际变化,并用 shared value 标记 touch / friction / out-of-bounds source。

if (!finished) return
if (workletChange) {
handleTriggerWorkletChange({
x: offsetX.value,
y: offsetY.value,
source: 'friction'
})
}
if (bindchange) {
runOnJS(runOnJSCallback)('handleTriggerChange', {
x: offsetX.value,
y: offsetY.value
y: offsetY.value,
source: 'friction'
})
}
}
)
}
if (direction === 'vertical' || direction === 'all') {
yInertialMotion.value = true
offsetY.value = withWechatDecay(
e.velocityY / 10,
offsetY.value,
draggableYRange.value,
friction,
() => {
yInertialMotion.value = false
(finished) => {
if (!finished) return
if (workletChange) {
handleTriggerWorkletChange({
x: offsetX.value,
y: offsetY.value,
source: 'friction'
})
}
if (bindchange) {
runOnJS(runOnJSCallback)('handleTriggerChange', {
x: offsetX.value,
y: offsetY.value
y: offsetY.value,
source: 'friction'
})
}
}
Expand All @@ -688,7 +724,7 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
gesturePan.requireExternalGestureToFail(...waitForHandlers)
}
return gesturePan
}, [disabled, direction, inertia, outOfBounds, gestureSwitch.current])
}, [disabled, direction, inertia, outOfBounds, gestureSwitch.current, workletChange, handleTriggerWorkletChange])

const animatedStyles = useAnimatedStyle(() => {
return {
Expand Down Expand Up @@ -731,7 +767,8 @@ const _MovableView = forwardRef<HandlerRef<View, MovableViewProps>, MovableViewP
'catchtouchmove',
'catchvtouchmove',
'catchhtouchmove',
'catchtouchend'
'catchtouchend',
'workletChange'
])

const innerProps = useInnerProps(
Expand Down
Loading