iOS Mail 应用里左滑删除邮件那个手势,大概是被模仿最多的移动交互。手指往左一划,按钮露出来,再一划触发动作。简单到任何人凭直觉就能操作。但把它搬到 React Native 上就会发现,"看起来简单"和"做起来像"之间,隔着整整一层物理学。
rit3zh/expo-ios-like-swipe-actions 这个项目用 Expo + Reanimated 4 + Gesture Handler 完整复刻了 iOS Mail 的滑动手势体验——橡胶带回弹、速度投影判定、破坏性操作的差异化行为、跨线程触觉反馈。代码量不大,但每一个细节都踩在正确的技术点上。我读它的源码时,最深的感受是:作者的功夫落在手感上,而非功能本身。手感这东西虚得很,但虚的东西往往由最硬的数学支撑。
https://github.com/rit3zh/expo-ios-like-swipe-actions
手势是物理,不是位移
大多数 React Native 滑动手势库的做法是线性映射:手指移动多少,组件就平移多少。iOS 不是这样。你在 iOS 上滑动一行邮件,会经历三个阶段——初始阶段 1:1 跟随手指,没有任何阻力;超过按钮宽度后进入阻尼阶段,越滑越重,像在拉一根越来越紧的弹簧;拉到某个临界点后几乎拉不动了,但还没到硬边界,这是弹性极限。
这个项目的核心是 shapeTranslation 函数,完整复现了这套三段式物理模型:
functionshapeTranslation(raw, limit, cellWidth, fullSwipe, elasticRatio) {"worklet";if (abs <= limit) return raw; // 1:1 跟随const overflow = abs - limit;if (fullSwipe && abs <= cw) {const easedProgress = progress * (FULL_SWIPE_RESISTANCE + (1 - FULL_SWIPE_RESISTANCE) * progress);return sign * (limit + remaining * easedProgress); }return sign * (elasticPeak + rubberBand(pastCw, refDim)); // 橡胶带回弹}
关键在于 rubberBand 函数,它直接实现了 Apple 经典的橡胶带公式:
functionrubberBand(overflow, dim, c = RUBBER_COEF) {"worklet";return (1 - 1 / ((overflow * c) / dim + 1)) * dim;}
RUBBER_COEF = 0.55。系数越小,阻力增长越快;系数越大,手感越松。这意味着组件的每一毫米滑动都在实时计算一个非线性函数——1:1 区间过渡到渐进阻力区间,再过渡到橡胶带区间。曲线决定手感。响应再快,曲线不对,总觉得别扭。
放手之后,速度说了算
滑动手势最难的不是跟手,而是手指离开屏幕之后怎么办。这个项目在 onEnd 里做了一个三路判定——提交(commit)、保持打开(open)、关闭(close)。提交的条件不是单一的"滑过某个位置",而是三个触发器取或:
const wantsCommit = fullSwipeEnabled && !closingVelocity && (absPos >= commitAt || // 位置超过提交阈值 absProj >= commitAt || // 投影位置超过提交阈值 (vx * closingSign < -1600 && absPos >= limit)); // 高速滑动
第三行最值得注意:即使没滑到位,只要速度超过 1600 px/s 且已经滑过了按钮区域(absPos >= limit),也算提交。这就是速度投影——手指的动量被当成意图信号。你轻轻一推和用力一甩,结果完全不同。
提交阈值的计算本身也是一个函数:
functioncommitThreshold(limit, cw, overshoot, ratio, expansionOffset) {"worklet";const ratioPoint = cw * ratio;const offsetPoint = expansionOffset === undefined ? ratioPoint : limit + expansionOffset;returnMath.max(limit + overshoot, Math.min(ratioPoint, offsetPoint));}
嵌套的 Math.max 和 Math.min 确保阈值不会因为按钮太少而太近,也不会因为按钮太多而太远。手指离开屏幕的那一刻,才是真正的交互开始。很多人做手势只关注手指在屏幕上时的跟随效果,忽略了手指离开后的惯性判定——那才是决定"像不像原生"的关键。
破坏性操作的细节魔鬼
iOS Mail 有一个很多人没注意到的细节——左滑点击删除(红色破坏性按钮),邮件不会自动收回,而是停留在打开状态;左滑点击标记已读(蓝色普通按钮),操作执行后行会自动收回。这个差异行为在 handleActionPress 中分三路处理:destructive 加主导按钮时动画滑到提交位置并保持打开,通过 scheduleOnRN 触发 committed 回调;destructive 但非主导按钮时直接执行 action.onPress(),不关闭行;非 destructive 的任何按钮,先 closeRow(true) 关闭行,等待 releaseDurationMs 后执行 onPress。
这不是 bug,是 iOS 的设计哲学——破坏性操作给了用户反悔窗口,而非破坏性操作执行完毕后自动收起,减少多余的手势。一个组件把这种细节都做进去了,功夫落在体验的还原上,而非功能的实现。
手势与滚动的边界战争,以及跨线程的触觉
在列表里做滑动手势,最大的敌人是垂直滚动。手指不可能绝对水平移动,稍微往下一点,列表就开始滚,滑动就断了。这个项目的解决方案是 activeOffsetX 和 failOffsetY 的组合:
Gesture.Pan() .activeOffsetX([-12, 12]) // 水平移动超过 12px 才激活 .failOffsetY([-12, 12]) // 垂直移动超过 12px 就失败
12px 的激活窗口不是随便选的。太小了会误触发,太大了手势迟钝。这个数字恰好是手指宽度的一个合理折中。
触觉反馈是这个项目技术含量最高的细节之一。问题在于:手势回调跑在 UI 线程(Reanimated worklet),但 expo-haptics 只能在 JS 线程调用。两个线程之间不能直接通信。项目的解法是用 react-native-worklets 的 scheduleOnRN,把触觉事件从 worklet 线程调度回 JS 线程:
const handleHaptic = useCallback((kind: "reveal" | "commit") => { onHapticCue?.(kind); }, [onHapticCue],);// 在 worklet onUpdate 中:if (hasCrossedReveal.value === 0) { hasCrossedReveal.value = 1;scheduleOnRN(handleHaptic, "reveal");}
两个关键节点都会触发触觉反馈:越过按钮宽度时(reveal)——“按钮出来了”;越过提交阈值时(commit)——“马上就要触发了”。而且这两个标记都有迟滞回退:reveal 在回退到 limit * 0.88(12% 余量)时才重置,commit 在回退到 commitAt - 12(12px 余量)时才重置。用户不会在阈值附近来回摩擦时收到重复震动。你的手指在 UI 线程上滑动,但震动的 API 在 JS 线程——跨线程,是手感好的前提。
组件 API 与性能
大多数滑动手势库的用法是传一个配置数组——<SwipeActions leftActions={[{ text: 'Archive', onPress: fn }]} />。这个项目的做法完全不同——它用的是 Compound Component 模式加 Slot 标记:
<SwipeActions.Root><SwipeActions.Main><Text>Hello</Text></SwipeActions.Main><SwipeActions.Containerside="right"onPress={fn}destructive><SwipeActions.Iconicon="trash" /><SwipeActions.Title>Delete</SwipeActions.Title></SwipeActions.Container></SwipeActions.Root>
Main 和 Container 组件各有一个静态 .slot 属性,子元素解析器通过 child.type.slot 来分类。Container 上的 onPress、destructive、side 等 props 被提取出来组装成 ISwipeAction 对象。这种做法的好处是配置即 JSX——开发者不需要在脑子里把 JSON 数组翻译成 UI,而是直接看到组件的嵌套关系。类型安全、可读性、可扩展性都更好。
性能方面,所有组件都是 React.memo,所有动画状态都用 Shared Value(translateX、cellWidth、isOpen 全部在 UI 线程运行),用 useAnimatedStyle 而不是 setState,手势移动时不触发 React 重渲染,Gesture.Pan() 放在 useMemo 里防止每次渲染重新创建,用 useAnimatedReaction 而不是轮询。结果就是:手势过程中几乎不会触发 React 渲染循环,所有动画都在 60fps 的 UI 线程上运行。
调参的艺术
这个项目有一个 swipe-constants.ts 文件,里面列着所有"魔法数字":
| | |
|---|
RUBBER_COEF | | |
ELASTIC_RATIO | | |
FULL_SWIPE_RESISTANCE | | |
CLOSE_VELOCITY | | |
VELOCITY_COMMIT | | |
MIN_OPEN_TRANSLATION | | |
PROJECTION_COEF | | |
还有 iOS 的缓动曲线:[0.22, 0.82, 0.18, 1],一个自定义的 cubic bezier,模拟原生 iOS 的弹簧感。这些数字不是从数学公式推导出来的,而是反复调到"感觉对了"为止。手感不是算出来的,是调出来的。但调的前提是有一个正确的模型。
总结
这个项目做对了几件事。橡胶带公式、速度投影、破坏性差异行为、触觉反馈节点,每一个选择都指向同一个目标——让用户觉得像原生。shapeTranslation 函数的三段式阻力模型是整组件的灵魂,没有正确的物理曲线,再好的组件结构也只是看起来像。跨线程回调用 scheduleOnRN 桥接 worklet 与 JS 线程,onHapticCue 作为可选 prop 让消费者自己决定是否接入 expo-haptics。Compound Component API 把配置写进 JSX,开发者写的是组件嵌套,不是 JSON 数组。所有动画在 UI 线程跑,性能优化做到极致却不外露,开发者只需要 <SwipeActions.Root> 就好。
做手势组件的人很多,能把物理模型当核心来写的,少见。
项目地址:https://github.com/rit3zh/expo-ios-like-swipe-actions