HarmonyOS APP开发---“心动卡“社交匹配App,需要用到这个库

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 丝滑,手势零延迟

这篇文章聊什么

  1. 卡片拖拽------Pan 手势 + SharedValue 驱动位移旋转
  2. 弹簧回弹 vs 飞出------判断滑动距离决定动画
  3. 下一张卡片升起------进入动画 + 堆叠效果

#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 解决了三件事:

  1. 零延迟跟手------Pan 手势 + SharedValue 在 UI 线程驱动,拖拽像素级跟手
  2. 飞出/回弹 ------withTiming 飞出 + withSpring 回弹,物理感动画
  3. 堆叠效果------后面卡片随层级缩放上移,进入动画丝滑

如果你也在做卡片滑动、侧滑删除、拖拽排序等手势驱动的动画场景,react-native-reanimated 是 RN 鸿蒙动画性能的天花板。

相关推荐
●VON2 小时前
Flutter 鸿蒙插件适配实战:用 is_lock_screen 2.0.0 判断真实锁屏状态
flutter·华为·harmonyos
Sunny_G2 小时前
鸿蒙 Markdown 编辑器表格所见即所得:七个版本的渲染重构(CodeMirror Decoration 实战)
ai编程·harmonyos
linnux领域3 小时前
eNSP 交换机与电脑不在一个网段?用一台 Windows 双网卡做路由,把实验环境彻底打通
windows·华为·电脑·ensp·静态路由·网络实验·ensp模拟器
●VON3 小时前
Flutter 鸿蒙插件适配实战:用 flutter_timezone_observer 1.2.0 监听系统时区变化
flutter·华为·harmonyos
云运维笔记3 小时前
华为VRP系统文件管理全攻略
前端·华为
●VON4 小时前
Flutter 鸿蒙 app_install_date 0.1.5 使用实战:读取应用安装时间
flutter·华为·harmonyos
Dream-Y.ocean4 小时前
鸿蒙平台 Apache Tomcat 管理控制台适配实战:基于 Electron 壳方案的真实 HTTP 服务器实现
tomcat·apache·harmonyos
●VON5 小时前
Flutter 鸿蒙插件适配实战:flutter_screenshot_detect 0.1.7 截图事件监听
flutter·华为·harmonyos·鸿蒙