HarmonyOS APP开发---"心动卡"社交匹配App,需要用到这个库
做一个社交匹配 App,用户左右滑卡片决定喜欢/跳过,滑出的卡片要有弹性回弹或飞出动画,下一张卡片从后方升起。Animated API跑在JS线程,滑多了就卡------
@react-native-ohos/react-native-reanimated在 UI 线程跑动画,配合手势零延迟跟手。
📦 仓库地址:https://gitcode.com/CPF-RN/rntpc_react-native-reanimated | 安装:npm install @react-native-ohos/react-native-reanimated
写在前面
"心动卡"的核心交互是"Tinder 式卡片滑动":
- 用户右滑 = 喜欢,卡片向右飞出 + 淡出
- 用户左滑 = 跳过,卡片向左飞出 + 淡出
- 滑动不够远 = 弹簧回弹原位
- 卡片飞出后,下一张从后方升起 + 缩放进入
- 滑动时卡片有旋转角度 + 透明度渐变 + 背景卡片放大
这些动画的痛点:
- 手势跟手:拖拽卡片必须零延迟,慢半拍用户立刻能感觉到
- 连续动画:弹簧回弹 + 飞出 + 下一张升起,多个动画串联
- JS 线程忙:卡片里可能有图片在解码,JS 线程一忙动画就掉帧
react-native-reanimated 把动画计算移到 UI 线程 (原生层),用 SharedValue + worklet 不走 JS Bridge------60fps 丝滑,手势零延迟。
这篇文章聊什么
- 卡片拖拽------Pan 手势 + SharedValue 驱动位移旋转
- 弹簧回弹 vs 飞出------判断滑动距离决定动画
- 下一张卡片升起------进入动画 + 堆叠效果
#mermaid-svg-kImIfzMIl90QkIZE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kImIfzMIl90QkIZE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kImIfzMIl90QkIZE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kImIfzMIl90QkIZE .error-icon{fill:#552222;}#mermaid-svg-kImIfzMIl90QkIZE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kImIfzMIl90QkIZE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kImIfzMIl90QkIZE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kImIfzMIl90QkIZE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kImIfzMIl90QkIZE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kImIfzMIl90QkIZE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kImIfzMIl90QkIZE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kImIfzMIl90QkIZE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kImIfzMIl90QkIZE .marker.cross{stroke:#333333;}#mermaid-svg-kImIfzMIl90QkIZE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kImIfzMIl90QkIZE p{margin:0;}#mermaid-svg-kImIfzMIl90QkIZE .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kImIfzMIl90QkIZE .cluster-label text{fill:#333;}#mermaid-svg-kImIfzMIl90QkIZE .cluster-label span{color:#333;}#mermaid-svg-kImIfzMIl90QkIZE .cluster-label span p{background-color:transparent;}#mermaid-svg-kImIfzMIl90QkIZE .label text,#mermaid-svg-kImIfzMIl90QkIZE span{fill:#333;color:#333;}#mermaid-svg-kImIfzMIl90QkIZE .node rect,#mermaid-svg-kImIfzMIl90QkIZE .node circle,#mermaid-svg-kImIfzMIl90QkIZE .node ellipse,#mermaid-svg-kImIfzMIl90QkIZE .node polygon,#mermaid-svg-kImIfzMIl90QkIZE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kImIfzMIl90QkIZE .rough-node .label text,#mermaid-svg-kImIfzMIl90QkIZE .node .label text,#mermaid-svg-kImIfzMIl90QkIZE .image-shape .label,#mermaid-svg-kImIfzMIl90QkIZE .icon-shape .label{text-anchor:middle;}#mermaid-svg-kImIfzMIl90QkIZE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kImIfzMIl90QkIZE .rough-node .label,#mermaid-svg-kImIfzMIl90QkIZE .node .label,#mermaid-svg-kImIfzMIl90QkIZE .image-shape .label,#mermaid-svg-kImIfzMIl90QkIZE .icon-shape .label{text-align:center;}#mermaid-svg-kImIfzMIl90QkIZE .node.clickable{cursor:pointer;}#mermaid-svg-kImIfzMIl90QkIZE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kImIfzMIl90QkIZE .arrowheadPath{fill:#333333;}#mermaid-svg-kImIfzMIl90QkIZE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kImIfzMIl90QkIZE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kImIfzMIl90QkIZE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kImIfzMIl90QkIZE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kImIfzMIl90QkIZE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kImIfzMIl90QkIZE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kImIfzMIl90QkIZE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kImIfzMIl90QkIZE .cluster text{fill:#333;}#mermaid-svg-kImIfzMIl90QkIZE .cluster span{color:#333;}#mermaid-svg-kImIfzMIl90QkIZE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kImIfzMIl90QkIZE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kImIfzMIl90QkIZE rect.text{fill:none;stroke-width:0;}#mermaid-svg-kImIfzMIl90QkIZE .icon-shape,#mermaid-svg-kImIfzMIl90QkIZE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kImIfzMIl90QkIZE .icon-shape p,#mermaid-svg-kImIfzMIl90QkIZE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kImIfzMIl90QkIZE .icon-shape .label rect,#mermaid-svg-kImIfzMIl90QkIZE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kImIfzMIl90QkIZE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kImIfzMIl90QkIZE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kImIfzMIl90QkIZE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 滑动> 阈值
滑动< 阈值
用户拖拽卡片
Gesture.Pan onUpdate
SharedValue: translateX/rotate/scale
UI线程实时渲染
手指抬起
withTiming 飞出
withSpring 回弹
触发 like/pass 回调
下一张卡片 entering 动画
回到原位
第一步:安装
bash
npm install @react-native-ohos/react-native-reanimated react-native-gesture-handler
babel 配置:
js
plugins: ['react-native-reanimated/plugin']
第二步:卡片拖拽 + 旋转
用 Pan 手势驱动卡片的位移、旋转和透明度:
tsx
import { GestureDetector, Gesture } from 'react-native-gesture-handler'
import Animated, {
useSharedValue, useAnimatedStyle, withSpring, withTiming,
runOnJS, interpolate, Extrapolate,
} from 'react-native-reanimated'
const SWIPE_THRESHOLD = 120 // 滑动超过 120px 触发飞出
function SwipeableCard({ profile, onSwipe, index }: {
profile: Profile
onSwipe: (direction: 'left' | 'right') => void
index: number
}) {
const translateX = useSharedValue(0)
const translateY = useSharedValue(0)
const pan = Gesture.Pan()
.onUpdate((e) => {
translateX.value = e.translationX
translateY.value = e.translationY
})
.onEnd((e) => {
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
// 飞出:向滑动方向加速飞出
const direction = translateX.value > 0 ? 1 : -1
translateX.value = withTiming(direction * 500, { duration: 300 }, () => {
runOnJS(onSwipe)(direction > 0 ? 'right' : 'left')
})
} else {
// 回弹:弹簧回到原位
translateX.value = withSpring(0, { damping: 15, stiffness: 150 })
translateY.value = withSpring(0, { damping: 15, stiffness: 150 })
}
})
// 动画样式:位移 + 旋转 + 透明度渐变
const animatedStyle = useAnimatedStyle(() => {
const rotate = interpolate(
translateX.value,
[-200, 0, 200],
[-15, 0, 15], // 最多旋转 15 度
Extrapolate.CLAMP,
)
const opacity = interpolate(
Math.abs(translateX.value),
[0, 200],
[1, 0.5], // 滑得越远越透明
Extrapolate.CLAMP,
)
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ rotate: `${rotate}deg` },
],
opacity,
}
})
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.card, animatedStyle]}>
{/* 卡片内容:头像、昵称、简介 */}
</Animated.View>
</GestureDetector>
)
}
关键点:
onUpdate实时更新 SharedValue------手势在原生层识别,动画在 UI 线程执行,零延迟跟手rotate随位移插值------卡片拖拽时有"翻牌"的感觉opacity随位移渐变------滑得越远越透明,给用户"快飞出了"的视觉反馈
第三步:飞出 vs 回弹
手指抬起时判断滑动距离:
tsx
.onEnd((e) => {
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
// ===== 飞出 =====
const direction = translateX.value > 0 ? 1 : -1
translateX.value = withTiming(
direction * 500, // 向滑动方向飞出 500px
{ duration: 300 },
() => {
// 动画完成后回调 JS 线程
runOnJS(onSwipe)(direction > 0 ? 'right' : 'left')
},
)
} else {
// ===== 回弹 =====
translateX.value = withSpring(0, {
damping: 15, // 适当阻尼,弹得不太多
stiffness: 150, // 弹簧刚度,回弹速度
})
}
})
- 飞出 :
withTiming线性加速飞出,300ms 完成 - 回弹 :
withSpring弹簧回弹,有物理感的弹动
全程在 UI 线程执行,JS 线程哪怕正在解码下一张卡片的图片,动画也丝滑不掉帧。
第四步:下一张卡片升起(堆叠效果)
多张卡片堆叠,后面的卡片随前一张的滑动距离放大上移:
tsx
function CardStack({ profiles }: { profiles: Profile[] }) {
const [currentIndex, setCurrentIndex] = useState(0)
return (
<View style={{ flex: 1, position: 'relative' }}>
{/* 渲染当前卡片 + 后面 2 张(堆叠效果) */}
{profiles.slice(currentIndex, currentIndex + 3).reverse().map((profile, i) => {
const stackIndex = 2 - i // 0=当前, 1=下一张, 2=再下一张
return (
<BackgroundCard
key={profile.id}
profile={profile}
stackIndex={stackIndex}
/>
)
})}
{/* 最上层是可滑动的卡片 */}
{profiles[currentIndex] && (
<SwipeableCard
profile={profiles[currentIndex]}
onSwipe={() => setCurrentIndex((i) => i + 1)}
index={currentIndex}
/>
)}
</View>
)
}
// 后面的卡片随层级缩放 + 上移
function BackgroundCard({ profile, stackIndex }: { profile: Profile; stackIndex: number }) {
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ scale: 1 - stackIndex * 0.05 }, // 后面的卡片小 5%
{ translateY: stackIndex * -10 }, // 后面的卡片上移 10px
],
opacity: stackIndex === 0 ? 1 : 0.9,
zIndex: 10 - stackIndex,
}))
return <Animated.View style={[styles.card, animatedStyle]}>{/* ... */}</Animated.View>
}
为什么"心动卡"选了 Reanimated?
| 需求 | RN Animated | @react-native-ohos/react-native-reanimated |
|---|---|---|
| 手势跟手 | JS 线程,延迟 | ✅ UI 线程,零延迟 |
| JS 忙时 | ❌ 掉帧 | ✅ 不受影响 |
| 弹簧动画 | ✅ 但 JS 线程 | ✅ UI 线程弹簧 |
| 插值旋转 | ✅ | ✅ interpolate |
| 连续动画 | ❌ 难串联 | ✅ withSequence |
| 声明式进入 | ❌ | ✅ entering |
总结
"心动卡"这个场景里,@react-native-ohos/react-native-reanimated 解决了三件事:
- 零延迟跟手------Pan 手势 + SharedValue 在 UI 线程驱动,拖拽像素级跟手
- 飞出/回弹 ------
withTiming飞出 +withSpring回弹,物理感动画 - 堆叠效果------后面卡片随层级缩放上移,进入动画丝滑
如果你也在做卡片滑动、侧滑删除、拖拽排序等手势驱动的动画场景,react-native-reanimated 是 RN 鸿蒙动画性能的天花板。