本文介绍如何用 Vue 3 + TypeScript 实现类似抖音的卡片滑动交互,应用于单词记忆场景。完整方案包含手势识别、堆叠动画、实时反馈和状态管理。
在线体验:m.dobell.top(手机浏览器打开)。
效果拆解
目标效果:屏幕中央一张主卡片,后方隐约可见两张堆叠卡片。手指左滑(不认识)或右滑(认识),卡片跟随手指移动并旋转,松开后飞出屏幕,下一张顶上。
核心需求:
- 3 张可见卡片(z-index 分层,缩放递减)
- Pointer Events 手势跟踪
- 滑动过程实时视觉反馈(颜色、图标、标签)
- 阈值判断 + 飞出动画 + 队列循环
卡片堆叠布局
三张卡片用 v-for 渲染,通过 index 控制层级和变形:
typescript
const stackStyle = (stackIndex: number) => {
if (stackIndex >= 3) return { display: 'none' }
const scaleMap = [1, 0.95, 0.90] // 越靠后越小
const yOffsetMap = [0, 12, 24] // 越靠后越向下
const zIndexMap = [30, 29, 28] // 越靠前越高
return {
zIndex: zIndexMap[stackIndex],
transform: `scale(${scaleMap[stackIndex]}) translateY(${yOffsetMap[stackIndex]}px)`,
transition: stackIndex === 0 && isDragging.value
? 'none' // 拖拽中不要过渡
: 'transform 0.35s ease'
}
}
数组用 ref<Word[]>([]) 存储当前队列。队列用完时自动从后端拉取下一批。
Pointer Events 手势跟踪
用 Pointer Events 而非 Touch Events,因为 Pointer Events 同时支持触屏和鼠标,方便开发调试:
typescript
const SWIPE_THRESHOLD = 72 // px,超过此值触发飞出
const isDragging = ref(false)
const dragX = ref(0)
const onPointerDown = (e: PointerEvent) => {
isDragging.value = true
startX.value = e.clientX
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
}
const onPointerMove = (e: PointerEvent) => {
if (!isDragging.value) return
dragX.value = e.clientX - startX.value
}
const onPointerUp = () => {
if (!isDragging.value) return
isDragging.value = false
if (Math.abs(dragX.value) > SWIPE_THRESHOLD) {
// 触发飞出
const direction = dragX.value > 0 ? 'right' : 'left'
flyAway(direction)
} else {
// 复位
dragX.value = 0
}
}
关键点:setPointerCapture 确保手指滑出元素边界后依然能跟踪。不用这个 API 的话,手指移出卡片范围就会丢失 tracking。
实时视觉反馈
根据拖拽距离动态计算颜色和图标透明度:
typescript
const feedbackColor = computed(() => {
const ratio = Math.min(Math.abs(dragX.value) / SWIPE_THRESHOLD, 1)
if (dragX.value > 0) {
return `rgba(34, 197, 94, ${ratio * 0.3})` // 绿色 = 认识
} else if (dragX.value < 0) {
return `rgba(239, 68, 68, ${ratio * 0.3})` // 红色 = 不认识
}
return 'transparent'
})
const checkIconOpacity = computed(() =>
dragX.value > 0 ? Math.min(dragX.value / SWIPE_THRESHOLD, 1) : 0
)
const crossIconOpacity = computed(() =>
dragX.value < 0 ? Math.min(-dragX.value / SWIPE_THRESHOLD, 1) : 0
)
拖拽距离与阈值的比值驱动所有视觉过渡。不需要动画库,computed 全搞定。
飞出动画 + 队列推进
飞出用 CSS transition + 最终位置计算:
typescript
const flyAway = (direction: 'left' | 'right') => {
const targetX = direction === 'right' ? window.innerWidth * 1.5 : -window.innerWidth * 1.5
flyX.value = targetX
flyRotation.value = direction === 'right' ? 20 : -20
// 记录结果
const result = direction === 'right' ? 'known' : 'forgotten'
submitReview(currentWord.value.id, result)
// 动画结束后移除当前词,推进队列
setTimeout(() => {
words.value.shift()
flyX.value = 0
flyRotation.value = 0
dragX.value = 0
// 队列快用完时预加载
if (words.value.length < 5) fetchMoreWords()
}, 350)
}
飞出期间新卡片已经在原来的第二张位置就位,视觉上无缝衔接。
四队列切换
高频词、大纲词、综合、错词巩固,四种模式对应不同 API 参数:
typescript
type QueueMode = 'high' | 'outline' | 'mixed' | 'wrong'
const switchQueue = async (mode: QueueMode) => {
currentMode.value = mode
words.value = []
await fetchWords(mode)
}
错词队列的每个词带有复习记录,后端根据复习次数和上次复习时间动态计算下次推送时机,实现间隔重复。
性能注意事项
- 事件监听用 passive: false :Pointer Events 需要
preventDefault防止页面滚动,所以要显式设置{ passive: false } - 动画用 transform 而非 left/top:transform 走合成层,不触发重排
- 队列预加载:剩 5 张时自动拉取,滑动体验零等待
完整代码较长,核心逻辑即上面这些。这个交互方案可以直接复用到任何需要卡片滑动判断的场景------背单词、刷题、审阅、相亲......
在线体验:m.dobell.top,打开就能看到效果。注册即送 3 天会员,月卡 29 元。