From 388d711f824829053a822ae2d836bf671dfa40ee Mon Sep 17 00:00:00 2001 From: xuegan Date: Mon, 6 Jul 2026 10:59:55 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0display-multiple-?= =?UTF-8?q?items=E5=92=8Cchangestart=E4=BA=8B=E4=BB=B6=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/components/react/mpx-swiper.tsx | 97 ++- ...iper-display-multiple-items-changestart.md | 618 ++++++++++++++++++ 2 files changed, 685 insertions(+), 30 deletions(-) create mode 100644 solutions/rn-mpx-swiper-display-multiple-items-changestart.md 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 ba2ddbdb85..36c532ea13 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx @@ -26,7 +26,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' @@ -77,7 +77,9 @@ interface SwiperProps { 'wait-for'?: Array 'simultaneous-handlers'?: Array disableGesture?: boolean + 'display-multiple-items'?: number bindchange?: (event: NativeSyntheticEvent | unknown) => void + bindchangestart?: (event: NativeSyntheticEvent | unknown) => void } /** @@ -158,7 +160,8 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr circular = false, disableGesture = false, current: propCurrent = 0, - bindchange + bindchange, + bindchangestart } = props const dotCommonStyle = { @@ -171,6 +174,10 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr marginBottom: dotSpacing, zIndex: 98 } + const propDisplayMultipleItems = Number(props['display-multiple-items']) + const displayMultipleItems = Number.isFinite(propDisplayMultipleItems) + ? Math.max(1, Math.floor(propDisplayMultipleItems)) + : 1 const easeingFunc = props['easing-function'] || 'default' const easeDuration = props.duration || 500 const horizontal = props.vertical !== undefined ? !props.vertical : true @@ -201,22 +208,26 @@ 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) + // 支持swiper-item 同时存在并列的情况 + const children = React.Children.toArray(props.children) as ReactElement[] // 默认前后补位的元素个数 - const patchElmNum = circular ? (preMargin ? 2 : 1) : 0 + const patchElmNum = (circular && children.length > 1) + ? displayMultipleItems + (hasEdgeMargin ? 1 : 0) + : 0 const patchElmNumShared = useSharedValue(patchElmNum) + const displayMultipleItemsShared = useSharedValue(displayMultipleItems) const circularShared = useSharedValue(circular) - // 支持swiper-item 同时存在并列的情况 - const children = React.Children.toArray(props.children) as ReactElement[] // 对有变化的变量,在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 initStep: number = isNaN(pstep) ? 0 : pstep / displayMultipleItems // 每个元素的宽度 or 高度,有固定值直接初始化无则0 const step = useSharedValue(initStep) // 记录选中元素的索引值 @@ -296,9 +307,11 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr 'duration', 'scale', 'disableGesture', + 'display-multiple-items', 'wait-for', 'simultaneous-handlers', - 'bindchange' + 'bindchange', + 'bindchangestart' ], { layoutRef: layoutRef }) @@ -306,7 +319,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr 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 iStep = (dir === 'x' ? realWidth : realHeight) / displayMultipleItems if (iStep !== step.value) { step.value = iStep updateCurrent(propCurrent, iStep) @@ -366,17 +379,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 } @@ -411,13 +425,14 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr let nextIndex = currentIndex.value if (!circularShared.value) { // 获取下一个位置的坐标, 循环到最后一个元素,直接停止, 取消定时器 - if (currentIndex.value === childrenLength.value - 1) { + if (currentIndex.value === childrenLength.value - displayMultipleItemsShared.value) { pauseLoop() return } nextIndex += 1 // targetOffset = -nextIndex * step.value - preMarginShared.value targetOffset = -nextIndex * step.value + runOnJSCallback('handleSwiperChangeStart', nextIndex) offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] @@ -431,6 +446,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr nextIndex = 0 targetOffset = -(childrenLength.value + patchElmNumShared.value) * step.value + preMarginShared.value // 执行动画到下一帧 + runOnJSCallback('handleSwiperChangeStart', nextIndex) offset.value = withTiming(targetOffset, { duration: easeDuration }, () => { @@ -444,6 +460,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr nextIndex = currentIndex.value + 1 targetOffset = -(nextIndex + patchElmNumShared.value) * step.value + preMarginShared.value // 执行动画到下一帧 + runOnJSCallback('handleSwiperChangeStart', nextIndex) offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] @@ -482,11 +499,17 @@ 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 }) const runOnJSCallback = useRunOnJSCallback(runOnJSCallbackRef) @@ -507,6 +530,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr if (targetOffset !== offset.value) { // 内部基于props.current!==currentIndex.value决定是否使用动画及更新currentIndex.value if (propCurrent !== undefined && propCurrent !== currentIndex.value) { + runOnJSCallback('handleSwiperChangeStart', propCurrent) offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] @@ -582,12 +606,13 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr }, [autoplay]) useEffect(() => { - if (circular !== circularShared.value) { + if (circular !== circularShared.value || patchElmNum !== patchElmNumShared.value || displayMultipleItems !== displayMultipleItemsShared.value) { circularShared.value = circular - patchElmNumShared.value = circular ? (preMargin ? 2 : 1) : 0 + patchElmNumShared.value = patchElmNum + displayMultipleItemsShared.value = displayMultipleItems offset.value = getOffset(currentIndex.value, step.value) } - }, [circular, preMargin]) + }, [circular, patchElmNum, displayMultipleItems]) const { gestureHandler } = useMemo(() => { // 基于transdir + 当前offset计算索引 function getTargetPosition (eventData: EventEndType) { @@ -606,21 +631,23 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const moveToIndex = transdir < 0 ? Math.ceil(computedIndex) : Math.floor(computedIndex) // 实际应该定位的索引值 if (!circularShared.value) { - selectedIndex = moveToIndex + const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) + selectedIndex = Math.min(Math.max(moveToIndex, 0), maxIndex) moveToTargetPos = selectedIndex * step.value } else { + const circularIndex = ((moveToIndex - patchElmNumShared.value) % childrenLength.value + childrenLength.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 } } @@ -639,7 +666,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const gestureMovePos = offset.value + translation if (!circularShared.value) { // 如果只判断区间,中间非滑动状态(handleResistanceMove)向左滑动,突然改为向右滑动,但是还在非滑动态,本应该可滑动判断为了不可滑动 - const posEnd = -step.value * (childrenLength.value - 1) + const posEnd = -step.value * (childrenLength.value - displayMultipleItemsShared.value) if (transdir < 0) { return gestureMovePos > posEnd } else { @@ -649,9 +676,16 @@ 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) if (isCriticalItem) { offset.value = withTiming(targetOffset, { duration: easeDuration, @@ -743,7 +777,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr 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 + const maxOffset = translation < 0 ? -(childrenLength.value - displayMultipleItemsShared.value) * step.value : 0 let resistance = 0.1 let overDrag = 0 let finalOffset = 0 @@ -797,7 +831,10 @@ 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) + currentIndex.value = selectedIndex + } } // 2. 非循环: 处理用户一直拖拽到临界点的场景,如果放到onFinalize无法阻止offset.value更新为越界的值 if (!circularShared.value) { 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..574ff31d8f --- /dev/null +++ b/solutions/rn-mpx-swiper-display-multiple-items-changestart.md @@ -0,0 +1,618 @@ +# Mpx2RN mpx-swiper 多项展示与 changestart 支持方案 + +## 背景与问题 + +`mpx-swiper.tsx` 是 Mpx2RN runtime 中对小程序 `swiper` 能力的 RN 实现。当前实现已经支持 `indicator-dots`、`current`、`autoplay`、`circular`、`vertical`、`previous-margin`、`next-margin`、`duration`、`easing-function` 等核心能力,但此前存在两个能力缺口: + +1. `display-multiple-items` 未支持,RN 侧一个 swiper item 始终按容器完整宽度或高度渲染,无法实现一屏展示多个 swiper item。 +2. `bindchangestart` / `changestart` 未支持,业务只能在 `change` 阶段拿到最终索引,无法在切换动画开始时获知目标页。 + +这两个能力都不是单纯新增 prop 即可完成。`display-multiple-items` 会改变 swiper 的基础度量模型、循环补位数量、非循环边界、手势目标索引、阻力区间与 autoplay 停止条件;`changestart` 则需要嵌入到 autoplay、外部 `current` 更新和手势切换三个路径中,且要避免与既有 `change` 事件重复或时序冲突。 + +## 目标 + +1. 在 RN runtime 中支持 `display-multiple-items`,默认值保持为 1。 +2. 在横向和纵向 swiper 下都以 `display-multiple-items` 拆分单个 item 的宽度或高度。 +3. 在非循环模式下,最大可滚动索引从 `childrenLength - 1` 调整为 `childrenLength - displayMultipleItems`。 +4. 在循环模式下,根据多项展示需要动态补足前后 clone,保证边界过渡过程中可见区域不露空。 +5. 新增 `bindchangestart`,在切换动画开始前触发 `changestart`。 +6. 保持原有 `change` 事件语义不变:`currentIndex` 真正更新后再触发。 +7. 新增 prop 不透传到 RN 原生 `View`。 +8. display 相关逻辑尽量贴近 `fix-drn-2.10.18` 分支实现,本轮只保留必要的小范围修正,不额外扩大动态更新与越界兜底范围。 + +## 非目标 + +1. 不支持 `snap-to-edge`,该能力仍标记为未支持。 +2. 不改 `mpx-swiper-item.tsx` 的渲染模型,只继续通过 `SwiperContext.step` 控制 item 尺寸。 +3. 不改分页点数量与交互语义,分页仍按真实 children 数量渲染。 +4. 不改 `change` 的事件 `source` 字段,目前仍沿用 `{ source: 'touch' }` 的既有行为。 +5. 不引入新的手势库或重写 gesture 状态机。 + +## 当前相关实现概览 + +### 文件范围 + +主要改动文件: + +```text +packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx +``` + +关联但未改动的文件: + +```text +packages/webpack-plugin/lib/runtime/components/react/mpx-swiper-item.tsx +``` + +`mpx-swiper-item.tsx` 中 item 的宽高由 `SwiperContext.step` 决定: + +1. 横向时 item 外层宽度为 `step.value`,高度为 `100%`。 +2. 纵向时 item 外层高度为 `step.value`,宽度为 `100%`。 + +因此本方案在 swiper wrapper 中重算 `step`,就能影响每个 swiper item 的可见尺寸。 + +## 方案一:新增 prop 与事件类型 + +### 改动内容 + +在 `SwiperProps` 中新增: + +```ts +'display-multiple-items'?: number +bindchangestart?: (event: NativeSyntheticEvent | unknown) => void +``` + +组件内部新增: + +```ts +const propDisplayMultipleItems = Number(props['display-multiple-items']) +const displayMultipleItems = Number.isFinite(propDisplayMultipleItems) + ? Math.max(1, Math.floor(propDisplayMultipleItems)) + : 1 +``` + +### 为什么这么改 + +1. `display-multiple-items` 是小程序 swiper 的标准属性,RN runtime 需要在 wrapper 层感知它,因为 item 尺寸、边界和循环补位都依赖该值。 +2. 用 `Number.isFinite`、`Math.floor` 和 `Math.max(1, ...)` 做保护,避免业务传入空值、非数字、0、负数或小数时出现除 0、反向边界、clone 数量与 offset 计算不一致等问题。 +3. `bindchangestart` 是业务事件回调,必须进入 props 类型,否则 TS 层无法表达该能力。 + +### prop 透传处理 + +`useInnerProps` 的 remove list 中新增: + +```ts +'display-multiple-items', +'bindchangestart' +``` + +原因是这两个字段是 Mpx runtime 层消费的逻辑 prop,不应该透传到 RN 原生 `View` 上,否则可能产生无效 native prop 或警告。 + +## 方案二:基于 displayMultipleItems 重算 step + +### 原模型 + +原实现中: + +```ts +step = wrapperWidthOrHeight +``` + +这意味着一个 swiper item 占满整个 swiper 容器。 + +### 新模型 + +新实现中: + +```ts +step = wrapperWidthOrHeight / displayMultipleItems +``` + +横向时: + +```ts +step = (width - previousMargin - nextMargin) / displayMultipleItems +``` + +纵向时: + +```ts +step = (height - previousMargin - nextMargin) / displayMultipleItems +``` + +### 为什么这么改 + +`mpx-swiper-item` 已经通过 context 读取 `step` 并用它设置自身外层宽高。将 `step` 降为容器可用尺寸的 `1 / displayMultipleItems`,可以复用现有 item 渲染机制,不需要改 swiper item 本身。 + +这样带来的好处: + +1. 改动集中在 swiper wrapper,侵入性小。 +2. 横向、纵向逻辑可以共用 `step`。 +3. 现有 scale、offset、gesture 计算仍围绕 `step` 运行,模型连续。 + +### 动态 display 更新 + +本轮不新增 `wrapperSizeRef` 缓存尺寸,也不额外处理 `display-multiple-items` 动态变化时的 step 反推。保持与 `fix-drn-2.10.18` 接近:主要依赖 `onWrapperLayout` 与既有 effect 同步 shared value。 + +这样做的原因是控制本次迁移范围,避免为了动态配置场景引入更多状态同步逻辑。动态切换 `display-multiple-items` 的完整表现可以作为后续增强单独评估。 + +## 方案三:非循环 current 边界保持 fix-drn 口径 + +### 问题 + +支持多项展示后,非循环模式下最后一个合法起点不再是最后一个 item。例如 `childrenLength = 5`、`displayMultipleItems = 3` 时,最后一个合法起点是 2,对应可见 `[2, 3, 4]`。 + +### 当前处理 + +本轮不新增 `normalizeCurrent` / `getMaxCurrent` 全链路裁剪 helper,仅保留 `fix-drn-2.10.18` 已有的核心处理: + +1. 手势 `getTargetPosition` 中将非循环目标索引限制在 `[0, childrenLength - displayMultipleItems]`。 +2. autoplay 的停止位置使用 `childrenLength - displayMultipleItems`。 +3. `canMove` / `handleResistanceMove` 的末端边界使用 `childrenLength - displayMultipleItems`。 + +外部传入越界 `current`、children 动态减少、`display-multiple-items` 动态变化导致的 current 重新裁剪,本轮暂不额外扩展。 + +## 方案四:循环模式动态补位 + +### 原补位逻辑 + +原 circular 模式补位规则是: + +1. 无 `previous-margin` 时,前后各补 1 个。 +2. 有 `previous-margin` 时,前后各补 2 个。 + +这个规则只适用于单 item 宽度接近容器宽度的场景。多 item 展示时,如果仍只补 1 或 2 个,在循环边界过渡时可能露出空白。 + +### 新补位数量 + +```ts +const hasEdgeMargin = !!preMargin || !!nextMargin +const patchElmNum = (circular && children.length > 1) + ? displayMultipleItems + (hasEdgeMargin ? 1 : 0) + : 0 +``` + +### 为什么要考虑 previous-margin / next-margin + +`patchElmNum` 需要同时兼顾多项展示和边缘露出: + +1. 无边缘 margin 时,可见区域宽度正好由 `displayMultipleItems` 个 item 填满,循环边界至少需要补 `displayMultipleItems` 个 clone。 +2. 有 `previous-margin` 或 `next-margin` 时,可见区域会额外露出边缘内容。此时滑动过渡中除了当前可见的 `displayMultipleItems` 个 item,还需要额外 1 个 item 承接边缘露出,所以补 `displayMultipleItems + 1` 个 clone。 +3. 这里使用 `preMargin || nextMargin`,而不是只看 `previous-margin`。原因是 `previous-margin` 影响左侧露出和初始 offset,`next-margin` 影响右侧露出;二者任意存在,都可能让循环边界需要多一个 clone 来覆盖边缘可视区域。 + +关键组合如下: + +```text +displayMultipleItems = 1, no margin -> patchElmNum = 1 +displayMultipleItems = 1, has margin -> patchElmNum = 2 +displayMultipleItems = N, no margin -> patchElmNum = N +displayMultipleItems = N, has margin -> patchElmNum = N + 1 +``` + +这样可以保留原默认行为:`display-multiple-items` 缺省为 1 且无 `previous-margin` / `next-margin` 时,仍然是前后各补 1 个,而不是无条件补 2 个。 + +### 前置 clone 生成 + +前置 clone 从真实 children 尾部向前取: + +```ts +const startIndex = intLen - (patchElmNum % intLen) +for (let i = 0; i < patchElmNum; i++) { + const sourceIndex = (startIndex + i) % intLen +} +``` + +例如: + +```text +childrenLength = 5 +displayMultipleItems = 2 +hasEdgeMargin = false +patchElmNum = 2 +front clones = [3, 4] +back clones = [0, 1] +``` + +如果存在 `previous-margin` 或 `next-margin`: + +```text +childrenLength = 5 +displayMultipleItems = 2 +hasEdgeMargin = true +patchElmNum = 3 +front clones = [2, 3, 4] +back clones = [0, 1, 2] +``` + +### 循环索引映射 + +在手势目标计算中,补位区的 `moveToIndex` 统一映射为真实索引: + +```ts +const circularIndex = ( + (moveToIndex - patchElmNumShared.value) % childrenLength.value + + childrenLength.value +) % childrenLength.value +``` + +### 为什么这么改 + +之前前置补位区只有两种映射: + +1. `moveToIndex === 0` 映射到 `childrenLength - patchElmNum` +2. 其他都映射到 `childrenLength - 1` + +当 `patchElmNum > 2` 时,这个映射会错误。例如: + +```text +childrenLength = 5 +displayMultipleItems = 2 +hasEdgeMargin = true +patchElmNum = 3 +front clones = [2, 3, 4] +旧映射 = [2, 4, 4] +新映射 = [2, 3, 4] +``` + +使用 modulo 后,前置补位、真实区、后置补位都能通过同一公式映射到真实索引。 + +## 方案五:非循环边界调整 + +### autoplay 边界 + +非循环 autoplay 原来在: + +```ts +currentIndex.value === childrenLength.value - 1 +``` + +时停止。现在改为: + +```ts +if (currentIndex.value === childrenLength.value - displayMultipleItemsShared.value) { + pauseLoop() +} +``` + +这与 `fix-drn-2.10.18` 的实现保持一致。 + +### 为什么不额外使用 >= + +`>=` 可以覆盖异常越界 current,但它属于额外防御逻辑。为了让本次迁移尽量贴近 `fix-drn`,暂时不扩大 autoplay 停止条件,只保留多项展示所需的停止索引调整。 + +### 手势边界 + +非循环模式下: + +1. `getTargetPosition` 将目标索引裁剪到 `[0, childrenLength - displayMultipleItems]`。 +2. `canMove` 的末端边界改为 `-step * maxIndex`。 +3. `handleResistanceMove` 的末端阻力边界同样改为 `-step * maxIndex`。 + +### 为什么这么改 + +多项展示时,滚动坐标含义仍然是“以第几个 item 作为视口起点”。非循环最后可滚动坐标应该让最后一屏刚好展示到最后一个真实 item,而不是以最后一个 item 作为起点。 + +## 方案六:changestart 事件 + +### 新增事件处理函数 + +```ts +function handleSwiperChangeStart (current: number) { + const eventData = getCustomEvent('changestart', {}, { + detail: { current }, + layoutRef + }) + bindchangestart && bindchangestart(eventData) +} +``` + +并注册到 `runOnJSCallbackRef`,让 worklet 中可以触发。 + +### 触发点 + +1. autoplay 确定 `nextIndex` 后、动画开始前触发。 +2. 外部 `current` 触发动画前触发。 +3. 手势 `handleEnd` 确定目标索引后、`withTiming` 动画开始前触发。 +4. 手势 onUpdate 过程中跨过半屏并更新 `currentIndex` 前触发。 + +### 与 change 的关系 + +`change` 仍由: + +```ts +useAnimatedReaction(() => currentIndex.value, ...) +``` + +触发。也就是说: + +```text +changestart: 目标 current 已确定,切换动画即将开始 +change: currentIndex 已更新,切换结果生效 +``` + +### 为什么这么改 + +这符合小程序侧常见的事件语义:`changestart` 代表切换开始,`change` 代表切换完成或索引变更生效。把 `changestart` 放在 `withTiming` 之前,可以让业务在动画开始时提前响应。 + +## 方案七:动态变化处理边界 + +### margin 动态变化 + +`previous-margin` / `next-margin` 变化后,当前保持接近 `fix-drn` 的处理方式: + +1. 计算 margin delta。 +2. 更新 `preMarginShared` / `nextMarginShared`。 +3. 用 `step.value - patchStep` 更新 step。 +4. 根据当前 index 重新计算 offset。 + +这里没有额外按 `displayMultipleItems` 拆分 margin delta,也没有缓存 wrapper 尺寸。该部分是后续可评估的增强点。 + +### display-multiple-items 动态变化 + +当 `displayMultipleItems` 变化: + +1. 更新 `displayMultipleItemsShared.value`。 +2. 根据当前 step 重新计算 offset。 + +本轮不通过缓存尺寸反推新 step,也不额外裁剪 current。这样可以减少逻辑迁移范围,保持与 `fix-drn` 更一致。 + +### circular 动态变化 + +当 `circular` 变化: + +1. 更新 `circularShared`。 +2. 更新 `patchElmNumShared`。 +3. 按新的 circular 语义重算 offset。 + +### children 动态变化 + +当 children 数量变化: + +1. 更新 `childrenLength.value`。 +2. 如果 `children.length - 1 < currentIndex.value`,将 current 重置为 0。 +3. 必要时暂停 autoplay 并重新启动。 + +## 关键数据流 + +### 横向非循环,多项展示 + +```text +wrapper layout width + -> realWidth = width - previousMargin - nextMargin + -> step = realWidth / displayMultipleItems + -> swiper-item width = step + -> maxCurrent = childrenLength - displayMultipleItems + -> offset = -current * step +``` + +### 横向循环,多项展示 + +```text +displayMultipleItems + -> hasEdgeMargin = !!previousMargin || !!nextMargin + -> patchElmNum = displayMultipleItems + (hasEdgeMargin ? 1 : 0) + -> render front clones + real children + back clones + -> initial offset = -(current + patchElmNum) * step + previousMargin + -> crossing boundary + -> animate to clone position + -> reset to matching real position +``` + +### changestart 时序 + +```text +target current calculated + -> changestart(current) + -> withTiming animation + -> currentIndex.value = current + -> change(current) +``` + +## 风险点与应对 + +### 风险一:循环补位数量与索引映射复杂 + +风险: + +1. `displayMultipleItems + (hasEdgeMargin ? 1 : 0)` 会让前后 clone 数量随配置变大。 +2. 当 `displayMultipleItems >= childrenLength` 时,clone 中会重复使用真实节点。 +3. 循环边界 reset 的坐标和真实索引映射需要完全一致,否则会出现跳错页或闪动。 + +当前应对: + +1. clone 生成使用 modulo,允许补位数量大于 children 数量。 +2. 手势目标索引使用统一 modulo 映射。 +3. 已用示例校验 `childrenLength=5, displayMultipleItems=2` 的前置 clone 映射从 `[2,4,4]` 修正为 `[2,3,4]`。 + +后续建议: + +1. 增加真实 RN 示例验证 `childrenLength < displayMultipleItems`、`childrenLength === displayMultipleItems`、`childrenLength > displayMultipleItems` 三类场景。 +2. 特别验证 circular + previous-margin + displayMultipleItems 的组合。 + +### 风险二:动态 display-multiple-items 依赖缓存尺寸 + +风险: + +RN 的 `onLayout` 只在布局变化时触发。如果只改 `display-multiple-items`,容器尺寸可能不变,因此不会自然重算 step。 + +当前取舍: + +1. 本轮不引入 wrapper 尺寸缓存。 +2. `displayMultipleItemsShared` 会更新,offset 会按当前 step 重新计算。 +3. 若业务运行时动态修改 `display-multiple-items`,可能需要等待布局回调或后续增强才能完全修正 item 尺寸。 + +剩余风险: + +1. 运行时动态切换 display 数量可能出现短时间或持续的 item 尺寸不符合预期。 +2. 如果业务明确依赖动态切换,需要单独补充缓存尺寸和 step 重算逻辑。 + +### 风险三:margin 动态变化与布局回调可能重复 + +风险: + +当前 margin effect 沿用接近 `fix-drn` 的 `step.value - patchStep` 处理。多项展示时,margin delta 理论上应按 `displayMultipleItems` 分摊到单个 item 的 step 上。 + +当前取舍: + +1. 本轮不引入 `wrapperSizeRef`,避免扩大状态同步逻辑。 +2. 保持与 `fix-drn` 行为接近,降低迁移风险。 + +剩余风险: + +1. 在 `displayMultipleItems > 1` 且动态修改 margin 时,step 变化量可能偏大。 +2. 如果业务不动态修改 margin,则主要影响较小。 + +### 风险四:外部 current 越界仍未统一裁剪 + +风险: + +外部传入越界 current 时,当前方案不会新增统一裁剪。例如非循环 `childrenLength=5, displayMultipleItems=3, current=4` 时,理论合法起点应为 2,但本轮不额外改这条路径。 + +当前取舍: + +1. 贴近 `fix-drn`,避免扩展 current 语义。 +2. 本轮聚焦基础 display 展示、补位数量、循环索引映射。 + +需要 review 的点: + +1. 是否接受外部越界 current 仍可能进入非法 offset。 +2. 是否需要在后续单独补充 `normalizeCurrent` 方案。 + +### 风险五:changestart 可能在某些路径提前于 change 多次触发 + +风险: + +手势 onUpdate 跨过半屏时会触发一次 `changestart` 并更新 `currentIndex`;onFinalize 阶段如果目标索引再次变化,也可能触发另一次。 + +当前应对: + +1. `triggerChangeStart` 会判断 `current !== currentIndex.value`。 +2. 如果 onUpdate 已经把 `currentIndex` 更新为目标值,onFinalize 同目标不会再次触发。 + +剩余风险: + +1. 快速往返拖拽时目标索引来回变化,会按变化次数触发多次 `changestart`。 +2. 这与“目标切换开始”语义基本一致,但业务如果假设一次手势最多一个 changestart,需要额外说明。 + +### 风险六:change 事件 source 仍固定为 touch + +风险: + +当前既有 `change` 事件 detail 里 `source` 固定为 `'touch'`。本次新增 autoplay 与外部 current 的 `changestart` 后,事件 source 精度没有同步提升。 + +为什么本次不改: + +1. 这是已有行为,贸然修改可能影响业务判断。 +2. 本次目标聚焦 `display-multiple-items` 与 `changestart`。 + +后续建议: + +如需完整对齐小程序,可单独评估 `source` 在 autoplay、touch、外部 current 更新中的语义。 + +### 风险七:display-multiple-items 输入归一化 + +风险: + +当前入口会将 `display-multiple-items` 归一化为正整数: + +```ts +const propDisplayMultipleItems = Number(props['display-multiple-items']) +const displayMultipleItems = Number.isFinite(propDisplayMultipleItems) + ? Math.max(1, Math.floor(propDisplayMultipleItems)) + : 1 +``` + +这可以避免 0、负数、小数、非数字值导致 step、clone 数量和 offset 计算不一致。 + +潜在影响: + +1. 如果历史业务误传小数,当前会向下取整,而不是按小数宽度展示。 +2. 如果历史业务误传非法值,当前会回退为 1,表现接近小程序默认值。 + +建议: + +方案 review 时确认这种正整数归一化是否符合业务兼容预期;如果需要更严格的告警,可后续补充开发态 warning。 + +### 风险八:测试覆盖不足 + +风险: + +当前完成了单文件 lint、diff check 和若干索引推导校验,但缺少真实 RN 运行时交互验证。 + +建议至少覆盖: + +1. 横向非循环:`display-multiple-items=2/3`。 +2. 纵向非循环:`display-multiple-items=2/3`。 +3. 横向循环:`childrenLength=2/3/5` 与 `display-multiple-items=2/3`。 +4. `previous-margin` / `next-margin` 与多项展示组合。 +5. autoplay 非循环到尾部停止。 +6. autoplay circular 从最后一项回到第一项。 +7. 外部动态更新 current 到合法值与越界值。 +8. 动态切换 `display-multiple-items`。 +9. 动态增删 swiper item。 +10. 快速拖拽、反向拖拽、边界阻力拖拽。 +11. `bindchangestart` 与 `bindchange` 触发顺序。 + +## 验证结果 + +已执行: + +```bash +node_modules/.bin/eslint packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx +git diff --check +``` + +结果: + +1. 单文件 ESLint 通过。 +2. `git diff --check` 通过。 + +已尝试: + +```bash +node_modules/.bin/tsc -p packages/webpack-plugin/lib/runtime/components/react/tsconfig.json --noEmit +``` + +结果: + +1. 当前失败原因是既有 `@mpxjs/perf` 模块类型缺失。 +2. 报错文件为 `mpx-simple-text.tsx`、`mpx-simple-view.tsx`、`mpx-text.tsx`、`mpx-view.tsx`。 +3. 未发现 `mpx-swiper.tsx` 新增 TS 错误。 + +额外推导校验: + +```text +childrenLength = 5 +displayMultipleItems = 2 +hasEdgeMargin = false +patchElmNum = 2 +front clone render indexes = [3, 4] +front clone mapped indexes = [3, 4] + +childrenLength = 5 +displayMultipleItems = 2 +hasEdgeMargin = true +patchElmNum = 3 +front clone render indexes = [2, 3, 4] +front clone mapped indexes = [2, 3, 4] +``` + +## Review 清单 + +代码 review 建议重点看: + +1. `displayMultipleItems` 正整数归一化是否符合业务兼容预期。 +2. `patchElmNum = displayMultipleItems + (hasEdgeMargin ? 1 : 0)` 是否满足所有循环边界场景。 +3. 外部越界 current 暂不统一裁剪是否符合本次“贴近 fix-drn”的取舍。 +4. `changestart` 是否需要补充 `source` 字段。 +5. 快速拖拽时多次 `changestart` 是否符合预期。 +6. margin 动态变化暂不按 `displayMultipleItems` 分摊 delta 是否可接受。 +7. `childrenLength <= displayMultipleItems` 时非循环和循环的行为是否需要额外限制。 +8. 是否需要在文档或测试用例中明确 `display-multiple-items` 动态变化的支持范围。 + +## 后续建议 + +1. 增加一个 RN 示例页面,专门覆盖 `display-multiple-items`、`circular`、`previous-margin`、`next-margin`、`autoplay`、`changestart` 组合。 +2. 在真机或模拟器上录制边界滑动行为,重点看循环 reset 是否闪动。 +3. 若项目测试体系允许,补充纯函数层面的索引映射单测,把 clone 生成与 `moveToIndex -> selectedIndex` 的关系固定下来。 +4. 若后续要完整对齐小程序事件语义,单独评估 `change/changestart` 的 `source` 字段。 From 882ac459d370e0c3a41e533baaa391dbd4cea863 Mon Sep 17 00:00:00 2001 From: xuegan Date: Thu, 23 Jul 2026 19:44:44 +0800 Subject: [PATCH 2/4] feat: save swiper display multi items feat --- .../references/rn-template-reference.md | 1 + docs-vitepress/guide/rn/component.md | 1 + .../template/wx/component-config/swiper.js | 2 +- .../runtime/components/react/mpx-swiper.tsx | 21 +++--- .../test/platform/wx/template/swiper.spec.js | 34 +++++++++ ...iper-display-multiple-items-changestart.md | 71 +++++++++++-------- 6 files changed, 91 insertions(+), 39 deletions(-) create mode 100644 packages/webpack-plugin/test/platform/wx/template/swiper.spec.js diff --git a/.agents/skills/mpx2rn/references/rn-template-reference.md b/.agents/skills/mpx2rn/references/rn-template-reference.md index cb55dab29f..432bf35034 100644 --- a/.agents/skills/mpx2rn/references/rn-template-reference.md +++ b/.agents/skills/mpx2rn/references/rn-template-reference.md @@ -812,6 +812,7 @@ Mpx 输出 RN 内置支持了大部分常用的基础组件,详情见下方文 | vertical | boolean | `false` | 滑动方向是否为纵向 | | previous-margin | string | `0` | 前边距,可用于露出前一项的一小部分,接受 px | | next-margin | string | `0` | 后边距,可用于露出后一项的一小部分,接受 px | +| display-multiple-items | number | `1` | 同时显示的滑块数量 | | scale | boolean | `false` | 滑动时是否开启前后元素缩小,默认是缩放 0.7 倍, 暂不支持自定义 | | easing-function | string | `linear` | 支持 linear、easeInCubic、easeOutCubic、easeInOutCubic | | simultaneous-handlers | array\ | `[]` | RN 环境特有属性,主要用于组件嵌套场景,允许多个手势同时识别和处理并触发,这个属性可以指定一个或多个手势处理器,处理器支持使用 this.$refs.xxx 获取组件实例来作为数组参数传递给 swiper 组件 | diff --git a/docs-vitepress/guide/rn/component.md b/docs-vitepress/guide/rn/component.md index 663c4223c6..eaae2ad482 100644 --- a/docs-vitepress/guide/rn/component.md +++ b/docs-vitepress/guide/rn/component.md @@ -168,6 +168,7 @@ | vertical | boolean | `false` | 滑动方向是否为纵向 | | previous-margin | string | `0` | 前边距,可用于露出前一项的一小部分,接受px | | next-margin | string | `0` | 后边距,可用于露出后一项的一小部分,接受px | +| display-multiple-items | number | `1` | 同时显示的滑块数量 | | scale | boolean | `false` | 滑动时是否开启前后元素缩小,默认是缩放0.7倍, 暂不支持自定义 | | easing-function | string | `linear` | 支持 linear、easeInCubic、easeOutCubic、easeInOutCubic| | simultaneous-handlers | array\| `[]` | RN环境特有属性,主要用于组件嵌套场景,允许多个手势同时识别和处理并触发,这个属性可以指定一个或多个手势处理器,处理器支持使用 this.$refs.xxx 获取组件实例来作为数组参数传递给 swiper 组件| 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.tsx b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx index 96f05376cb..95a592535f 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx @@ -172,10 +172,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr marginBottom: dotSpacing, zIndex: 98 } - const propDisplayMultipleItems = Number(props['display-multiple-items']) - const displayMultipleItems = Number.isFinite(propDisplayMultipleItems) - ? Math.max(1, Math.floor(propDisplayMultipleItems)) - : 1 + const displayMultipleItems = Number(props['display-multiple-items']) || 1 const easeingFunc = props['easing-function'] || 'default' const easeDuration = props.duration || 500 const horizontal = props.vertical !== undefined ? !props.vertical : true @@ -422,7 +419,8 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr let nextIndex = currentIndex.value if (!circularShared.value) { // 获取下一个位置的坐标, 循环到最后一个元素,直接停止, 取消定时器 - if (currentIndex.value === childrenLength.value - displayMultipleItemsShared.value) { + const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) + if (currentIndex.value >= maxIndex) { pauseLoop() return } @@ -629,10 +627,13 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr // 实际应该定位的索引值 if (!circularShared.value) { const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) - selectedIndex = Math.min(Math.max(moveToIndex, 0), maxIndex) + selectedIndex = Math.min(moveToIndex, maxIndex) moveToTargetPos = selectedIndex * step.value } else { - const circularIndex = ((moveToIndex - patchElmNumShared.value) % childrenLength.value + childrenLength.value) % childrenLength.value + let circularIndex = (moveToIndex - patchElmNumShared.value) % childrenLength.value + if (circularIndex < 0) { + circularIndex += childrenLength.value + } if (moveToIndex >= childrenLength.value + patchElmNumShared.value) { selectedIndex = circularIndex resetOffsetPos = (selectedIndex + patchElmNumShared.value) * step.value - preMarginShared.value @@ -663,7 +664,8 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const gestureMovePos = offset.value + translation if (!circularShared.value) { // 如果只判断区间,中间非滑动状态(handleResistanceMove)向左滑动,突然改为向右滑动,但是还在非滑动态,本应该可滑动判断为了不可滑动 - const posEnd = -step.value * (childrenLength.value - displayMultipleItemsShared.value) + const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) + const posEnd = -step.value * maxIndex if (transdir < 0) { return gestureMovePos > posEnd } else { @@ -774,7 +776,8 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const { translation, transdir } = eventData const moveToOffset = offset.value + translation const maxOverDrag = Math.floor(step.value / 2) - const maxOffset = translation < 0 ? -(childrenLength.value - displayMultipleItemsShared.value) * step.value : 0 + const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) + const maxOffset = translation < 0 ? -maxIndex * step.value : 0 let resistance = 0.1 let overDrag = 0 let finalOffset = 0 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..3a0b588d98 --- /dev/null +++ b/packages/webpack-plugin/test/platform/wx/template/swiper.spec.js @@ -0,0 +1,34 @@ +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 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/solutions/rn-mpx-swiper-display-multiple-items-changestart.md b/solutions/rn-mpx-swiper-display-multiple-items-changestart.md index 574ff31d8f..6875ab006d 100644 --- a/solutions/rn-mpx-swiper-display-multiple-items-changestart.md +++ b/solutions/rn-mpx-swiper-display-multiple-items-changestart.md @@ -65,16 +65,13 @@ bindchangestart?: (event: NativeSyntheticEvent | unknown) => void 组件内部新增: ```ts -const propDisplayMultipleItems = Number(props['display-multiple-items']) -const displayMultipleItems = Number.isFinite(propDisplayMultipleItems) - ? Math.max(1, Math.floor(propDisplayMultipleItems)) - : 1 +const displayMultipleItems = Number(props['display-multiple-items']) || 1 ``` ### 为什么这么改 1. `display-multiple-items` 是小程序 swiper 的标准属性,RN runtime 需要在 wrapper 层感知它,因为 item 尺寸、边界和循环补位都依赖该值。 -2. 用 `Number.isFinite`、`Math.floor` 和 `Math.max(1, ...)` 做保护,避免业务传入空值、非数字、0、负数或小数时出现除 0、反向边界、clone 数量与 offset 计算不一致等问题。 +2. RN 模板编译后静态属性可能以字符串形式传入,使用 `Number()` 做必要的数值转换,避免补位数量计算中的 `+` 发生字符串拼接;未传值、传入 `0` 或无法转换为数字时通过 `|| 1` 使用默认值,不额外增加 `Number.isFinite`、取整或正数裁剪。 3. `bindchangestart` 是业务事件回调,必须进入 props 类型,否则 TS 层无法表达该能力。 ### prop 透传处理 @@ -88,6 +85,21 @@ const displayMultipleItems = Number.isFinite(propDisplayMultipleItems) 原因是这两个字段是 Mpx runtime 层消费的逻辑 prop,不应该透传到 RN 原生 `View` 上,否则可能产生无效 native prop 或警告。 +### RN 编译能力声明 + +原模板组件配置会在 iOS、Android 和 Harmony 编译时提示 `display-multiple-items` 不受支持。runtime 完成支持后,需要将该属性从 RN unsupported 规则中移除,避免编译器继续输出与实际能力不一致的 warning: + +```js +{ + test: /^(snap-to-edge|easing-function)$/, + ios: iosPropLog, + android: androidPropLog, + harmony: harmonyPropLog +} +``` + +这里只移除 `display-multiple-items`,`snap-to-edge` 和 `easing-function` 等尚未支持的属性继续保持原 warning。模板编译测试同时覆盖“`display-multiple-items` 不告警”和“`snap-to-edge` 仍告警”,防止误放开整条规则。 + ## 方案二:基于 displayMultipleItems 重算 step ### 原模型 @@ -229,10 +241,10 @@ back clones = [0, 1, 2] 在手势目标计算中,补位区的 `moveToIndex` 统一映射为真实索引: ```ts -const circularIndex = ( - (moveToIndex - patchElmNumShared.value) % childrenLength.value + - childrenLength.value -) % childrenLength.value +let circularIndex = (moveToIndex - patchElmNumShared.value) % childrenLength.value +if (circularIndex < 0) { + circularIndex += childrenLength.value +} ``` ### 为什么这么改 @@ -254,7 +266,12 @@ front clones = [2, 3, 4] 新映射 = [2, 3, 4] ``` -使用 modulo 后,前置补位、真实区、后置补位都能通过同一公式映射到真实索引。 +这里分为两步处理: + +1. `moveToIndex - patchElmNumShared.value` 先移除前置 clone 带来的索引偏移,再通过 `% childrenLength.value` 将索引限制在一个 children 周期内。 +2. JavaScript 对负数取余仍会得到负数,前置 clone 可能产生负余数,因此在结果小于 0 时加上 `childrenLength.value`,将其修正为有效的真实索引。 + +这样前置补位、真实区、后置补位都能使用同一套映射逻辑。分步写法与双重取模结果一致,但更容易理解每一步的目的。 ## 方案五:非循环边界调整 @@ -269,22 +286,21 @@ currentIndex.value === childrenLength.value - 1 时停止。现在改为: ```ts -if (currentIndex.value === childrenLength.value - displayMultipleItemsShared.value) { +const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) +if (currentIndex.value >= maxIndex) { pauseLoop() } ``` -这与 `fix-drn-2.10.18` 的实现保持一致。 +### 为什么使用非负 maxIndex 和 >= -### 为什么不额外使用 >= - -`>=` 可以覆盖异常越界 current,但它属于额外防御逻辑。为了让本次迁移尽量贴近 `fix-drn`,暂时不扩大 autoplay 停止条件,只保留多项展示所需的停止索引调整。 +非循环模式下,最后一个合法起始索引不能小于 0。当 `displayMultipleItems > childrenLength` 时,`childrenLength - displayMultipleItems` 会得到负数,但实际只能停在索引 0。使用 `Math.max(0, ...)` 可以让 autoplay、手势定位和阻力边界保持一致。使用 `>=` 则可以在 children 动态减少、当前索引已经超过新边界时及时停止 autoplay。 ### 手势边界 非循环模式下: -1. `getTargetPosition` 将目标索引裁剪到 `[0, childrenLength - displayMultipleItems]`。 +1. `getTargetPosition` 将目标索引上限裁剪到 `maxIndex`。 2. `canMove` 的末端边界改为 `-step * maxIndex`。 3. `handleResistanceMove` 的末端阻力边界同样改为 `-step * maxIndex`。 @@ -292,6 +308,8 @@ if (currentIndex.value === childrenLength.value - displayMultipleItemsShared.val 多项展示时,滚动坐标含义仍然是“以第几个 item 作为视口起点”。非循环最后可滚动坐标应该让最后一屏刚好展示到最后一个真实 item,而不是以最后一个 item 作为起点。 +当 `displayMultipleItems > childrenLength` 时,非循环模式不会生成 clone,`maxIndex` 固定为 0;循环模式则继续通过重复 clone 填充可视区域,不受这组非循环边界调整影响。 + ## 方案六:changestart 事件 ### 新增事件处理函数 @@ -509,29 +527,24 @@ RN 的 `onLayout` 只在布局变化时触发。如果只改 `display-multiple-i 如需完整对齐小程序,可单独评估 `source` 在 autoplay、touch、外部 current 更新中的语义。 -### 风险七:display-multiple-items 输入归一化 +### 风险七:display-multiple-items 非法输入 风险: -当前入口会将 `display-multiple-items` 归一化为正整数: +当前入口只做必要的数值转换和默认值处理,不额外校验或归一化 `display-multiple-items`: ```ts -const propDisplayMultipleItems = Number(props['display-multiple-items']) -const displayMultipleItems = Number.isFinite(propDisplayMultipleItems) - ? Math.max(1, Math.floor(propDisplayMultipleItems)) - : 1 +const displayMultipleItems = Number(props['display-multiple-items']) || 1 ``` -这可以避免 0、负数、小数、非数字值导致 step、clone 数量和 offset 计算不一致。 - 潜在影响: -1. 如果历史业务误传小数,当前会向下取整,而不是按小数宽度展示。 -2. 如果历史业务误传非法值,当前会回退为 1,表现接近小程序默认值。 +1. `undefined`、`0`、空字符串、非法字符串和 `NaN` 经转换后会回退为 `1`。 +2. 负数、小数和 `Infinity` 等转换后的 truthy 数值会直接参与 step、clone 数量和边界计算,调用方需要遵循该属性应为正整数的约定。 建议: -方案 review 时确认这种正整数归一化是否符合业务兼容预期;如果需要更严格的告警,可后续补充开发态 warning。 +保留 `Number()` 解决 RN 模板输入的运行时类型问题,但不在本次实现中增加 `Number.isFinite`、`Math.floor` 或 `Math.max` 等额外容错;如后续确认需要统一校验,应作为组件属性校验策略单独评估。 ### 风险八:测试覆盖不足 @@ -601,13 +614,13 @@ front clone mapped indexes = [2, 3, 4] 代码 review 建议重点看: -1. `displayMultipleItems` 正整数归一化是否符合业务兼容预期。 +1. `displayMultipleItems` 使用 `Number(props['display-multiple-items']) || 1` 完成类型转换和默认值处理是否符合业务兼容预期。 2. `patchElmNum = displayMultipleItems + (hasEdgeMargin ? 1 : 0)` 是否满足所有循环边界场景。 3. 外部越界 current 暂不统一裁剪是否符合本次“贴近 fix-drn”的取舍。 4. `changestart` 是否需要补充 `source` 字段。 5. 快速拖拽时多次 `changestart` 是否符合预期。 6. margin 动态变化暂不按 `displayMultipleItems` 分摊 delta 是否可接受。 -7. `childrenLength <= displayMultipleItems` 时非循环和循环的行为是否需要额外限制。 +7. `childrenLength <= displayMultipleItems` 时循环模式重复 clone 的展示行为是否符合预期。 8. 是否需要在文档或测试用例中明确 `display-multiple-items` 动态变化的支持范围。 ## 后续建议 From 4d0bc0a50b5a440fa518e842c4b3f903111a597a Mon Sep 17 00:00:00 2001 From: xuegan Date: Wed, 29 Jul 2026 22:29:54 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=20swiper=20?= =?UTF-8?q?=E5=A4=9A=E9=A1=B9=E5=B1=95=E7=A4=BA=E8=BE=B9=E7=95=8C=E4=B8=8E?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../references/rn-template-reference.md | 1 + docs-vitepress/guide/rn/component.md | 1 + .../runtime/components/react/mpx-swiper.tsx | 4 +- ...iper-display-multiple-items-changestart.md | 652 +++--------------- 4 files changed, 102 insertions(+), 556 deletions(-) diff --git a/.agents/skills/mpx2rn/references/rn-template-reference.md b/.agents/skills/mpx2rn/references/rn-template-reference.md index 432bf35034..8e43f6749a 100644 --- a/.agents/skills/mpx2rn/references/rn-template-reference.md +++ b/.agents/skills/mpx2rn/references/rn-template-reference.md @@ -824,6 +824,7 @@ Mpx 输出 RN 内置支持了大部分常用的基础组件,详情见下方文 | 事件名 | 说明 | | --- | --- | | bindchange | current 改变时会触发 change 事件,`event.detail = {current, source}` | +| bindchangestart | swiper 切换开始时会触发 changestart 事件,`event.detail = {current}` | ### swiper-item diff --git a/docs-vitepress/guide/rn/component.md b/docs-vitepress/guide/rn/component.md index eaae2ad482..98a1e2119c 100644 --- a/docs-vitepress/guide/rn/component.md +++ b/docs-vitepress/guide/rn/component.md @@ -183,6 +183,7 @@ | 事件名 | 说明 | | ----------------| ------------------ | | bindchange| current 改变时会触发 change 事件,`event.detail = {current, source}`| +| bindchangestart| swiper 切换开始时会触发 changestart 事件,`event.detail = {current}`| ### swiper-item 仅可放置在swiper组件中,宽高自动设置为100%。 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 95a592535f..b269a32674 100644 --- a/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx +++ b/packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx @@ -572,7 +572,8 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr useEffect(() => { childrenLength.value = children.length - if (children.length - 1 < currentIndex.value) { + const maxIndex = circular ? children.length - 1 : Math.max(0, children.length - displayMultipleItems) + if (!children.length || maxIndex < currentIndex.value) { pauseLoop() currentIndex.value = 0 offset.value = getOffset(0, step.value) @@ -719,6 +720,7 @@ 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) + triggerChangeStart(moveToIndex) offset.value = withTiming(targetOffset, { duration: easeDuration, easing: easeMap[easeingFunc] diff --git a/solutions/rn-mpx-swiper-display-multiple-items-changestart.md b/solutions/rn-mpx-swiper-display-multiple-items-changestart.md index 6875ab006d..5470f302dc 100644 --- a/solutions/rn-mpx-swiper-display-multiple-items-changestart.md +++ b/solutions/rn-mpx-swiper-display-multiple-items-changestart.md @@ -1,631 +1,173 @@ # Mpx2RN mpx-swiper 多项展示与 changestart 支持方案 -## 背景与问题 - -`mpx-swiper.tsx` 是 Mpx2RN runtime 中对小程序 `swiper` 能力的 RN 实现。当前实现已经支持 `indicator-dots`、`current`、`autoplay`、`circular`、`vertical`、`previous-margin`、`next-margin`、`duration`、`easing-function` 等核心能力,但此前存在两个能力缺口: - -1. `display-multiple-items` 未支持,RN 侧一个 swiper item 始终按容器完整宽度或高度渲染,无法实现一屏展示多个 swiper item。 -2. `bindchangestart` / `changestart` 未支持,业务只能在 `change` 阶段拿到最终索引,无法在切换动画开始时获知目标页。 - -这两个能力都不是单纯新增 prop 即可完成。`display-multiple-items` 会改变 swiper 的基础度量模型、循环补位数量、非循环边界、手势目标索引、阻力区间与 autoplay 停止条件;`changestart` 则需要嵌入到 autoplay、外部 `current` 更新和手势切换三个路径中,且要避免与既有 `change` 事件重复或时序冲突。 - ## 目标 -1. 在 RN runtime 中支持 `display-multiple-items`,默认值保持为 1。 -2. 在横向和纵向 swiper 下都以 `display-multiple-items` 拆分单个 item 的宽度或高度。 -3. 在非循环模式下,最大可滚动索引从 `childrenLength - 1` 调整为 `childrenLength - displayMultipleItems`。 -4. 在循环模式下,根据多项展示需要动态补足前后 clone,保证边界过渡过程中可见区域不露空。 -5. 新增 `bindchangestart`,在切换动画开始前触发 `changestart`。 -6. 保持原有 `change` 事件语义不变:`currentIndex` 真正更新后再触发。 -7. 新增 prop 不透传到 RN 原生 `View`。 -8. display 相关逻辑尽量贴近 `fix-drn-2.10.18` 分支实现,本轮只保留必要的小范围修正,不额外扩大动态更新与越界兜底范围。 - -## 非目标 - -1. 不支持 `snap-to-edge`,该能力仍标记为未支持。 -2. 不改 `mpx-swiper-item.tsx` 的渲染模型,只继续通过 `SwiperContext.step` 控制 item 尺寸。 -3. 不改分页点数量与交互语义,分页仍按真实 children 数量渲染。 -4. 不改 `change` 的事件 `source` 字段,目前仍沿用 `{ source: 'touch' }` 的既有行为。 -5. 不引入新的手势库或重写 gesture 状态机。 - -## 当前相关实现概览 - -### 文件范围 - -主要改动文件: - -```text -packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx -``` - -关联但未改动的文件: - -```text -packages/webpack-plugin/lib/runtime/components/react/mpx-swiper-item.tsx -``` - -`mpx-swiper-item.tsx` 中 item 的宽高由 `SwiperContext.step` 决定: - -1. 横向时 item 外层宽度为 `step.value`,高度为 `100%`。 -2. 纵向时 item 外层高度为 `step.value`,宽度为 `100%`。 - -因此本方案在 swiper wrapper 中重算 `step`,就能影响每个 swiper item 的可见尺寸。 - -## 方案一:新增 prop 与事件类型 - -### 改动内容 - -在 `SwiperProps` 中新增: - -```ts -'display-multiple-items'?: number -bindchangestart?: (event: NativeSyntheticEvent | unknown) => void -``` - -组件内部新增: +Mpx2RN 的 `swiper` 新增两项能力: -```ts -const displayMultipleItems = Number(props['display-multiple-items']) || 1 -``` - -### 为什么这么改 +1. 支持 `display-multiple-items`,一屏展示多个 `swiper-item`。 +2. 支持 `bindchangestart`,在 swiper 开始切换时通知目标索引。 -1. `display-multiple-items` 是小程序 swiper 的标准属性,RN runtime 需要在 wrapper 层感知它,因为 item 尺寸、边界和循环补位都依赖该值。 -2. RN 模板编译后静态属性可能以字符串形式传入,使用 `Number()` 做必要的数值转换,避免补位数量计算中的 `+` 发生字符串拼接;未传值、传入 `0` 或无法转换为数字时通过 `|| 1` 使用默认值,不额外增加 `Number.isFinite`、取整或正数裁剪。 -3. `bindchangestart` 是业务事件回调,必须进入 props 类型,否则 TS 层无法表达该能力。 +实现尽量复用现有 `step`、offset、手势和循环补位模型,不修改 `mpx-swiper-item`,不重写手势状态机。 -### prop 透传处理 +## 使用方式 -`useInnerProps` 的 remove list 中新增: - -```ts -'display-multiple-items', -'bindchangestart' +```html + + + + + ``` -原因是这两个字段是 Mpx runtime 层消费的逻辑 prop,不应该透传到 RN 原生 `View` 上,否则可能产生无效 native prop 或警告。 - -### RN 编译能力声明 - -原模板组件配置会在 iOS、Android 和 Harmony 编译时提示 `display-multiple-items` 不受支持。runtime 完成支持后,需要将该属性从 RN unsupported 规则中移除,避免编译器继续输出与实际能力不一致的 warning: - ```js -{ - test: /^(snap-to-edge|easing-function)$/, - ios: iosPropLog, - android: androidPropLog, - harmony: harmonyPropLog +function handleChangeStart(event) { + const { current } = event.detail } ``` -这里只移除 `display-multiple-items`,`snap-to-edge` 和 `easing-function` 等尚未支持的属性继续保持原 warning。模板编译测试同时覆盖“`display-multiple-items` 不告警”和“`snap-to-edge` 仍告警”,防止误放开整条规则。 +- `display-multiple-items` 默认值为 `1`。 +- `changestart` 返回 `event.detail = { current }`。 +- 原有 `change` 语义不变,仍返回 `event.detail = { current, source }`。 -## 方案二:基于 displayMultipleItems 重算 step +## 核心实现 -### 原模型 +### display-multiple-items -原实现中: +运行时接收并转换属性: ```ts -step = wrapperWidthOrHeight +const displayMultipleItems = Number(props['display-multiple-items']) || 1 ``` -这意味着一个 swiper item 占满整个 swiper 容器。 +模板组件配置同时放开 iOS、Android 和 Harmony 对该属性的校验,避免继续提示 unsupported warning。 -### 新模型 +#### 1. 重算单个 item 尺寸 -新实现中: +`mpx-swiper-item` 已使用 `SwiperContext.step` 设置宽度或高度,因此只需修改 `step`: -```ts -step = wrapperWidthOrHeight / displayMultipleItems +```text +availableSize = mainAxisSize - previousMargin - nextMargin +step = availableSize / displayMultipleItems ``` -横向时: +- 横向 swiper 使用可用宽度。 +- 纵向 swiper 使用可用高度。 +- offset、动画和手势继续以一个 `step` 为移动单位。 -```ts -step = (width - previousMargin - nextMargin) / displayMultipleItems -``` +#### 2. 调整非循环边界 -纵向时: +多项展示时,最后一个合法起点为: ```ts -step = (height - previousMargin - nextMargin) / displayMultipleItems +const maxIndex = Math.max( + 0, + childrenLength - displayMultipleItems +) ``` -### 为什么这么改 - -`mpx-swiper-item` 已经通过 context 读取 `step` 并用它设置自身外层宽高。将 `step` 降为容器可用尺寸的 `1 / displayMultipleItems`,可以复用现有 item 渲染机制,不需要改 swiper item 本身。 - -这样带来的好处: - -1. 改动集中在 swiper wrapper,侵入性小。 -2. 横向、纵向逻辑可以共用 `step`。 -3. 现有 scale、offset、gesture 计算仍围绕 `step` 运行,模型连续。 - -### 动态 display 更新 - -本轮不新增 `wrapperSizeRef` 缓存尺寸,也不额外处理 `display-multiple-items` 动态变化时的 step 反推。保持与 `fix-drn-2.10.18` 接近:主要依赖 `onWrapperLayout` 与既有 effect 同步 shared value。 - -这样做的原因是控制本次迁移范围,避免为了动态配置场景引入更多状态同步逻辑。动态切换 `display-multiple-items` 的完整表现可以作为后续增强单独评估。 - -## 方案三:非循环 current 边界保持 fix-drn 口径 - -### 问题 - -支持多项展示后,非循环模式下最后一个合法起点不再是最后一个 item。例如 `childrenLength = 5`、`displayMultipleItems = 3` 时,最后一个合法起点是 2,对应可见 `[2, 3, 4]`。 - -### 当前处理 - -本轮不新增 `normalizeCurrent` / `getMaxCurrent` 全链路裁剪 helper,仅保留 `fix-drn-2.10.18` 已有的核心处理: - -1. 手势 `getTargetPosition` 中将非循环目标索引限制在 `[0, childrenLength - displayMultipleItems]`。 -2. autoplay 的停止位置使用 `childrenLength - displayMultipleItems`。 -3. `canMove` / `handleResistanceMove` 的末端边界使用 `childrenLength - displayMultipleItems`。 +例如 5 个 item 同时展示 3 个时,`maxIndex = 2`,最后一屏为 `[2, 3, 4]`。 -外部传入越界 `current`、children 动态减少、`display-multiple-items` 动态变化导致的 current 重新裁剪,本轮暂不额外扩展。 +`maxIndex` 统一用于手势目标、autoplay 终点和边界阻力,避免最后一屏继续滑出空白。 -## 方案四:循环模式动态补位 +#### 3. 调整循环补位 -### 原补位逻辑 - -原 circular 模式补位规则是: - -1. 无 `previous-margin` 时,前后各补 1 个。 -2. 有 `previous-margin` 时,前后各补 2 个。 - -这个规则只适用于单 item 宽度接近容器宽度的场景。多 item 展示时,如果仍只补 1 或 2 个,在循环边界过渡时可能露出空白。 - -### 新补位数量 +循环模式需要在真实 children 前后克隆足够的 item: ```ts -const hasEdgeMargin = !!preMargin || !!nextMargin -const patchElmNum = (circular && children.length > 1) +const hasEdgeMargin = !!previousMargin || !!nextMargin +const patchElmNum = circular && childrenLength > 1 ? displayMultipleItems + (hasEdgeMargin ? 1 : 0) : 0 ``` -### 为什么要考虑 previous-margin / next-margin - -`patchElmNum` 需要同时兼顾多项展示和边缘露出: - -1. 无边缘 margin 时,可见区域宽度正好由 `displayMultipleItems` 个 item 填满,循环边界至少需要补 `displayMultipleItems` 个 clone。 -2. 有 `previous-margin` 或 `next-margin` 时,可见区域会额外露出边缘内容。此时滑动过渡中除了当前可见的 `displayMultipleItems` 个 item,还需要额外 1 个 item 承接边缘露出,所以补 `displayMultipleItems + 1` 个 clone。 -3. 这里使用 `preMargin || nextMargin`,而不是只看 `previous-margin`。原因是 `previous-margin` 影响左侧露出和初始 offset,`next-margin` 影响右侧露出;二者任意存在,都可能让循环边界需要多一个 clone 来覆盖边缘可视区域。 - -关键组合如下: - -```text -displayMultipleItems = 1, no margin -> patchElmNum = 1 -displayMultipleItems = 1, has margin -> patchElmNum = 2 -displayMultipleItems = N, no margin -> patchElmNum = N -displayMultipleItems = N, has margin -> patchElmNum = N + 1 -``` - -这样可以保留原默认行为:`display-multiple-items` 缺省为 1 且无 `previous-margin` / `next-margin` 时,仍然是前后各补 1 个,而不是无条件补 2 个。 - -### 前置 clone 生成 - -前置 clone 从真实 children 尾部向前取: - -```ts -const startIndex = intLen - (patchElmNum % intLen) -for (let i = 0; i < patchElmNum; i++) { - const sourceIndex = (startIndex + i) % intLen -} -``` - -例如: - -```text -childrenLength = 5 -displayMultipleItems = 2 -hasEdgeMargin = false -patchElmNum = 2 -front clones = [3, 4] -back clones = [0, 1] -``` - -如果存在 `previous-margin` 或 `next-margin`: - -```text -childrenLength = 5 -displayMultipleItems = 2 -hasEdgeMargin = true -patchElmNum = 3 -front clones = [2, 3, 4] -back clones = [0, 1, 2] -``` - -### 循环索引映射 +| 场景 | 前后各补数量 | +| --- | ---: | +| 无 margin | `displayMultipleItems` | +| 有 previous-margin 或 next-margin | `displayMultipleItems + 1` | -在手势目标计算中,补位区的 `moveToIndex` 统一映射为真实索引: +补位区通过取模映射回真实索引: ```ts -let circularIndex = (moveToIndex - patchElmNumShared.value) % childrenLength.value -if (circularIndex < 0) { - circularIndex += childrenLength.value -} -``` - -### 为什么这么改 - -之前前置补位区只有两种映射: - -1. `moveToIndex === 0` 映射到 `childrenLength - patchElmNum` -2. 其他都映射到 `childrenLength - 1` - -当 `patchElmNum > 2` 时,这个映射会错误。例如: - -```text -childrenLength = 5 -displayMultipleItems = 2 -hasEdgeMargin = true -patchElmNum = 3 -front clones = [2, 3, 4] -旧映射 = [2, 4, 4] -新映射 = [2, 3, 4] -``` - -这里分为两步处理: - -1. `moveToIndex - patchElmNumShared.value` 先移除前置 clone 带来的索引偏移,再通过 `% childrenLength.value` 将索引限制在一个 children 周期内。 -2. JavaScript 对负数取余仍会得到负数,前置 clone 可能产生负余数,因此在结果小于 0 时加上 `childrenLength.value`,将其修正为有效的真实索引。 - -这样前置补位、真实区、后置补位都能使用同一套映射逻辑。分步写法与双重取模结果一致,但更容易理解每一步的目的。 - -## 方案五:非循环边界调整 - -### autoplay 边界 - -非循环 autoplay 原来在: - -```ts -currentIndex.value === childrenLength.value - 1 +let index = (moveToIndex - patchElmNum) % childrenLength +if (index < 0) index += childrenLength ``` -时停止。现在改为: - -```ts -const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) -if (currentIndex.value >= maxIndex) { - pauseLoop() -} -``` - -### 为什么使用非负 maxIndex 和 >= - -非循环模式下,最后一个合法起始索引不能小于 0。当 `displayMultipleItems > childrenLength` 时,`childrenLength - displayMultipleItems` 会得到负数,但实际只能停在索引 0。使用 `Math.max(0, ...)` 可以让 autoplay、手势定位和阻力边界保持一致。使用 `>=` 则可以在 children 动态减少、当前索引已经超过新边界时及时停止 autoplay。 - -### 手势边界 - -非循环模式下: - -1. `getTargetPosition` 将目标索引上限裁剪到 `maxIndex`。 -2. `canMove` 的末端边界改为 `-step * maxIndex`。 -3. `handleResistanceMove` 的末端阻力边界同样改为 `-step * maxIndex`。 +这样可以统一处理前置 clone、真实 children 和后置 clone,避免补位数量增加后跳错索引。 -### 为什么这么改 +### changestart -多项展示时,滚动坐标含义仍然是“以第几个 item 作为视口起点”。非循环最后可滚动坐标应该让最后一屏刚好展示到最后一个真实 item,而不是以最后一个 item 作为起点。 - -当 `displayMultipleItems > childrenLength` 时,非循环模式不会生成 clone,`maxIndex` 固定为 0;循环模式则继续通过重复 clone 填充可视区域,不受这组非循环边界调整影响。 - -## 方案六:changestart 事件 - -### 新增事件处理函数 +目标索引确定后创建事件: ```ts -function handleSwiperChangeStart (current: number) { - const eventData = getCustomEvent('changestart', {}, { +function handleSwiperChangeStart(current) { + const event = getCustomEvent('changestart', {}, { detail: { current }, layoutRef }) - bindchangestart && bindchangestart(eventData) + bindchangestart && bindchangestart(event) } ``` -并注册到 `runOnJSCallbackRef`,让 worklet 中可以触发。 - -### 触发点 +覆盖四条切换路径: -1. autoplay 确定 `nextIndex` 后、动画开始前触发。 -2. 外部 `current` 触发动画前触发。 -3. 手势 `handleEnd` 确定目标索引后、`withTiming` 动画开始前触发。 -4. 手势 onUpdate 过程中跨过半屏并更新 `currentIndex` 前触发。 +1. autoplay 确定下一个索引。 +2. 外部更新 `current`。 +3. 手势结束并确定目标索引。 +4. 拖动超过半个 item,目标索引发生变化。 -### 与 change 的关系 - -`change` 仍由: - -```ts -useAnimatedReaction(() => currentIndex.value, ...) -``` - -触发。也就是说: +事件时序: ```text -changestart: 目标 current 已确定,切换动画即将开始 -change: currentIndex 已更新,切换结果生效 +确定目标索引 + -> changestart + -> 执行动画或更新索引 + -> change ``` -### 为什么这么改 - -这符合小程序侧常见的事件语义:`changestart` 代表切换开始,`change` 代表切换完成或索引变更生效。把 `changestart` 放在 `withTiming` 之前,可以让业务在动画开始时提前响应。 - -## 方案七:动态变化处理边界 - -### margin 动态变化 - -`previous-margin` / `next-margin` 变化后,当前保持接近 `fix-drn` 的处理方式: +`changestart` 表示切换开始,`change` 表示 current 已经更新。 -1. 计算 margin delta。 -2. 更新 `preMarginShared` / `nextMarginShared`。 -3. 用 `step.value - patchStep` 更新 step。 -4. 根据当前 index 重新计算 offset。 +## 兼容性与非目标 -这里没有额外按 `displayMultipleItems` 拆分 margin delta,也没有缓存 wrapper 尺寸。该部分是后续可评估的增强点。 +1. 未传 `display-multiple-items` 时默认为 `1`,保留原单项展示行为。 +2. 单项展示且无 margin 时,循环模式仍然前后各补一个 item。 +3. 横向、纵向、autoplay、circular、previous-margin 和 next-margin 继续可用。 +4. `snap-to-edge` 仍不支持。 +5. 不修改 `change` 事件现有的 `source: 'touch'` 行为。 -### display-multiple-items 动态变化 +## 已知风险 -当 `displayMultipleItems` 变化: +1. **动态修改配置**:只修改 `display-multiple-items` 时,容器可能不会重新触发 `onLayout`,`step` 不一定立即重算;动态修改 margin 也仍沿用原有增量算法。 +2. **外部 current 越界**:手势和 autoplay 已使用新边界,但外部传入的 `current` 没有新增统一裁剪。 +3. **非法属性值**:当前只做 `Number(value) || 1`,负数、小数和 `Infinity` 不会额外归一化,调用方应传正整数。 +4. **循环渲染开销**:展示数量较大时 clone 数量同步增加;当展示数量大于 children 数量时会重复克隆。 +5. **事件触发次数**:快速往返拖动时目标索引可能多次变化,因此一次手势可能触发多次 `changestart`。 +6. **运行时测试不足**:当前模板测试只覆盖属性告警,循环补位和事件时序仍需 RN runtime 测试或真机验证。 -1. 更新 `displayMultipleItemsShared.value`。 -2. 根据当前 step 重新计算 offset。 +## 验证重点 -本轮不通过缓存尺寸反推新 step,也不额外裁剪 current。这样可以减少逻辑迁移范围,保持与 `fix-drn` 更一致。 +1. 横向、纵向分别验证展示数量 `1/2/3`。 +2. 非循环模式验证最后一屏和边界阻力。 +3. 循环模式验证无 margin、previous-margin、next-margin。 +4. 验证 children 数量小于、等于和大于展示数量。 +5. 验证 autoplay、外部 `current`、快速反向滑动。 +6. 验证 `changestart` 先于对应的 `change`。 -### circular 动态变化 +## Review 重点 -当 `circular` 变化: - -1. 更新 `circularShared`。 -2. 更新 `patchElmNumShared`。 -3. 按新的 circular 语义重算 offset。 - -### children 动态变化 - -当 children 数量变化: - -1. 更新 `childrenLength.value`。 -2. 如果 `children.length - 1 < currentIndex.value`,将 current 重置为 0。 -3. 必要时暂停 autoplay 并重新启动。 - -## 关键数据流 - -### 横向非循环,多项展示 - -```text -wrapper layout width - -> realWidth = width - previousMargin - nextMargin - -> step = realWidth / displayMultipleItems - -> swiper-item width = step - -> maxCurrent = childrenLength - displayMultipleItems - -> offset = -current * step -``` - -### 横向循环,多项展示 +本次方案的核心关系是: ```text displayMultipleItems - -> hasEdgeMargin = !!previousMargin || !!nextMargin - -> patchElmNum = displayMultipleItems + (hasEdgeMargin ? 1 : 0) - -> render front clones + real children + back clones - -> initial offset = -(current + patchElmNum) * step + previousMargin - -> crossing boundary - -> animate to clone position - -> reset to matching real position -``` - -### changestart 时序 - -```text -target current calculated - -> changestart(current) - -> withTiming animation - -> currentIndex.value = current - -> change(current) -``` - -## 风险点与应对 - -### 风险一:循环补位数量与索引映射复杂 - -风险: - -1. `displayMultipleItems + (hasEdgeMargin ? 1 : 0)` 会让前后 clone 数量随配置变大。 -2. 当 `displayMultipleItems >= childrenLength` 时,clone 中会重复使用真实节点。 -3. 循环边界 reset 的坐标和真实索引映射需要完全一致,否则会出现跳错页或闪动。 - -当前应对: - -1. clone 生成使用 modulo,允许补位数量大于 children 数量。 -2. 手势目标索引使用统一 modulo 映射。 -3. 已用示例校验 `childrenLength=5, displayMultipleItems=2` 的前置 clone 映射从 `[2,4,4]` 修正为 `[2,3,4]`。 - -后续建议: - -1. 增加真实 RN 示例验证 `childrenLength < displayMultipleItems`、`childrenLength === displayMultipleItems`、`childrenLength > displayMultipleItems` 三类场景。 -2. 特别验证 circular + previous-margin + displayMultipleItems 的组合。 - -### 风险二:动态 display-multiple-items 依赖缓存尺寸 - -风险: - -RN 的 `onLayout` 只在布局变化时触发。如果只改 `display-multiple-items`,容器尺寸可能不变,因此不会自然重算 step。 - -当前取舍: - -1. 本轮不引入 wrapper 尺寸缓存。 -2. `displayMultipleItemsShared` 会更新,offset 会按当前 step 重新计算。 -3. 若业务运行时动态修改 `display-multiple-items`,可能需要等待布局回调或后续增强才能完全修正 item 尺寸。 - -剩余风险: - -1. 运行时动态切换 display 数量可能出现短时间或持续的 item 尺寸不符合预期。 -2. 如果业务明确依赖动态切换,需要单独补充缓存尺寸和 step 重算逻辑。 - -### 风险三:margin 动态变化与布局回调可能重复 - -风险: - -当前 margin effect 沿用接近 `fix-drn` 的 `step.value - patchStep` 处理。多项展示时,margin delta 理论上应按 `displayMultipleItems` 分摊到单个 item 的 step 上。 - -当前取舍: - -1. 本轮不引入 `wrapperSizeRef`,避免扩大状态同步逻辑。 -2. 保持与 `fix-drn` 行为接近,降低迁移风险。 - -剩余风险: - -1. 在 `displayMultipleItems > 1` 且动态修改 margin 时,step 变化量可能偏大。 -2. 如果业务不动态修改 margin,则主要影响较小。 - -### 风险四:外部 current 越界仍未统一裁剪 - -风险: - -外部传入越界 current 时,当前方案不会新增统一裁剪。例如非循环 `childrenLength=5, displayMultipleItems=3, current=4` 时,理论合法起点应为 2,但本轮不额外改这条路径。 - -当前取舍: - -1. 贴近 `fix-drn`,避免扩展 current 语义。 -2. 本轮聚焦基础 display 展示、补位数量、循环索引映射。 - -需要 review 的点: - -1. 是否接受外部越界 current 仍可能进入非法 offset。 -2. 是否需要在后续单独补充 `normalizeCurrent` 方案。 - -### 风险五:changestart 可能在某些路径提前于 change 多次触发 - -风险: - -手势 onUpdate 跨过半屏时会触发一次 `changestart` 并更新 `currentIndex`;onFinalize 阶段如果目标索引再次变化,也可能触发另一次。 - -当前应对: - -1. `triggerChangeStart` 会判断 `current !== currentIndex.value`。 -2. 如果 onUpdate 已经把 `currentIndex` 更新为目标值,onFinalize 同目标不会再次触发。 - -剩余风险: - -1. 快速往返拖拽时目标索引来回变化,会按变化次数触发多次 `changestart`。 -2. 这与“目标切换开始”语义基本一致,但业务如果假设一次手势最多一个 changestart,需要额外说明。 - -### 风险六:change 事件 source 仍固定为 touch - -风险: - -当前既有 `change` 事件 detail 里 `source` 固定为 `'touch'`。本次新增 autoplay 与外部 current 的 `changestart` 后,事件 source 精度没有同步提升。 - -为什么本次不改: - -1. 这是已有行为,贸然修改可能影响业务判断。 -2. 本次目标聚焦 `display-multiple-items` 与 `changestart`。 - -后续建议: - -如需完整对齐小程序,可单独评估 `source` 在 autoplay、touch、外部 current 更新中的语义。 - -### 风险七:display-multiple-items 非法输入 - -风险: - -当前入口只做必要的数值转换和默认值处理,不额外校验或归一化 `display-multiple-items`: - -```ts -const displayMultipleItems = Number(props['display-multiple-items']) || 1 -``` - -潜在影响: - -1. `undefined`、`0`、空字符串、非法字符串和 `NaN` 经转换后会回退为 `1`。 -2. 负数、小数和 `Infinity` 等转换后的 truthy 数值会直接参与 step、clone 数量和边界计算,调用方需要遵循该属性应为正整数的约定。 - -建议: - -保留 `Number()` 解决 RN 模板输入的运行时类型问题,但不在本次实现中增加 `Number.isFinite`、`Math.floor` 或 `Math.max` 等额外容错;如后续确认需要统一校验,应作为组件属性校验策略单独评估。 - -### 风险八:测试覆盖不足 - -风险: - -当前完成了单文件 lint、diff check 和若干索引推导校验,但缺少真实 RN 运行时交互验证。 - -建议至少覆盖: - -1. 横向非循环:`display-multiple-items=2/3`。 -2. 纵向非循环:`display-multiple-items=2/3`。 -3. 横向循环:`childrenLength=2/3/5` 与 `display-multiple-items=2/3`。 -4. `previous-margin` / `next-margin` 与多项展示组合。 -5. autoplay 非循环到尾部停止。 -6. autoplay circular 从最后一项回到第一项。 -7. 外部动态更新 current 到合法值与越界值。 -8. 动态切换 `display-multiple-items`。 -9. 动态增删 swiper item。 -10. 快速拖拽、反向拖拽、边界阻力拖拽。 -11. `bindchangestart` 与 `bindchange` 触发顺序。 - -## 验证结果 - -已执行: - -```bash -node_modules/.bin/eslint packages/webpack-plugin/lib/runtime/components/react/mpx-swiper.tsx -git diff --check -``` - -结果: - -1. 单文件 ESLint 通过。 -2. `git diff --check` 通过。 - -已尝试: - -```bash -node_modules/.bin/tsc -p packages/webpack-plugin/lib/runtime/components/react/tsconfig.json --noEmit + -> step 变小 + -> 非循环 maxIndex 调整 + -> 循环 clone 数量增加 ``` -结果: - -1. 当前失败原因是既有 `@mpxjs/perf` 模块类型缺失。 -2. 报错文件为 `mpx-simple-text.tsx`、`mpx-simple-view.tsx`、`mpx-text.tsx`、`mpx-view.tsx`。 -3. 未发现 `mpx-swiper.tsx` 新增 TS 错误。 - -额外推导校验: - -```text -childrenLength = 5 -displayMultipleItems = 2 -hasEdgeMargin = false -patchElmNum = 2 -front clone render indexes = [3, 4] -front clone mapped indexes = [3, 4] - -childrenLength = 5 -displayMultipleItems = 2 -hasEdgeMargin = true -patchElmNum = 3 -front clone render indexes = [2, 3, 4] -front clone mapped indexes = [2, 3, 4] -``` - -## Review 清单 - -代码 review 建议重点看: - -1. `displayMultipleItems` 使用 `Number(props['display-multiple-items']) || 1` 完成类型转换和默认值处理是否符合业务兼容预期。 -2. `patchElmNum = displayMultipleItems + (hasEdgeMargin ? 1 : 0)` 是否满足所有循环边界场景。 -3. 外部越界 current 暂不统一裁剪是否符合本次“贴近 fix-drn”的取舍。 -4. `changestart` 是否需要补充 `source` 字段。 -5. 快速拖拽时多次 `changestart` 是否符合预期。 -6. margin 动态变化暂不按 `displayMultipleItems` 分摊 delta 是否可接受。 -7. `childrenLength <= displayMultipleItems` 时循环模式重复 clone 的展示行为是否符合预期。 -8. 是否需要在文档或测试用例中明确 `display-multiple-items` 动态变化的支持范围。 - -## 后续建议 +Review 时主要确认: -1. 增加一个 RN 示例页面,专门覆盖 `display-multiple-items`、`circular`、`previous-margin`、`next-margin`、`autoplay`、`changestart` 组合。 -2. 在真机或模拟器上录制边界滑动行为,重点看循环 reset 是否闪动。 -3. 若项目测试体系允许,补充纯函数层面的索引映射单测,把 clone 生成与 `moveToIndex -> selectedIndex` 的关系固定下来。 -4. 若后续要完整对齐小程序事件语义,单独评估 `change/changestart` 的 `source` 字段。 +1. 循环补位公式是否覆盖业务使用的 margin 组合。 +2. 是否接受动态配置和外部越界 `current` 暂不完整处理。 +3. 快速往返拖动可能多次触发 `changestart` 是否符合业务预期。 From 32a58731eee5ac94da0caa9420e942f8bb65f17f Mon Sep 17 00:00:00 2001 From: xuegan Date: Thu, 10 Sep 2026 21:21:15 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(rn):=20=E4=BF=AE=E5=A4=8D=20swiper=20?= =?UTF-8?q?=E5=A4=9A=E9=A1=B9=E5=B1=95=E7=A4=BA=E4=B8=8E=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../references/rn-template-reference.md | 8 +- docs-vitepress/guide/rn/component.md | 8 +- .../components/react/mpx-swiper-utils.ts | 122 +++++ .../runtime/components/react/mpx-swiper.tsx | 371 +++++++------- .../test/platform/wx/template/swiper.spec.js | 10 + .../react-native/mpx-swiper-utils.spec.ts | 110 +++++ .../runtime/react-native/mpx-swiper.spec.ts | 453 ++++++++++++++++++ ...iper-display-multiple-items-changestart.md | 32 +- 8 files changed, 915 insertions(+), 199 deletions(-) create mode 100644 packages/webpack-plugin/lib/runtime/components/react/mpx-swiper-utils.ts create mode 100644 packages/webpack-plugin/test/runtime/react-native/mpx-swiper-utils.spec.ts create mode 100644 packages/webpack-plugin/test/runtime/react-native/mpx-swiper.spec.ts diff --git a/.agents/skills/mpx2rn/references/rn-template-reference.md b/.agents/skills/mpx2rn/references/rn-template-reference.md index de7fece225..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,14 +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` | 同时显示的滑块数量 | +| 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 组件 | @@ -824,7 +824,7 @@ Mpx 输出 RN 内置支持了大部分常用的基础组件,详情见下方文 | 事件名 | 说明 | | --- | --- | | change | current 改变时会触发 change 事件,`event.detail = {current, source}` | -| changestart | swiper 切换开始时会触发 changestart 事件,`event.detail = {current}` | +| 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 a18152acbc..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,14 +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` | 同时显示的滑块数量 | +| 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 组件| @@ -183,7 +183,7 @@ | 事件名 | 说明 | | ------------| ------------------ | | change| current 改变时会触发 change 事件,`event.detail = {current, source}`| -| changestart| swiper 切换开始时会触发 changestart 事件,`event.detail = {current}`| +| changestart| swiper 切换开始时会触发 changestart 事件,`event.detail = {current}` 中的 current 为目标索引 | ### swiper-item 仅可放置在swiper组件中,宽高自动设置为100%。 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 b269a32674..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 @@ -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,7 @@ interface SwiperProps { 'wait-for'?: Array 'simultaneous-handlers'?: Array disableGesture?: boolean - 'display-multiple-items'?: number + 'display-multiple-items'?: number | string bindchange?: (event: NativeSyntheticEvent | unknown) => void bindchangestart?: (event: NativeSyntheticEvent | unknown) => void } @@ -124,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 @@ -172,7 +198,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr marginBottom: dotSpacing, zIndex: 98 } - const displayMultipleItems = Number(props['display-multiple-items']) || 1 + 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 @@ -208,27 +234,29 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const autoplayShared = useSharedValue(autoplay) // 支持swiper-item 同时存在并列的情况 const children = React.Children.toArray(props.children) as ReactElement[] - // 默认前后补位的元素个数 - const patchElmNum = (circular && children.length > 1) - ? displayMultipleItems + (hasEdgeMargin ? 1 : 0) - : 0 - const patchElmNumShared = useSharedValue(patchElmNum) 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 / displayMultipleItems + 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 @@ -240,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) @@ -311,33 +340,39 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr 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) / displayMultipleItems + 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) { @@ -352,18 +387,6 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr return ( - {dots} ) @@ -414,12 +437,12 @@ 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) { // 获取下一个位置的坐标, 循环到最后一个元素,直接停止, 取消定时器 - const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) + const maxIndex = getSwiperMaxIndex(childrenLength.value, displayMultipleItemsShared.value, false) if (currentIndex.value >= maxIndex) { pauseLoop() return @@ -427,13 +450,16 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr 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 { // 默认向右, 向下 @@ -441,27 +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') + } }) } } @@ -478,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() } } @@ -506,35 +538,65 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr 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) { - runOnJSCallback('handleSwiperChangeStart', propCurrent) + 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 () { @@ -546,50 +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 - const maxIndex = circular ? children.length - 1 : Math.max(0, children.length - displayMultipleItems) - if (!children.length || maxIndex < 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 @@ -601,14 +656,6 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr } }, [autoplay]) - useEffect(() => { - if (circular !== circularShared.value || patchElmNum !== patchElmNumShared.value || displayMultipleItems !== displayMultipleItemsShared.value) { - circularShared.value = circular - patchElmNumShared.value = patchElmNum - displayMultipleItemsShared.value = displayMultipleItems - offset.value = getOffset(currentIndex.value, step.value) - } - }, [circular, patchElmNum, displayMultipleItems]) const { gestureHandler } = useMemo(() => { // 基于transdir + 当前offset计算索引 function getTargetPosition (eventData: EventEndType) { @@ -627,14 +674,11 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const moveToIndex = transdir < 0 ? Math.ceil(computedIndex) : Math.floor(computedIndex) // 实际应该定位的索引值 if (!circularShared.value) { - const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) - selectedIndex = Math.min(moveToIndex, maxIndex) + const maxIndex = getSwiperMaxIndex(childrenLength.value, displayMultipleItemsShared.value, false) + selectedIndex = Math.min(Math.max(moveToIndex, 0), maxIndex) moveToTargetPos = selectedIndex * step.value } else { - let circularIndex = (moveToIndex - patchElmNumShared.value) % childrenLength.value - if (circularIndex < 0) { - circularIndex += childrenLength.value - } + const circularIndex = getCircularIndex(moveToIndex - patchElmNumShared.value, childrenLength.value) if (moveToIndex >= childrenLength.value + patchElmNumShared.value) { selectedIndex = circularIndex resetOffsetPos = (selectedIndex + patchElmNumShared.value) * step.value - preMarginShared.value @@ -665,7 +709,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const gestureMovePos = offset.value + translation if (!circularShared.value) { // 如果只判断区间,中间非滑动状态(handleResistanceMove)向左滑动,突然改为向右滑动,但是还在非滑动态,本应该可滑动判断为了不可滑动 - const maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) + const maxIndex = getSwiperMaxIndex(childrenLength.value, displayMultipleItemsShared.value, false) const posEnd = -step.value * maxIndex if (transdir < 0) { return gestureMovePos > posEnd @@ -686,12 +730,13 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr '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') @@ -701,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') } @@ -719,14 +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) - triggerChangeStart(moveToIndex) + 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') } }) @@ -747,61 +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 maxIndex = Math.max(0, childrenLength.value - displayMultipleItemsShared.value) + const maxIndex = getSwiperMaxIndex(childrenLength.value, displayMultipleItemsShared.value, false) const maxOffset = translation < 0 ? -maxIndex * 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 + return getSwiperResistanceOffset(offset.value, translation, transdir, maxOffset, step.value) } // 设置手势移动的方向 function setMoveDir (curAbsoPos: number) { @@ -814,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] @@ -824,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 @@ -835,6 +841,7 @@ const SwiperWrapper = forwardRef, SwiperProps>((pr const { selectedIndex } = getTargetPosition({ transdir: moveDistance } as EventEndType) if (selectedIndex !== currentIndex.value) { triggerChangeStart(selectedIndex) + targetIndex.value = selectedIndex currentIndex.value = selectedIndex } } @@ -872,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 index 3a0b588d98..585c6456e4 100644 --- a/packages/webpack-plugin/test/platform/wx/template/swiper.spec.js +++ b/packages/webpack-plugin/test/platform/wx/template/swiper.spec.js @@ -19,6 +19,16 @@ describe('swiper template transform', function () { 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 = '' 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 index 5470f302dc..a84624d782 100644 --- a/solutions/rn-mpx-swiper-display-multiple-items-changestart.md +++ b/solutions/rn-mpx-swiper-display-multiple-items-changestart.md @@ -36,10 +36,10 @@ function handleChangeStart(event) { ### display-multiple-items -运行时接收并转换属性: +运行时接收并将属性归一化为有限正整数: ```ts -const displayMultipleItems = Number(props['display-multiple-items']) || 1 +const displayMultipleItems = normalizeDisplayMultipleItems(props['display-multiple-items']) ``` 模板组件配置同时放开 iOS、Android 和 Harmony 对该属性的校验,避免继续提示 unsupported warning。 @@ -71,10 +71,15 @@ const maxIndex = Math.max( 例如 5 个 item 同时展示 3 个时,`maxIndex = 2`,最后一屏为 `[2, 3, 4]`。 `maxIndex` 统一用于手势目标、autoplay 终点和边界阻力,避免最后一屏继续滑出空白。 +外部 `current` 和动态配置变更也使用同一上限归一化,避免产生越界 offset。 -#### 3. 调整循环补位 +#### 3. 统一重算动态布局 -循环模式需要在真实 children 前后克隆足够的 item: +缓存 swiper 主轴尺寸,`display-multiple-items`、`previous-margin` 或 `next-margin` 变化时均通过完整公式重算 `step`,并在同一次状态对齐中更新索引、offset 与 autoplay。 + +#### 4. 调整循环补位 + +循环模式需要在真实 children 前后克隆足够的 item。基础数量为: ```ts const hasEdgeMargin = !!previousMargin || !!nextMargin @@ -88,6 +93,8 @@ const patchElmNum = circular && childrenLength > 1 | 无 margin | `displayMultipleItems` | | 有 previous-margin 或 next-margin | `displayMultipleItems + 1` | +实际补位数还会与 `ceil(viewportSize / step)` 取较大值,保证自动播放和手势动画的任意一帧都有足够 clone 覆盖视口。 + 补位区通过取模映射回真实索引: ```ts @@ -96,6 +103,11 @@ 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 @@ -139,12 +151,9 @@ function handleSwiperChangeStart(current) { ## 已知风险 -1. **动态修改配置**:只修改 `display-multiple-items` 时,容器可能不会重新触发 `onLayout`,`step` 不一定立即重算;动态修改 margin 也仍沿用原有增量算法。 -2. **外部 current 越界**:手势和 autoplay 已使用新边界,但外部传入的 `current` 没有新增统一裁剪。 -3. **非法属性值**:当前只做 `Number(value) || 1`,负数、小数和 `Infinity` 不会额外归一化,调用方应传正整数。 -4. **循环渲染开销**:展示数量较大时 clone 数量同步增加;当展示数量大于 children 数量时会重复克隆。 -5. **事件触发次数**:快速往返拖动时目标索引可能多次变化,因此一次手势可能触发多次 `changestart`。 -6. **运行时测试不足**:当前模板测试只覆盖属性告警,循环补位和事件时序仍需 RN runtime 测试或真机验证。 +1. **循环渲染开销**:展示数量较大时 clone 数量同步增加;当展示数量大于 children 数量时会重复克隆。 +2. **事件触发次数**:快速往返拖动时目标索引可能多次变化,因此一次手势可能触发多次 `changestart`。 +3. **真机覆盖**:核心数值与边界逻辑已有 RN runtime 单测,手势动画的平台实现仍需真机回归。 ## 验证重点 @@ -154,6 +163,7 @@ function handleSwiperChangeStart(current) { 4. 验证 children 数量小于、等于和大于展示数量。 5. 验证 autoplay、外部 `current`、快速反向滑动。 6. 验证 `changestart` 先于对应的 `change`。 +7. 验证指示点总数与真实 children 一致,且多项展示和循环跨尾部时高亮范围正确。 ## Review 重点 @@ -169,5 +179,5 @@ displayMultipleItems Review 时主要确认: 1. 循环补位公式是否覆盖业务使用的 margin 组合。 -2. 是否接受动态配置和外部越界 `current` 暂不完整处理。 +2. 动态配置、children 变化与外部 `current` 是否始终同步到同一组 step、索引和 offset。 3. 快速往返拖动可能多次触发 `changestart` 是否符合业务预期。