diff --git a/.agents/skills/mpx2rn/references/rn-template-reference.md b/.agents/skills/mpx2rn/references/rn-template-reference.md index 509787cb90..dfb7cf6f25 100644 --- a/.agents/skills/mpx2rn/references/rn-template-reference.md +++ b/.agents/skills/mpx2rn/references/rn-template-reference.md @@ -796,7 +796,7 @@ Mpx 输出 RN 内置支持了大部分常用的基础组件,详情见下方文 | 属性名 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| indicator-dots | boolean | `false` | 是否显示面板指示点 | +| indicator-dots | boolean | `false` | 是否显示面板指示点,数量与 `swiper-item` 一致,多项展示时从 `current` 开始的 `display-multiple-items` 个指示点同时高亮 | | indicator-color | color | `rgba(0, 0, 0, .3)` | 指示点颜色 | | indicator-active-color | color | `#000000` | 当前选中的指示点颜色 | | indicator-width | number | | 指示点宽度 | @@ -805,13 +805,14 @@ Mpx 输出 RN 内置支持了大部分常用的基础组件,详情见下方文 | indicator-radius | number | | 指示点圆角 | | indicator-margin | number | | 指示点外边距 | | autoplay | boolean | `false` | 是否自动切换 | -| current | number | `0` | 当前所在滑块的 index | +| current | number | `0` | 当前所在滑块的 index,越界时会裁剪到合法范围 | | interval | number | `5000` | 自动切换时间间隔 | | duration | number | `500` | 滑动动画时长 | | circular | boolean | `false` | 是否采用衔接滑动 | | vertical | boolean | `false` | 滑动方向是否为纵向 | | previous-margin | string | `0` | 前边距,可用于露出前一项的一小部分,接受 px | | next-margin | string | `0` | 后边距,可用于露出后一项的一小部分,接受 px | +| display-multiple-items | number | `1` | 同时显示的滑块数量,非有限值或非正数按 `1` 处理,小数向下取整 | | scale | boolean | `false` | 滑动时是否开启前后元素缩小,默认是缩放 0.7 倍, 暂不支持自定义 | | easing-function | string | `linear` | 支持 linear、easeInCubic、easeOutCubic、easeInOutCubic | | simultaneous-handlers | array\ | `[]` | RN 环境特有属性,主要用于组件嵌套场景,允许多个手势同时识别和处理并触发,这个属性可以指定一个或多个手势处理器,处理器支持使用 this.$refs.xxx 获取组件实例来作为数组参数传递给 swiper 组件 | @@ -823,6 +824,7 @@ Mpx 输出 RN 内置支持了大部分常用的基础组件,详情见下方文 | 事件名 | 说明 | | --- | --- | | change | current 改变时会触发 change 事件,`event.detail = {current, source}` | +| changestart | swiper 切换开始时会触发 changestart 事件,`event.detail = {current}` 中的 current 为目标索引 | ### swiper-item diff --git a/docs-vitepress/guide/rn/component.md b/docs-vitepress/guide/rn/component.md index 6a56a3f781..6d5f36ac04 100644 --- a/docs-vitepress/guide/rn/component.md +++ b/docs-vitepress/guide/rn/component.md @@ -152,7 +152,7 @@ | 属性名 | 类型 | 默认值 | 说明 | | ----------------------- | ------- | ------------------ | ------------------------------------| -| indicator-dots | boolean | `false` | 是否显示面板指示点 | +| indicator-dots | boolean | `false` | 是否显示面板指示点,数量与 `swiper-item` 一致,多项展示时从 `current` 开始的 `display-multiple-items` 个指示点同时高亮 | | indicator-color | color | `rgba(0, 0, 0, .3)` | 指示点颜色 | | indicator-active-color | color | `#000000` | 当前选中的指示点颜色 | | indicator-width | number | | 指示点宽度 | @@ -161,13 +161,14 @@ | indicator-radius | number | | 指示点圆角 | | indicator-margin | number | | 指示点外边距 | | autoplay | boolean | `false` | 是否自动切换 | -| current | number | `0` | 当前所在滑块的 index | +| current | number | `0` | 当前所在滑块的 index,越界时会裁剪到合法范围 | | interval | number | `5000` | 自动切换时间间隔 | | duration | number | `500` | 滑动动画时长 | | circular | boolean | `false` | 是否采用衔接滑动 | | vertical | boolean | `false` | 滑动方向是否为纵向 | | previous-margin | string | `0` | 前边距,可用于露出前一项的一小部分,接受px | | next-margin | string | `0` | 后边距,可用于露出后一项的一小部分,接受px | +| display-multiple-items | number | `1` | 同时显示的滑块数量,非有限值或非正数按 `1` 处理,小数向下取整 | | scale | boolean | `false` | 滑动时是否开启前后元素缩小,默认是缩放0.7倍, 暂不支持自定义 | | easing-function | string | `linear` | 支持 linear、easeInCubic、easeOutCubic、easeInOutCubic| | simultaneous-handlers | array\| `[]` | RN环境特有属性,主要用于组件嵌套场景,允许多个手势同时识别和处理并触发,这个属性可以指定一个或多个手势处理器,处理器支持使用 this.$refs.xxx 获取组件实例来作为数组参数传递给 swiper 组件| @@ -179,9 +180,10 @@ 事件 -| 事件名 | 说明 | -| ----------------| ------------------ | +| 事件名 | 说明 | +| ------------| ------------------ | | change| current 改变时会触发 change 事件,`event.detail = {current, source}`| +| changestart| swiper 切换开始时会触发 changestart 事件,`event.detail = {current}` 中的 current 为目标索引 | ### swiper-item 仅可放置在swiper组件中,宽高自动设置为100%。 diff --git a/packages/webpack-plugin/lib/platform/template/wx/component-config/swiper.js b/packages/webpack-plugin/lib/platform/template/wx/component-config/swiper.js index 82099ccce6..8d55e27b34 100644 --- a/packages/webpack-plugin/lib/platform/template/wx/component-config/swiper.js +++ b/packages/webpack-plugin/lib/platform/template/wx/component-config/swiper.js @@ -67,7 +67,7 @@ module.exports = function ({ print }) { qa: qaPropLog }, { - test: /^(display-multiple-items|snap-to-edge|easing-function)$/, + test: /^(snap-to-edge|easing-function)$/, ios: iosPropLog, android: androidPropLog, harmony: harmonyPropLog diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper-utils.ts b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper-utils.ts new file mode 100644 index 0000000000..c3d1489c57 --- /dev/null +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper-utils.ts @@ -0,0 +1,122 @@ +export function normalizeDisplayMultipleItems (value: number | string | undefined) { + const displayMultipleItems = Math.floor(Number(value)) + return Number.isFinite(displayMultipleItems) ? Math.max(1, displayMultipleItems) : 1 +} + +export function getSwiperMaxIndex (childrenLength: number, displayMultipleItems: number, circular: boolean) { + 'worklet' + return Math.max(0, childrenLength - (circular ? 1 : displayMultipleItems)) +} + +export function normalizeSwiperCurrent ( + current: number | string, + childrenLength: number, + displayMultipleItems: number, + circular: boolean +) { + const currentIndex = Math.floor(Number(current)) + if (!Number.isFinite(currentIndex)) return 0 + return Math.min(Math.max(0, currentIndex), getSwiperMaxIndex(childrenLength, displayMultipleItems, circular)) +} + +export function getSwiperStep ( + mainAxisSize: number, + previousMargin: number, + nextMargin: number, + displayMultipleItems: number +) { + const step = (mainAxisSize - previousMargin - nextMargin) / displayMultipleItems + return Number.isFinite(step) && step > 0 ? step : 0 +} + +export function getSwiperPatchElmNum ( + circular: boolean, + childrenLength: number, + displayMultipleItems: number, + hasEdgeMargin: boolean, + viewportSize: number, + step: number +) { + if (!circular || childrenLength <= 1) return 0 + const basePatchElmNum = displayMultipleItems + (hasEdgeMargin ? 1 : 0) + const viewportItemCount = Math.ceil(viewportSize / step) + return Number.isFinite(viewportItemCount) + ? Math.max(basePatchElmNum, viewportItemCount) + : basePatchElmNum +} + +export function getCircularIndex (index: number, childrenLength: number) { + 'worklet' + if (!childrenLength) return 0 + return ((index % childrenLength) + childrenLength) % childrenLength +} + +export function isSwiperDotActive ( + dotIndex: number, + currentIndex: number, + displayMultipleItems: number, + childrenLength: number, + circular: boolean +) { + 'worklet' + if (dotIndex < 0 || dotIndex >= childrenLength) return false + const activeDotCount = Math.min(displayMultipleItems, childrenLength) + if (!circular) return dotIndex >= currentIndex && dotIndex < currentIndex + activeDotCount + return getCircularIndex(dotIndex - currentIndex, childrenLength) < activeDotCount +} + +export function getCircularBoundary ( + moveToOffset: number, + childrenLength: number, + patchElmNum: number, + step: number, + viewportSize: number +) { + 'worklet' + if (childrenLength <= 0 || step <= 0) { + return { + isBoundary: false, + resetOffset: 0 + } + } + const boundaryStart = 0 + const boundaryEnd = Math.max( + -(childrenLength + patchElmNum) * step, + -((childrenLength + patchElmNum * 2) * step - viewportSize) + ) + const cycleSize = childrenLength * step + if (moveToOffset < boundaryEnd) { + return { + isBoundary: true, + resetOffset: moveToOffset + Math.ceil((boundaryEnd - moveToOffset) / cycleSize) * cycleSize + } + } + if (moveToOffset > boundaryStart) { + return { + isBoundary: true, + resetOffset: moveToOffset - Math.ceil((moveToOffset - boundaryStart) / cycleSize) * cycleSize + } + } + return { + isBoundary: false, + resetOffset: 0 + } +} + +export function getSwiperResistanceOffset ( + offset: number, + translation: number, + transdir: number, + maxOffset: number, + step: number +) { + 'worklet' + const moveToOffset = offset + translation + const maxOverDrag = step / 2 + const overDrag = transdir < 0 ? Math.abs(moveToOffset - maxOffset) : Math.abs(moveToOffset) + const resistance = Math.max(0.1, Math.min(0.5, 1 - overDrag / maxOverDrag)) + const adjustOffset = offset + translation * resistance + return transdir < 0 + ? Math.max(adjustOffset, maxOffset - maxOverDrag) + : Math.min(adjustOffset, maxOverDrag) +} diff --git a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx index d8621b671e..03d62f6ced 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx @@ -1,13 +1,14 @@ import { View, NativeSyntheticEvent, LayoutChangeEvent } from 'react-native' import { GestureDetector, Gesture, PanGesture, GestureStateChangeEvent, PanGestureHandlerEventPayload } from 'react-native-gesture-handler' -import Animated, { useAnimatedStyle, useSharedValue, withTiming, Easing, runOnJS, useAnimatedReaction, cancelAnimation } from 'react-native-reanimated' +import Animated, { useAnimatedStyle, useSharedValue, withTiming, Easing, runOnJS, useAnimatedReaction, cancelAnimation, SharedValue } from 'react-native-reanimated' -import React, { JSX, forwardRef, useRef, useEffect, ReactNode, ReactElement, useMemo, createElement } from 'react' +import React, { JSX, forwardRef, useRef, useEffect, useState, ReactNode, ReactElement, useMemo, createElement } from 'react' import useInnerProps, { getCustomEvent } from './getInnerListeners' import useNodesRef, { HandlerRef } from './useNodesRef' // 引入辅助函数 import { useTransformStyle, splitStyle, splitProps, useLayout, wrapChildren, extendObject, GestureHandler, flatGesture, useRunOnJSCallback, useTextPassThrough } from './utils' import { SwiperContext } from './context' import Portal from './mpx-portal' +import { getCircularBoundary, getCircularIndex, getSwiperMaxIndex, getSwiperPatchElmNum, getSwiperResistanceOffset, getSwiperStep, isSwiperDotActive, normalizeDisplayMultipleItems, normalizeSwiperCurrent } from './mpx-swiper-utils' /** * ✔ indicator-dots * ✔ indicator-color @@ -26,7 +27,7 @@ import Portal from './mpx-portal' * ✔ previous-margin * ✔ next-margin * ✔ easing-function ="easeOutCubic" - * ✘ display-multiple-items + * ✔ display-multiple-items * ✘ snap-to-edge */ type EaseType = 'default' | 'linear' | 'easeInCubic' | 'easeOutCubic' | 'easeInOutCubic' @@ -46,7 +47,7 @@ type EventEndType = { interface SwiperProps { children?: ReactNode circular?: boolean - current?: number + current?: number | string interval?: number autoplay?: boolean // scrollView 只有安卓可以设 @@ -76,7 +77,9 @@ interface SwiperProps { 'wait-for'?: Array 'simultaneous-handlers'?: Array disableGesture?: boolean + 'display-multiple-items'?: number | string bindchange?: (event: NativeSyntheticEvent | unknown) => void + bindchangestart?: (event: NativeSyntheticEvent | unknown) => void } /** @@ -122,8 +125,33 @@ const styles: { [key: string]: Object } = { } } -const activeDotStyle = { - zIndex: 99 +interface SwiperDotProps { + index: number + currentIndex: SharedValue + displayMultipleItems: SharedValue + childrenLength: SharedValue + circular: SharedValue + activeColor: string + inactiveColor: string + style: Object +} + +function SwiperDot ({ + index, + currentIndex, + displayMultipleItems, + childrenLength, + circular, + activeColor, + inactiveColor, + style +}: SwiperDotProps) { + const dotAnimatedStyle = useAnimatedStyle(() => ({ + backgroundColor: isSwiperDotActive(index, currentIndex.value, displayMultipleItems.value, childrenLength.value, circular.value) + ? activeColor + : inactiveColor + })) + return } const longPressRatio = 100 @@ -156,7 +184,8 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr circular = false, disableGesture = false, current: propCurrent = 0, - bindchange + bindchange, + bindchangestart } = props const dotCommonStyle = { @@ -169,6 +198,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr marginBottom: dotSpacing, zIndex: 98 } + const displayMultipleItems = normalizeDisplayMultipleItems(props['display-multiple-items']) const easeingFunc = props['easing-function'] || 'default' const easeDuration = props.duration || 500 const horizontal = props.vertical !== undefined ? !props.vertical : true @@ -198,29 +228,35 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const textPassThrough = useTextPassThrough(textStyle, textProps, { enableTextPassThrough }) const preMargin = props['previous-margin'] ? global.__formatValue(props['previous-margin']) as number : 0 const nextMargin = props['next-margin'] ? global.__formatValue(props['next-margin']) as number : 0 + const hasEdgeMargin = !!preMargin || !!nextMargin const preMarginShared = useSharedValue(preMargin) const nextMarginShared = useSharedValue(nextMargin) const autoplayShared = useSharedValue(autoplay) - // 默认前后补位的元素个数 - const patchElmNum = circular ? (preMargin ? 2 : 1) : 0 - const patchElmNumShared = useSharedValue(patchElmNum) - const circularShared = useSharedValue(circular) // 支持swiper-item 同时存在并列的情况 const children = React.Children.toArray(props.children) as ReactElement[] + const displayMultipleItemsShared = useSharedValue(displayMultipleItems) + const circularShared = useSharedValue(circular) // 对有变化的变量,在worklet中只能使用sharedValue变量,useRef不能更新 const childrenLength = useSharedValue(children.length) - const initWidth = typeof normalStyle?.width === 'number' ? normalStyle.width - preMargin - nextMargin : normalStyle.width - const initHeight = typeof normalStyle?.height === 'number' ? normalStyle.height - preMargin - nextMargin : normalStyle.height const dir = horizontal === false ? 'y' : 'x' - const pstep = dir === 'x' ? initWidth : initHeight - const initStep: number = isNaN(pstep) ? 0 : pstep + const mainAxisSize = dir === 'x' ? normalStyle?.width : normalStyle?.height + const initMainAxisSize = typeof mainAxisSize === 'number' ? mainAxisSize : 0 + const [layoutMainAxisSize, setLayoutMainAxisSize] = useState(initMainAxisSize) + const mainAxisSizeShared = useSharedValue(layoutMainAxisSize) + const initStep = getSwiperStep(layoutMainAxisSize, preMargin, nextMargin, displayMultipleItems) + // 前后补位需要覆盖整个视口,避免边距较大时动画过程露白 + const patchElmNum = getSwiperPatchElmNum(circular, children.length, displayMultipleItems, hasEdgeMargin, layoutMainAxisSize, initStep) + const patchElmNumShared = useSharedValue(patchElmNum) // 每个元素的宽度 or 高度,有固定值直接初始化无则0 const step = useSharedValue(initStep) + const initCurrent = normalizeSwiperCurrent(propCurrent, children.length, displayMultipleItems, circular) // 记录选中元素的索引值 - const currentIndex = useSharedValue(propCurrent) + const currentIndex = useSharedValue(initCurrent) + // 记录动画或手势已选定、但可能尚未完成切换的目标索引 + const targetIndex = useSharedValue(initCurrent) // const initOffset = getOffset(props.current || 0, initStep) // 记录元素的偏移量 - const offset = useSharedValue(getOffset(propCurrent, initStep)) + const offset = useSharedValue(getOffset(initCurrent, initStep)) const strAbso = 'absolute' + dir.toUpperCase() as StrAbsoType const strVelocity = 'velocity' + dir.toUpperCase() as StrVelocityType // 标识手指触摸和抬起, 起点在onBegin @@ -232,9 +268,10 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr // 记录用户手滑动的方向 const moveDir = useSharedValue(0) const timerId = useRef(0 as number | ReturnType) + const propCurrentRef = useRef(propCurrent) const intervalTimer = props.interval || 500 // 记录是否首次,首次不能触发bindchange回调 - const isFirstRef = useRef(true) + const isFirstShared = useSharedValue(true) const simultaneousHandlers = flatGesture(originSimultaneousHandlers) const waitForHandlers = flatGesture(waitFor) @@ -293,41 +330,49 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr 'duration', 'scale', 'disableGesture', + 'display-multiple-items', 'wait-for', 'simultaneous-handlers', - 'bindchange' + 'bindchange', + 'bindchangestart' ], { layoutRef: layoutRef }) function onWrapperLayout (e: LayoutChangeEvent) { const { width, height } = e.nativeEvent.layout - const realWidth = dir === 'x' ? width - preMargin - nextMargin : width - const realHeight = dir === 'y' ? height - preMargin - nextMargin : height - const iStep = dir === 'x' ? realWidth : realHeight + const newMainAxisSize = dir === 'x' ? width : height + const iStep = getSwiperStep(newMainAxisSize, preMargin, nextMargin, displayMultipleItems) + const nextPatchElmNum = getSwiperPatchElmNum(circular, children.length, displayMultipleItems, hasEdgeMargin, newMainAxisSize, iStep) + mainAxisSizeShared.value = newMainAxisSize + if (newMainAxisSize !== layoutMainAxisSize) setLayoutMainAxisSize(newMainAxisSize) + if (nextPatchElmNum !== patchElmNum) { + pauseLoop() + return + } if (iStep !== step.value) { step.value = iStep - updateCurrent(propCurrent, iStep) + syncCurrent(targetIndex.value, iStep) updateAutoplay() } } - const dotAnimatedStyle = useAnimatedStyle(() => { - if (!step.value) return {} - const dotStep = dotCommonStyle.width + dotCommonStyle.marginRight + dotCommonStyle.marginLeft - if (dir === 'x') { - return { transform: [{ translateX: currentIndex.value * dotStep }] } - } else { - return { transform: [{ translateY: currentIndex.value * dotStep }] } - } - }) - function renderPagination () { const activeColor = activeDotColor || '#007aff' const unActionColor = dotColor || 'rgba(0,0,0,.2)' // 正常渲染所有dots const dots: Array = [] for (let i = 0; i < children.length; i++) { - dots.push() + dots.push() } let paginationStyle = styles['pagination_' + dir] if (paginationMargin) { @@ -342,18 +387,6 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr return ( - {dots} ) @@ -363,17 +396,18 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const intLen = children.length let renderChild = children.slice() if (circular && intLen > 1) { - // 最前面加最后一个元素 - const lastChild = React.cloneElement(children[intLen - 1] as ReactElement, { key: 'clone0' }) - // 最后面加第一个元素 - const firstChild = React.cloneElement(children[0] as ReactElement, { key: 'clone1' }) - if (preMargin) { - const lastChild1 = React.cloneElement(children[intLen - 2] as ReactElement, { key: 'clone2' }) - const firstChild1 = React.cloneElement(children[1] as ReactElement, { key: 'clone3' }) - renderChild = [lastChild1, lastChild].concat(renderChild).concat([firstChild, firstChild1]) - } else { - renderChild = [lastChild].concat(renderChild).concat([firstChild]) + const frontClones = [] + const startIndex = intLen - (patchElmNum % intLen) + for (let i = 0; i < patchElmNum; i++) { + const sourceIndex = (startIndex + i) % intLen + frontClones.push(React.cloneElement(children[sourceIndex] as ReactElement, { key: `clone_front_${i}` })) } + const backClones = [] + for (let i = 0; i < patchElmNum; i++) { + const sourceIndex = i % intLen + backClones.push(React.cloneElement(children[sourceIndex] as ReactElement, { key: `clone_back_${i}` })) + } + renderChild = [...frontClones, ...renderChild, ...backClones] } const arrChildren = renderChild.map((child, index) => { const extraStyle = {} as { [key: string]: any } @@ -403,24 +437,29 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const { loop, pauseLoop, resumeLoop } = useMemo(() => { function createAutoPlay () { - if (!step.value) return + if (!autoplayShared.value || childrenLength.value <= 1 || step.value <= 0 || touchfinish.value === false || targetIndex.value !== currentIndex.value) return let targetOffset = 0 let nextIndex = currentIndex.value if (!circularShared.value) { // 获取下一个位置的坐标, 循环到最后一个元素,直接停止, 取消定时器 - if (currentIndex.value === childrenLength.value - 1) { + const maxIndex = getSwiperMaxIndex(childrenLength.value, displayMultipleItemsShared.value, false) + if (currentIndex.value >= maxIndex) { pauseLoop() return } nextIndex += 1 // targetOffset = -nextIndex * step.value - preMarginShared.value targetOffset = -nextIndex * step.value + targetIndex.value = nextIndex + runOnJSCallback('handleSwiperChangeStart', nextIndex) offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] - }, () => { - currentIndex.value = nextIndex - runOnJS(runOnJSCallback)('loop') + }, (finished) => { + if (finished && targetIndex.value === nextIndex) { + currentIndex.value = nextIndex + runOnJS(runOnJSCallback)('resumeLoop') + } }) } else { // 默认向右, 向下 @@ -428,25 +467,33 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr nextIndex = 0 targetOffset = -(childrenLength.value + patchElmNumShared.value) * step.value + preMarginShared.value // 执行动画到下一帧 + targetIndex.value = nextIndex + runOnJSCallback('handleSwiperChangeStart', nextIndex) offset.value = withTiming(targetOffset, { duration: easeDuration - }, () => { - const initOffset = -step.value * patchElmNumShared.value + preMarginShared.value - // 将开始位置设置为真正的位置 - offset.value = initOffset - currentIndex.value = nextIndex - runOnJS(runOnJSCallback)('loop') + }, (finished) => { + if (finished && targetIndex.value === nextIndex) { + const initOffset = -step.value * patchElmNumShared.value + preMarginShared.value + // 将开始位置设置为真正的位置 + offset.value = initOffset + currentIndex.value = nextIndex + runOnJS(runOnJSCallback)('resumeLoop') + } }) } else { nextIndex = currentIndex.value + 1 targetOffset = -(nextIndex + patchElmNumShared.value) * step.value + preMarginShared.value // 执行动画到下一帧 + targetIndex.value = nextIndex + runOnJSCallback('handleSwiperChangeStart', nextIndex) offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] - }, () => { - currentIndex.value = nextIndex - runOnJS(runOnJSCallback)('loop') + }, (finished) => { + if (finished && targetIndex.value === nextIndex) { + currentIndex.value = nextIndex + runOnJS(runOnJSCallback)('resumeLoop') + } }) } } @@ -463,7 +510,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr } // resumeLoop在worklet中调用 function resumeLoop () { - if (autoplayShared.value && childrenLength.value > 1) { + if (autoplayShared.value && childrenLength.value > 1 && touchfinish.value !== false && targetIndex.value === currentIndex.value) { loop() } } @@ -479,40 +526,77 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr bindchange && bindchange(eventData) } + function handleSwiperChangeStart (current: number) { + const eventData = getCustomEvent('changestart', {}, { detail: { current }, layoutRef: layoutRef }) + bindchangestart && bindchangestart(eventData) + } + const runOnJSCallbackRef = useRef({ loop, pauseLoop, resumeLoop, - handleSwiperChange + handleSwiperChange, + handleSwiperChangeStart }) + runOnJSCallbackRef.current.loop = loop + runOnJSCallbackRef.current.pauseLoop = pauseLoop + runOnJSCallbackRef.current.resumeLoop = resumeLoop + runOnJSCallbackRef.current.handleSwiperChange = handleSwiperChange + runOnJSCallbackRef.current.handleSwiperChangeStart = handleSwiperChangeStart const runOnJSCallback = useRunOnJSCallback(runOnJSCallbackRef) function getOffset (index: number, stepValue: number) { if (!stepValue) return 0 let targetOffset = 0 if (circular && children.length > 1) { - const targetIndex = index + patchElmNum - targetOffset = -(stepValue * targetIndex - preMargin) + const targetPositionIndex = index + patchElmNum + targetOffset = -(stepValue * targetPositionIndex - preMargin) } else { targetOffset = -index * stepValue } return targetOffset } - function updateCurrent (index: number, stepValue: number) { - const targetOffset = getOffset(index || 0, stepValue) - if (targetOffset !== offset.value) { - // 内部基于props.current!==currentIndex.value决定是否使用动画及更新currentIndex.value - if (propCurrent !== undefined && propCurrent !== currentIndex.value) { + function syncCurrent (index: number | string, stepValue: number) { + const nextCurrent = normalizeSwiperCurrent(index, children.length, displayMultipleItems, circular) + if (nextCurrent !== currentIndex.value && nextCurrent !== targetIndex.value) { + runOnJSCallback('handleSwiperChangeStart', nextCurrent) + } + touchfinish.value = true + cancelAnimation(offset) + targetIndex.value = nextCurrent + offset.value = getOffset(nextCurrent, stepValue) + currentIndex.value = nextCurrent + } + + function updateCurrent (index: number | string, stepValue: number) { + const nextCurrent = normalizeSwiperCurrent(index, children.length, displayMultipleItems, circular) + if (nextCurrent === targetIndex.value) return + pauseLoop() + if (touchfinish.value === false) touchfinish.value = true + const targetOffset = getOffset(nextCurrent, stepValue) + cancelAnimation(offset) + targetIndex.value = nextCurrent + if (nextCurrent !== currentIndex.value) { + runOnJSCallback('handleSwiperChangeStart', nextCurrent) + if (targetOffset !== offset.value) { offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] - }, () => { - currentIndex.value = propCurrent + }, (finished) => { + if (finished && targetIndex.value === nextCurrent) { + currentIndex.value = nextCurrent + runOnJS(runOnJSCallback)('resumeLoop') + } }) } else { - offset.value = targetOffset + currentIndex.value = nextCurrent + updateAutoplay() } + } else { + cancelAnimation(offset) + offset.value = targetOffset + updateAutoplay() } } function updateAutoplay () { @@ -524,49 +608,43 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr } // 1. 用户在当前页切换选中项,动画;用户携带选中index打开到swiper页直接选中不走动画 useAnimatedReaction(() => currentIndex.value, (newIndex: number, preIndex: number) => { + if (isFirstShared.value) { + isFirstShared.value = false + return + } // 这里必须传递函数名, 直接写()=> {}形式会报 访问了未sharedValue信息 - if (newIndex !== preIndex && bindchange && !isFirstRef.current) { + if (newIndex !== preIndex && bindchange) { runOnJS(runOnJSCallback)('handleSwiperChange', newIndex, propCurrent) } - isFirstRef.current = false }) useEffect(() => { - let patchStep = 0 - if (preMargin !== preMarginShared.value) { - patchStep += preMargin - preMarginShared.value - } - if (nextMargin !== nextMarginShared.value) { - patchStep += nextMargin - nextMarginShared.value - } + // 1. 如果用户在touch的过程中, 外部更新了current以外部为准(小程序表现) + // 2. 手指滑动过程中更新索引,外部会把current再传入进来,导致offset直接更新,增加判断不同才更新 + const propCurrentChanged = !Object.is(propCurrent, propCurrentRef.current) + const configChanged = preMargin !== preMarginShared.value || + nextMargin !== nextMarginShared.value || + circular !== circularShared.value || + patchElmNum !== patchElmNumShared.value || + displayMultipleItems !== displayMultipleItemsShared.value || + children.length !== childrenLength.value + if (!propCurrentChanged && !configChanged) return + propCurrentRef.current = propCurrent preMarginShared.value = preMargin nextMarginShared.value = nextMargin - const newStep = step.value - patchStep - if (step.value !== newStep) { - step.value = newStep - offset.value = getOffset(currentIndex.value, newStep) - } - }, [preMargin, nextMargin]) - - useEffect(() => { + circularShared.value = circular + patchElmNumShared.value = patchElmNum + displayMultipleItemsShared.value = displayMultipleItems childrenLength.value = children.length - if (children.length - 1 < currentIndex.value) { - pauseLoop() - currentIndex.value = 0 - offset.value = getOffset(0, step.value) - if (autoplay && children.length > 1) { - loop() - } - } - }, [children.length]) - - useEffect(() => { - // 1. 如果用户在touch的过程中, 外部更新了current以外部为准(小程序表现) - // 2. 手指滑动过程中更新索引,外部会把current再传入进来,导致offset直接更新,增加判断不同才更新 - if (propCurrent !== currentIndex.value) { - updateCurrent(propCurrent, step.value) + const newStep = getSwiperStep(mainAxisSizeShared.value, preMargin, nextMargin, displayMultipleItems) + step.value = newStep + if (configChanged) { + syncCurrent(propCurrentChanged ? propCurrent : targetIndex.value, newStep) + updateAutoplay() + } else { + updateCurrent(propCurrent, newStep) } - }, [propCurrent]) + }, [propCurrent, preMargin, nextMargin, circular, patchElmNum, displayMultipleItems, children.length]) useEffect(() => { autoplayShared.value = autoplay @@ -578,13 +656,6 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr } }, [autoplay]) - useEffect(() => { - if (circular !== circularShared.value) { - circularShared.value = circular - patchElmNumShared.value = circular ? (preMargin ? 2 : 1) : 0 - offset.value = getOffset(currentIndex.value, step.value) - } - }, [circular, preMargin]) const { gestureHandler } = useMemo(() => { // 基于transdir + 当前offset计算索引 function getTargetPosition (eventData: EventEndType) { @@ -603,21 +674,23 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const moveToIndex = transdir < 0 ? Math.ceil(computedIndex) : Math.floor(computedIndex) // 实际应该定位的索引值 if (!circularShared.value) { - selectedIndex = moveToIndex + const maxIndex = getSwiperMaxIndex(childrenLength.value, displayMultipleItemsShared.value, false) + selectedIndex = Math.min(Math.max(moveToIndex, 0), maxIndex) moveToTargetPos = selectedIndex * step.value } else { + const circularIndex = getCircularIndex(moveToIndex - patchElmNumShared.value, childrenLength.value) if (moveToIndex >= childrenLength.value + patchElmNumShared.value) { - selectedIndex = moveToIndex - (childrenLength.value + patchElmNumShared.value) + selectedIndex = circularIndex resetOffsetPos = (selectedIndex + patchElmNumShared.value) * step.value - preMarginShared.value moveToTargetPos = moveToIndex * step.value - preMarginShared.value isCriticalItem = true } else if (moveToIndex <= patchElmNumShared.value - 1) { - selectedIndex = moveToIndex === 0 ? childrenLength.value - patchElmNumShared.value : childrenLength.value - 1 + selectedIndex = circularIndex resetOffsetPos = (selectedIndex + patchElmNumShared.value) * step.value - preMarginShared.value moveToTargetPos = moveToIndex * step.value - preMarginShared.value isCriticalItem = true } else { - selectedIndex = moveToIndex - patchElmNumShared.value + selectedIndex = circularIndex moveToTargetPos = moveToIndex * step.value - preMarginShared.value } } @@ -636,7 +709,8 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const gestureMovePos = offset.value + translation if (!circularShared.value) { // 如果只判断区间,中间非滑动状态(handleResistanceMove)向左滑动,突然改为向右滑动,但是还在非滑动态,本应该可滑动判断为了不可滑动 - const posEnd = -step.value * (childrenLength.value - 1) + const maxIndex = getSwiperMaxIndex(childrenLength.value, displayMultipleItemsShared.value, false) + const posEnd = -step.value * maxIndex if (transdir < 0) { return gestureMovePos > posEnd } else { @@ -646,15 +720,23 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr return true } } + function triggerChangeStart (current: number) { + 'worklet' + if (current !== currentIndex.value) { + runOnJS(runOnJSCallback)('handleSwiperChangeStart', current) + } + } function handleEnd (eventData: EventEndType) { 'worklet' const { isCriticalItem, targetOffset, resetOffset, selectedIndex } = getTargetPosition(eventData) + triggerChangeStart(selectedIndex) + targetIndex.value = selectedIndex if (isCriticalItem) { offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] - }, () => { - if (touchfinish.value !== false) { + }, (finished) => { + if (finished && touchfinish.value !== false && targetIndex.value === selectedIndex) { currentIndex.value = selectedIndex offset.value = resetOffset runOnJS(runOnJSCallback)('resumeLoop') @@ -664,8 +746,8 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] - }, () => { - if (touchfinish.value !== false) { + }, (finished) => { + if (finished && touchfinish.value !== false && targetIndex.value === selectedIndex) { currentIndex.value = selectedIndex runOnJS(runOnJSCallback)('resumeLoop') } @@ -682,13 +764,19 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr } const curIndex = currentOffset / step.value const moveToIndex = (transdir < 0 ? Math.floor(curIndex) : Math.ceil(curIndex)) - patchElmNumShared.value - const targetOffset = -(moveToIndex + patchElmNumShared.value) * step.value + (circularShared.value ? preMarginShared.value : 0) + const selectedIndex = circularShared.value + ? getCircularIndex(moveToIndex, childrenLength.value) + : Math.min(Math.max(moveToIndex, 0), getSwiperMaxIndex(childrenLength.value, displayMultipleItemsShared.value, false)) + const targetPositionIndex = circularShared.value ? moveToIndex : selectedIndex + const targetOffset = -(targetPositionIndex + patchElmNumShared.value) * step.value + (circularShared.value ? preMarginShared.value : 0) + triggerChangeStart(selectedIndex) + targetIndex.value = selectedIndex offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] - }, () => { - if (touchfinish.value !== false) { - currentIndex.value = moveToIndex + }, (finished) => { + if (finished && touchfinish.value !== false && targetIndex.value === selectedIndex) { + currentIndex.value = selectedIndex runOnJS(runOnJSCallback)('resumeLoop') } }) @@ -709,60 +797,16 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr 'worklet' // 1. 基于当前的offset和translation判断是否超过当前边界值 const { translation } = eventData - // 与终点的逻辑对齐,都是超过补位元素对应的起点offset - const boundaryStart = 0 - const boundaryEnd = -(childrenLength.value + patchElmNumShared.value) * step.value const moveToOffset = offset.value + translation - let isBoundary = false - let resetOffset = 0 - if (moveToOffset < boundaryEnd) { - isBoundary = true - // 超过边界的距离 - const exceedLength = Math.abs(moveToOffset) - Math.abs(boundaryEnd) - // 计算对标正常元素所在的offset - resetOffset = patchElmNumShared.value * step.value + exceedLength - } - if (moveToOffset > boundaryStart) { - isBoundary = true - // 超过边界的距离 - const exceedLength = Math.abs(boundaryStart) - Math.abs(moveToOffset) - // 计算对标正常元素所在的offset - resetOffset = (patchElmNumShared.value + childrenLength.value - 1) * step.value - exceedLength - } - return { - isBoundary, - resetOffset: -resetOffset - } + return getCircularBoundary(moveToOffset, childrenLength.value, patchElmNumShared.value, step.value, mainAxisSizeShared.value) } // 非循环超出边界,应用阻力; 开始滑动少阻力小,滑动越长阻力越大 function handleResistanceMove (eventData: EventDataType) { 'worklet' const { translation, transdir } = eventData - const moveToOffset = offset.value + translation - const maxOverDrag = Math.floor(step.value / 2) - const maxOffset = translation < 0 ? -(childrenLength.value - 1) * step.value : 0 - let resistance = 0.1 - let overDrag = 0 - let finalOffset = 0 - // 向右向下小于0, 向左向上大于0; - if (transdir < 0) { - overDrag = Math.abs(moveToOffset - maxOffset) - } else { - overDrag = Math.abs(moveToOffset) - } - // 滑动越多resistance越小 - resistance = 1 - overDrag / maxOverDrag - // 确保阻力在合理范围内 - resistance = Math.min(0.5, resistance) - // 限制在最大拖拽范围内 - if (transdir < 0) { - const adjustOffset = offset.value + translation * resistance - finalOffset = Math.max(adjustOffset, maxOffset - maxOverDrag) - } else { - const adjustOffset = offset.value + translation * resistance - finalOffset = Math.min(adjustOffset, maxOverDrag) - } - return finalOffset + const maxIndex = getSwiperMaxIndex(childrenLength.value, displayMultipleItemsShared.value, false) + const maxOffset = translation < 0 ? -maxIndex * step.value : 0 + return getSwiperResistanceOffset(offset.value, translation, transdir, maxOffset, step.value) } // 设置手势移动的方向 function setMoveDir (curAbsoPos: number) { @@ -775,9 +819,10 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const gesturePan = Gesture.Pan() .onBegin((e: GestureStateChangeEvent) => { 'worklet' - if (!step.value) return + if (step.value <= 0 || !childrenLength.value) return touchfinish.value = false cancelAnimation(offset) + targetIndex.value = currentIndex.value runOnJS(runOnJSCallback)('pauseLoop') preAbsolutePos.value = e[strAbso] moveTranstion.value = e[strAbso] @@ -785,7 +830,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr .onUpdate((e: GestureStateChangeEvent) => { 'worklet' const moveDistance = e[strAbso] - preAbsolutePos.value - if (touchfinish.value || moveDistance === 0) return + if (touchfinish.value || moveDistance === 0 || step.value <= 0 || !childrenLength.value) return const eventData = { translation: moveDistance, transdir: moveDistance @@ -794,7 +839,11 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const offsetHalf = computeHalf() if (childrenLength.value > 1 && offsetHalf) { const { selectedIndex } = getTargetPosition({ transdir: moveDistance } as EventEndType) - currentIndex.value = selectedIndex + if (selectedIndex !== currentIndex.value) { + triggerChangeStart(selectedIndex) + targetIndex.value = selectedIndex + currentIndex.value = selectedIndex + } } // 2. 非循环: 处理用户一直拖拽到临界点的场景,如果放到onFinalize无法阻止offset.value更新为越界的值 if (!circularShared.value) { @@ -830,6 +879,10 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr 'worklet' if (touchfinish.value) return touchfinish.value = true + if (step.value <= 0 || !childrenLength.value) { + targetIndex.value = currentIndex.value + return + } // 触发过onUpdate正常情况下e[strAbso] - preAbsolutePos.value=0; 未触发过onUpdate的情况下e[strAbso] - preAbsolutePos.value 不为0 // 正常状态下基于onUpdate时的moveDir判断方向、未触发onUpdate的则基于onBegin的moveTranstion判断方向 const moveDistance = e[strAbso] - preAbsolutePos.value diff --git a/packages/webpack-plugin/test/platform/wx/template/swiper.spec.js b/packages/webpack-plugin/test/platform/wx/template/swiper.spec.js new file mode 100644 index 0000000000..585c6456e4 --- /dev/null +++ b/packages/webpack-plugin/test/platform/wx/template/swiper.spec.js @@ -0,0 +1,44 @@ +const { compileTemplate, warnFn, errorFn } = require('../../util') + +describe('swiper template transform', function () { + afterEach(() => { + warnFn.mockClear() + errorFn.mockClear() + }) + + it('should support display-multiple-items in react native modes', function () { + const input = '' + + ;['ios', 'android', 'harmony'].forEach((mode) => { + compileTemplate(input, { srcMode: 'wx', mode }) + }) + + expect(warnFn.mock.calls.map(args => args[0])).not.toEqual(expect.arrayContaining([ + expect.stringContaining(' does not support [display-multiple-items] property') + ])) + expect(errorFn).not.toHaveBeenCalled() + }) + + it('should preserve changestart handlers in react native modes', function () { + const input = '' + + ;['ios', 'android', 'harmony'].forEach((mode) => { + expect(compileTemplate(input, { srcMode: 'wx', mode })).toContain('bindchangestart: (this.handleStart)') + }) + + expect(errorFn).not.toHaveBeenCalled() + }) + + it('should continue warning unsupported properties in react native modes', function () { + const input = '' + + ;['ios', 'android', 'harmony'].forEach((mode) => { + compileTemplate(input, { srcMode: 'wx', mode }) + }) + + expect(warnFn.mock.calls.map(args => args[0])).toEqual(expect.arrayContaining([ + expect.stringContaining(' does not support [snap-to-edge] property') + ])) + expect(errorFn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/mpx-swiper-utils.spec.ts b/packages/webpack-plugin/test/runtime/react-native/mpx-swiper-utils.spec.ts new file mode 100644 index 0000000000..2c98e21dee --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/mpx-swiper-utils.spec.ts @@ -0,0 +1,110 @@ +/// + +import { + getCircularBoundary, + getCircularIndex, + getSwiperMaxIndex, + getSwiperPatchElmNum, + getSwiperResistanceOffset, + getSwiperStep, + isSwiperDotActive, + normalizeDisplayMultipleItems, + normalizeSwiperCurrent +} from '../../../lib/runtime/components/react/mpx-swiper-utils' + +describe('MpxSwiper RN runtime calculations', () => { + test.each([ + [undefined, 1], + ['3', 3], + [2.8, 2], + [0, 1], + [-2, 1], + [Infinity, 1], + ['invalid', 1] + ])('normalizes display-multiple-items %p to %p', (value, expected) => { + expect(normalizeDisplayMultipleItems(value)).toBe(expected) + }) + + test('calculates step from the latest size, margins and display count', () => { + expect(getSwiperStep(300, 10, 20, 3)).toBe(90) + expect(getSwiperStep(300, 10, 20, 2)).toBe(135) + expect(getSwiperStep(300, 40, 20, 3)).toBe(80) + expect(getSwiperStep(0, 10, 20, 3)).toBe(0) + }) + + test('clamps current to the last complete non-circular viewport', () => { + expect(getSwiperMaxIndex(5, 3, false)).toBe(2) + expect(normalizeSwiperCurrent('4', 5, 3, false)).toBe(2) + expect(normalizeSwiperCurrent(-1, 5, 3, false)).toBe(0) + expect(normalizeSwiperCurrent(1.8, 5, 3, false)).toBe(1) + expect(normalizeSwiperCurrent(Infinity, 5, 3, false)).toBe(0) + }) + + test('keeps every child reachable in circular mode', () => { + expect(getSwiperMaxIndex(5, 3, true)).toBe(4) + expect(normalizeSwiperCurrent(4, 5, 3, true)).toBe(4) + expect(getCircularIndex(-1, 5)).toBe(4) + expect(getCircularIndex(5, 5)).toBe(0) + }) + + test('activates every visible item dot in non-circular mode', () => { + expect([0, 1, 2, 3, 4].map(index => isSwiperDotActive(index, 0, 3, 5, false))).toEqual([ + true, + true, + true, + false, + false + ]) + expect([0, 1, 2, 3, 4].map(index => isSwiperDotActive(index, 2, 3, 5, false))).toEqual([ + false, + false, + true, + true, + true + ]) + }) + + test('wraps active item dots in circular mode', () => { + expect([0, 1, 2, 3, 4].map(index => isSwiperDotActive(index, 4, 2, 5, true))).toEqual([ + true, + false, + false, + false, + true + ]) + }) + + test('renders enough circular clones to cover the viewport', () => { + expect(getSwiperPatchElmNum(true, 5, 2, true, 300, 80)).toBe(4) + expect(getSwiperPatchElmNum(true, 5, 2, false, 300, 150)).toBe(2) + expect(getSwiperPatchElmNum(false, 5, 2, true, 300, 80)).toBe(0) + }) + + test('wraps circular offsets by exactly one children cycle', () => { + expect(getCircularBoundary(10, 5, 3, 100, 300)).toEqual({ + isBoundary: true, + resetOffset: -490 + }) + expect(getCircularBoundary(-810, 5, 3, 100, 300)).toEqual({ + isBoundary: true, + resetOffset: -310 + }) + expect(getCircularBoundary(-1810, 5, 3, 100, 300)).toEqual({ + isBoundary: true, + resetOffset: -310 + }) + }) + + test('wraps before large edge margins exhaust the circular clones', () => { + expect(getCircularBoundary(-590, 5, 3, 80, 300)).toEqual({ + isBoundary: true, + resetOffset: -190 + }) + }) + + test('keeps over-drag resistance finite and in the drag direction', () => { + expect(getSwiperResistanceOffset(0, -100, -100, 0, 100)).toBe(-10) + expect(getSwiperResistanceOffset(0, -1, -1, 0, 1)).toBe(-0.1) + expect(getSwiperResistanceOffset(0, 100, 100, 0, 100)).toBe(10) + }) +}) diff --git a/packages/webpack-plugin/test/runtime/react-native/mpx-swiper.spec.ts b/packages/webpack-plugin/test/runtime/react-native/mpx-swiper.spec.ts new file mode 100644 index 0000000000..fa9300b2c7 --- /dev/null +++ b/packages/webpack-plugin/test/runtime/react-native/mpx-swiper.spec.ts @@ -0,0 +1,453 @@ +/// + +type DependencyList = readonly unknown[] | undefined +type EffectCleanup = void | (() => void) +type EffectSlot = { + cleanup: EffectCleanup + deps: DependencyList +} +type MemoSlot = { + value: unknown + deps: DependencyList +} +type Reaction = { + prepare: () => unknown + react: (current: unknown, previous: unknown) => void + previous: unknown +} +type SharedValue = { + value: unknown +} +type PendingAnimation = { + callback?: (finished: boolean) => void + cancelled: boolean + completed: boolean + sharedValue?: SharedValue + value: unknown +} +type AnimationValue = { + mockAnimation: PendingAnimation +} +type GestureCallback = (event: Record) => void +type MockGesture = Record & { + callbacks: Record +} + +let mockRefIndex = 0 +let mockStateIndex = 0 +let mockMemoIndex = 0 +let mockEffectIndex = 0 +let mockSharedValueIndex = 0 +let mockReactionIndex = 0 +let mockRefs: Array<{ current: unknown }> = [] +let mockStates: unknown[] = [] +let mockMemos: MemoSlot[] = [] +let mockEffects: EffectSlot[] = [] +let mockSharedValues: SharedValue[] = [] +let mockReactions: Reaction[] = [] +let mockPendingAnimations: PendingAnimation[] = [] +let mockGestures: MockGesture[] = [] +let mockAnimatedStyleFactories: Array<() => unknown> = [] + +function mockAreHookInputsEqual (nextDeps: DependencyList, prevDeps: DependencyList) { + return !!nextDeps && !!prevDeps && + nextDeps.length === prevDeps.length && + nextDeps.every((value, index) => Object.is(value, prevDeps[index])) +} + +function mockBeginRender () { + mockRefIndex = 0 + mockStateIndex = 0 + mockMemoIndex = 0 + mockEffectIndex = 0 + mockSharedValueIndex = 0 + mockReactionIndex = 0 + mockAnimatedStyleFactories = [] +} + +function mockResetHooks () { + mockRefs = [] + mockStates = [] + mockMemos = [] + mockEffects = [] + mockSharedValues = [] + mockReactions = [] + mockPendingAnimations = [] + mockGestures = [] + mockBeginRender() +} + +function mockCleanupEffects () { + mockEffects.forEach(({ cleanup }) => cleanup && cleanup()) +} + +function mockNotifyReactions () { + mockReactions.forEach((reaction) => { + const current = reaction.prepare() + if (current !== reaction.previous) { + const previous = reaction.previous + reaction.previous = current + reaction.react(current, previous) + } + }) +} + +function mockCancelSharedValueAnimation (sharedValue: SharedValue) { + mockPendingAnimations.forEach((animation) => { + if (animation.sharedValue === sharedValue && !animation.completed) { + animation.cancelled = true + } + }) +} + +function mockIsAnimationValue (value: unknown): value is AnimationValue { + return !!value && typeof value === 'object' && 'mockAnimation' in value +} + +function mockFinishAnimations () { + mockPendingAnimations.slice().forEach((animation) => { + if (!animation.completed) { + animation.completed = true + animation.callback?.(!animation.cancelled) + } + }) +} + +function mockGetOffset () { + const styleFactory = mockAnimatedStyleFactories[mockAnimatedStyleFactories.length - 1] + const style = styleFactory() as { transform: Array<{ translateX: number }> } + return style.transform[0].translateX +} + +jest.mock('react-native', () => ({ + View: 'View' +}), { virtual: false }) + +jest.mock('react-native-gesture-handler', () => { + const createGesture = () => { + const gesture = { callbacks: {} } as MockGesture + ;['onBegin', 'onUpdate', 'onFinalize'].forEach((method) => { + gesture[method] = (callback: GestureCallback) => { + gesture.callbacks[method] = callback + return gesture + } + }) + ;[ + 'withRef', + 'activeOffsetX', + 'activeOffsetY', + 'failOffsetX', + 'failOffsetY', + 'simultaneousWithExternalGesture', + 'requireExternalGestureToFail' + ].forEach((method) => { + gesture[method] = () => gesture + }) + mockGestures.push(gesture) + return gesture + } + return { + GestureDetector: 'GestureDetector', + Gesture: { Pan: createGesture } + } +}, { virtual: false }) + +jest.mock('react-native-reanimated', () => ({ + __esModule: true, + default: { View: 'AnimatedView' }, + useSharedValue: (initialValue: unknown) => { + const index = mockSharedValueIndex++ + if (!mockSharedValues[index]) { + let value = initialValue + const sharedValue = {} as SharedValue + Object.defineProperty(sharedValue, 'value', { + get: () => value, + set: (nextValue: unknown) => { + mockCancelSharedValueAnimation(sharedValue) + if (mockIsAnimationValue(nextValue)) { + nextValue.mockAnimation.sharedValue = sharedValue + value = nextValue.mockAnimation.value + } else { + value = nextValue + } + mockNotifyReactions() + } + }) + mockSharedValues[index] = sharedValue + } + return mockSharedValues[index] + }, + useAnimatedStyle: (factory: () => unknown) => { + mockAnimatedStyleFactories.push(factory) + return factory() + }, + withTiming: (value: unknown, _options: unknown, callback?: (finished: boolean) => void) => { + const animation = { value, callback, cancelled: false, completed: false } + mockPendingAnimations.push(animation) + return { mockAnimation: animation } + }, + Easing: { + cubic: 'cubic', + linear: 'linear', + in: (value: unknown) => value, + out: (value: unknown) => value, + inOut: (value: unknown) => value + }, + runOnJS: (callback: (...args: any[]) => unknown) => callback, + useAnimatedReaction: (prepare: () => unknown, react: (current: unknown, previous: unknown) => void) => { + const index = mockReactionIndex++ + const reaction = mockReactions[index] + if (reaction) { + reaction.prepare = prepare + reaction.react = react + } else { + const current = prepare() + mockReactions[index] = { prepare, react, previous: current } + react(current, null) + } + }, + cancelAnimation: (sharedValue: SharedValue) => mockCancelSharedValueAnimation(sharedValue) +}), { virtual: false }) + +jest.mock('react', () => { + const actual = jest.requireActual('react') + return Object.assign({}, actual, { + forwardRef: (render: unknown) => render, + useEffect: (effect: () => EffectCleanup, deps?: DependencyList) => { + const index = mockEffectIndex++ + const slot = mockEffects[index] + if (!slot || !mockAreHookInputsEqual(deps, slot.deps)) { + slot?.cleanup && slot.cleanup() + mockEffects[index] = { cleanup: effect(), deps } + } + }, + useMemo: (factory: () => unknown, deps?: DependencyList) => { + const index = mockMemoIndex++ + const slot = mockMemos[index] + if (!slot || !mockAreHookInputsEqual(deps, slot.deps)) { + mockMemos[index] = { value: factory(), deps } + } + return mockMemos[index].value + }, + useRef: (initialValue: unknown) => { + const index = mockRefIndex++ + if (!mockRefs[index]) mockRefs[index] = { current: initialValue } + return mockRefs[index] + }, + useState: (initialValue: unknown) => { + const index = mockStateIndex++ + if (!(index in mockStates)) mockStates[index] = initialValue + return [mockStates[index], (nextValue: unknown) => { + mockStates[index] = nextValue + }] + } + }) +}) + +jest.mock('../../../lib/runtime/components/react/getInnerListeners', () => ({ + __esModule: true, + default: () => ({}), + getCustomEvent: (type: string, _event: unknown, options: { detail: unknown }) => ({ + type, + detail: options.detail + }) +})) + +jest.mock('../../../lib/runtime/components/react/useNodesRef', () => ({ + __esModule: true, + default: () => undefined +})) + +jest.mock('../../../lib/runtime/components/react/context', () => ({ + SwiperContext: { Provider: 'SwiperProvider' } +})) + +jest.mock('../../../lib/runtime/components/react/mpx-portal', () => ({ + __esModule: true, + default: 'Portal' +})) + +jest.mock('../../../lib/runtime/components/react/utils', () => ({ + useTransformStyle: (style: Record) => ({ + normalStyle: style, + hasVarDec: false, + varContextRef: { current: {} }, + hasSelfPercent: false, + hasPositionFixed: false, + setWidth: jest.fn(), + setHeight: jest.fn() + }), + splitStyle: () => ({ textStyle: {}, innerStyle: {} }), + splitProps: () => ({ textProps: {} }), + useLayout: () => ({ layoutRef: { current: {} }, layoutProps: {}, layoutStyle: {} }), + wrapChildren: (children: unknown) => children, + extendObject: Object.assign, + flatGesture: () => [], + useRunOnJSCallback: (callbackMapRef: { current: Record unknown> }) => { + return (key: string, ...args: any[]) => callbackMapRef.current[key]?.(...args) + }, + useTextPassThrough: () => undefined +})) + +// eslint-disable-next-line import/first +import React from 'react' +// eslint-disable-next-line import/first +import Swiper from '../../../lib/runtime/components/react/mpx-swiper' + +function renderSwiper (props: Record) { + mockBeginRender() + return (Swiper as any)(props, null) +} + +function findElementByType (element: any, type: unknown): any { + if (!element) return null + if (Array.isArray(element)) { + let matched = null + element.some((child) => { + matched = findElementByType(child, type) + return !!matched + }) + return matched + } + if (element.type === type) return element + return findElementByType(element.props?.children, type) +} + +describe('MpxSwiper RN runtime events', () => { + const children = [0, 1, 2].map((key) => React.createElement('SwiperItem', { key })) + + beforeEach(() => { + jest.useFakeTimers() + mockResetHooks() + }) + + afterEach(() => { + mockCleanupEffects() + jest.clearAllTimers() + jest.useRealTimers() + }) + + test('emits changestart before change when autoplay selects the next item', () => { + const eventOrder: string[] = [] + + renderSwiper({ + style: { width: 300, height: 100 }, + autoplay: true, + interval: 10, + 'display-multiple-items': '2', + bindchangestart: (event: { type: string; detail: { current: number } }) => { + eventOrder.push(`${event.type}:${event.detail.current}`) + }, + bindchange: (event: { type: string; detail: { current: number } }) => { + eventOrder.push(`${event.type}:${event.detail.current}`) + }, + children + }) + + jest.advanceTimersByTime(10) + expect(eventOrder).toEqual(['changestart:1']) + + mockFinishAnimations() + expect(eventOrder).toEqual(['changestart:1', 'change:1']) + }) + + test('does not commit an obsolete controlled-current transition', () => { + const eventOrder: string[] = [] + const props = { + style: { width: 300, height: 100 }, + bindchangestart: (event: { type: string; detail: { current: number } }) => { + eventOrder.push(`${event.type}:${event.detail.current}`) + }, + bindchange: (event: { type: string; detail: { current: number } }) => { + eventOrder.push(`${event.type}:${event.detail.current}`) + }, + children + } + + renderSwiper(Object.assign({ current: 0 }, props)) + renderSwiper(Object.assign({ current: 2 }, props)) + renderSwiper(Object.assign({ current: 0 }, props)) + mockFinishAnimations() + + expect(eventOrder).toEqual(['changestart:2']) + expect(mockReactions[0].prepare()).toBe(0) + }) + + test('keeps the partial offset when the parent echoes a gesture current', () => { + const props = { + style: { width: 300, height: 100 }, + bindchange: jest.fn(), + children + } + + renderSwiper(Object.assign({ current: 0 }, props)) + const gesture = mockGestures[0].callbacks + gesture.onBegin({ absoluteX: 0 }) + gesture.onUpdate({ absoluteX: -160 }) + gesture.onUpdate({ absoluteX: -170 }) + expect(mockGetOffset()).toBe(-170) + + renderSwiper(Object.assign({ current: 1 }, props)) + + expect(props.bindchange).toHaveBeenCalledTimes(1) + expect(mockGetOffset()).toBe(-170) + }) + + test('does not resume autoplay after it is disabled during a transition', () => { + const eventOrder: string[] = [] + const props = { + style: { width: 300, height: 100 }, + interval: 10, + bindchangestart: (event: { type: string; detail: { current: number } }) => { + eventOrder.push(`${event.type}:${event.detail.current}`) + }, + bindchange: (event: { type: string; detail: { current: number } }) => { + eventOrder.push(`${event.type}:${event.detail.current}`) + }, + children + } + + renderSwiper(Object.assign({ autoplay: true }, props)) + jest.advanceTimersByTime(10) + renderSwiper(Object.assign({ autoplay: false }, props)) + mockFinishAnimations() + jest.advanceTimersByTime(20) + + expect(eventOrder).toEqual(['changestart:1', 'change:1']) + }) + + test('renders enough circular clones for a viewport with large edge margins', () => { + const circularChildren = [0, 1, 2, 3, 4].map((key) => React.createElement('SwiperItem', { key })) + const result = renderSwiper({ + style: { width: 300, height: 100 }, + circular: true, + disableGesture: true, + 'display-multiple-items': 2, + 'next-margin': '140', + children: circularChildren + }) + const provider = findElementByType(result, 'SwiperProvider') + + expect(React.Children.count(provider.props.children)).toBe(13) + }) + + test('emits change when config and controlled current update together', () => { + const eventOrder: string[] = [] + const fiveChildren = [0, 1, 2, 3, 4].map((key) => React.createElement('SwiperItem', { key })) + const props = { + style: { width: 300, height: 100 }, + bindchangestart: (event: { type: string; detail: { current: number } }) => { + eventOrder.push(`${event.type}:${event.detail.current}`) + }, + bindchange: (event: { type: string; detail: { current: number } }) => { + eventOrder.push(`${event.type}:${event.detail.current}`) + }, + children: fiveChildren + } + + renderSwiper(Object.assign({ current: 0, 'display-multiple-items': 2 }, props)) + renderSwiper(Object.assign({ current: 4, 'display-multiple-items': 3 }, props)) + + expect(eventOrder).toEqual(['changestart:2', 'change:2']) + }) +}) diff --git a/solutions/rn-mpx-swiper-display-multiple-items-changestart.md b/solutions/rn-mpx-swiper-display-multiple-items-changestart.md new file mode 100644 index 0000000000..a84624d782 --- /dev/null +++ b/solutions/rn-mpx-swiper-display-multiple-items-changestart.md @@ -0,0 +1,183 @@ +# Mpx2RN mpx-swiper 多项展示与 changestart 支持方案 + +## 目标 + +Mpx2RN 的 `swiper` 新增两项能力: + +1. 支持 `display-multiple-items`,一屏展示多个 `swiper-item`。 +2. 支持 `bindchangestart`,在 swiper 开始切换时通知目标索引。 + +实现尽量复用现有 `step`、offset、手势和循环补位模型,不修改 `mpx-swiper-item`,不重写手势状态机。 + +## 使用方式 + +```html + + + + + +``` + +```js +function handleChangeStart(event) { + const { current } = event.detail +} +``` + +- `display-multiple-items` 默认值为 `1`。 +- `changestart` 返回 `event.detail = { current }`。 +- 原有 `change` 语义不变,仍返回 `event.detail = { current, source }`。 + +## 核心实现 + +### display-multiple-items + +运行时接收并将属性归一化为有限正整数: + +```ts +const displayMultipleItems = normalizeDisplayMultipleItems(props['display-multiple-items']) +``` + +模板组件配置同时放开 iOS、Android 和 Harmony 对该属性的校验,避免继续提示 unsupported warning。 + +#### 1. 重算单个 item 尺寸 + +`mpx-swiper-item` 已使用 `SwiperContext.step` 设置宽度或高度,因此只需修改 `step`: + +```text +availableSize = mainAxisSize - previousMargin - nextMargin +step = availableSize / displayMultipleItems +``` + +- 横向 swiper 使用可用宽度。 +- 纵向 swiper 使用可用高度。 +- offset、动画和手势继续以一个 `step` 为移动单位。 + +#### 2. 调整非循环边界 + +多项展示时,最后一个合法起点为: + +```ts +const maxIndex = Math.max( + 0, + childrenLength - displayMultipleItems +) +``` + +例如 5 个 item 同时展示 3 个时,`maxIndex = 2`,最后一屏为 `[2, 3, 4]`。 + +`maxIndex` 统一用于手势目标、autoplay 终点和边界阻力,避免最后一屏继续滑出空白。 +外部 `current` 和动态配置变更也使用同一上限归一化,避免产生越界 offset。 + +#### 3. 统一重算动态布局 + +缓存 swiper 主轴尺寸,`display-multiple-items`、`previous-margin` 或 `next-margin` 变化时均通过完整公式重算 `step`,并在同一次状态对齐中更新索引、offset 与 autoplay。 + +#### 4. 调整循环补位 + +循环模式需要在真实 children 前后克隆足够的 item。基础数量为: + +```ts +const hasEdgeMargin = !!previousMargin || !!nextMargin +const patchElmNum = circular && childrenLength > 1 + ? displayMultipleItems + (hasEdgeMargin ? 1 : 0) + : 0 +``` + +| 场景 | 前后各补数量 | +| --- | ---: | +| 无 margin | `displayMultipleItems` | +| 有 previous-margin 或 next-margin | `displayMultipleItems + 1` | + +实际补位数还会与 `ceil(viewportSize / step)` 取较大值,保证自动播放和手势动画的任意一帧都有足够 clone 覆盖视口。 + +补位区通过取模映射回真实索引: + +```ts +let index = (moveToIndex - patchElmNum) % childrenLength +if (index < 0) index += childrenLength +``` + +这样可以统一处理前置 clone、真实 children 和后置 clone,避免补位数量增加后跳错索引。 +滑动越过补位边界时,offset 始终按 `childrenLength * step` 的完整周期平移。回绕阈值同时考虑视口尺寸,在剩余 clone 不足以覆盖视口前提前回绕,避免大边距场景露白。 + +#### 5. 对齐多项展示指示点 + +指示点总数仍与真实 `swiper-item` 数量一致,不包含循环补位 clone。`display-multiple-items` 大于 `1` 时,从 `current` 开始的多个主展示项对应指示点同时高亮;循环末尾使用真实索引取模,例如 5 项、`current=4`、同时展示 2 项时高亮第 5、1 个指示点。 + +### changestart + +目标索引确定后创建事件: + +```ts +function handleSwiperChangeStart(current) { + const event = getCustomEvent('changestart', {}, { + detail: { current }, + layoutRef + }) + bindchangestart && bindchangestart(event) +} +``` + +覆盖四条切换路径: + +1. autoplay 确定下一个索引。 +2. 外部更新 `current`。 +3. 手势结束并确定目标索引。 +4. 拖动超过半个 item,目标索引发生变化。 + +事件时序: + +```text +确定目标索引 + -> changestart + -> 执行动画或更新索引 + -> change +``` + +`changestart` 表示切换开始,`change` 表示 current 已经更新。 + +## 兼容性与非目标 + +1. 未传 `display-multiple-items` 时默认为 `1`,保留原单项展示行为。 +2. 单项展示且无 margin 时,循环模式仍然前后各补一个 item。 +3. 横向、纵向、autoplay、circular、previous-margin 和 next-margin 继续可用。 +4. `snap-to-edge` 仍不支持。 +5. 不修改 `change` 事件现有的 `source: 'touch'` 行为。 + +## 已知风险 + +1. **循环渲染开销**:展示数量较大时 clone 数量同步增加;当展示数量大于 children 数量时会重复克隆。 +2. **事件触发次数**:快速往返拖动时目标索引可能多次变化,因此一次手势可能触发多次 `changestart`。 +3. **真机覆盖**:核心数值与边界逻辑已有 RN runtime 单测,手势动画的平台实现仍需真机回归。 + +## 验证重点 + +1. 横向、纵向分别验证展示数量 `1/2/3`。 +2. 非循环模式验证最后一屏和边界阻力。 +3. 循环模式验证无 margin、previous-margin、next-margin。 +4. 验证 children 数量小于、等于和大于展示数量。 +5. 验证 autoplay、外部 `current`、快速反向滑动。 +6. 验证 `changestart` 先于对应的 `change`。 +7. 验证指示点总数与真实 children 一致,且多项展示和循环跨尾部时高亮范围正确。 + +## Review 重点 + +本次方案的核心关系是: + +```text +displayMultipleItems + -> step 变小 + -> 非循环 maxIndex 调整 + -> 循环 clone 数量增加 +``` + +Review 时主要确认: + +1. 循环补位公式是否覆盖业务使用的 margin 组合。 +2. 动态配置、children 变化与外部 `current` 是否始终同步到同一组 step、索引和 offset。 +3. 快速往返拖动可能多次触发 `changestart` 是否符合业务预期。