Uniapp实现超酷的电商小程序加入购物车动画特效开发实战教程
书接上篇,我们今天继续来学习一下 UniApp 聊一个电商系统常见的话题:商品飞入购物车动画特效,附带实现思路和所有源码示例,以及常见避坑指南和开发实践。
此系列文章将带领你从移动端跨平台开发入门到精通,如果你也喜欢关注APP、小程序、公众号、H5等等应用的开发,可以持续关注后续更新,避免错过宝贵的知识分享。
致开发者的忠告: AI编程盛行的今天,我们并不是不需要学习技术,而是更应该专研技术,拥有把控全局的架构设计思维才能在AI盛行的未来有立足之地。
言归正传,咱们今天继续来聊一聊电商APP中使用Uniapp实现加入购物车动画组件原理的深度剖析:从原理到实践,让商品“飞”进购物车,H5/App/微信小程序都支持。
大家好,我是你们的技术前辈。今天我们来聊一个电商项目里非常常见、也非常能提升用户体验的小功能:加入购物车抛物线动画。
你肯定见过:在商品列表页,点击商品右下角的“加购”按钮,会有一个小圆点(或商品图标)从点击位置划出一道优美的抛物线,最终落到底部购物车图标上。这个小特效不仅让交互更生动,还能给用户明确的反馈:“商品已加入购物车”。
很多同学想自己实现,但往往会遇到:
-
小球飞歪了,没落在购物车上。
-
页面滚动后,起点/终点计算错乱。
-
连续点击时动画互相干扰。
-
不知道如何封装成可复用的组件。
-
最头疼的是:好不容易在H5跑通了,到微信小程序里直接报错(因为用了
document.createElement)。
别担心,今天我就带大家一步步从原理到实践,实现一个真正跨端(H5/App/微信小程序)的“飞入购物车”动画组件。我们会采用一种非常巧妙的 纯CSS动画方案,性能好、代码简洁,还能完美解决坐标计算问题,并且完全基于 Vue/uni-app 的响应式系统,不依赖任何平台特有的DOM API。
一、核心原理:用 CSS 动画画出抛物线
1.1 抛物线运动的分解
抛物线运动可以分解为两个方向:
-
水平方向:匀速直线运动(速度恒定)。
-
竖直方向:匀加速直线运动(受重力影响)。
在 CSS 中,如果我们想对一个元素同时应用不同的运动规律,一个元素做不到(因为 transform 是一个整体属性,只能应用一种时间函数)。但我们可以用嵌套元素来巧妙解决:
-
外层元素控制竖直运动,应用
cubic-bezier缓动(模拟重力加速度)。 -
内层元素控制水平运动,应用
linear缓动(匀速)。
两个元素同时运动,组合起来就是完美的抛物线。最终你看到的小球位置 = 外层的垂直位移 + 内层的水平位移。
1.2 缓动函数的选择
-
竖直下落:
cubic-bezier(.22, 1.4, 1, 1)是一个非常自然的“加速下落”曲线(起始慢,然后加速),很像小球受重力作用。 -
水平匀速:
linear。
1.3 用 CSS 变量传递位移
每个小球的起点和终点不同,所以水平和垂直位移 dx 和 dy 是动态的。我们可以通过 CSS 变量 --x 和 --y 将位移值传递给动画,这样动画定义就可以复用:
@keyframesmoveX {
to { transform: translateX(var(--x)); }
}
@keyframesmoveY {
to { transform: translateY(var(--y)); }
}
外层元素应用 moveY,内层元素应用 moveX,并通过 style 属性设置 --x 和 --y。
二、坐标计算:如何精准获取起点和终点?
这是整个组件最关键的环节。如果坐标不准,动画就会飞歪。
2.1 起点的获取
当用户点击加购按钮时,我们可以从事件对象中获取点击点相对于视口的坐标。
-
在 uni-app 中,标准事件对象包含
detail: { x, y },这俩就是相对于视口的坐标。 -
如果是原生触摸事件,可以从
touches[0]中获取clientX/clientY。
为什么用视口坐标?因为购物车图标通常是 position: fixed 固定定位,它的边界也是相对于视口的。两者坐标系一致,完全不受页面滚动的影响。这就省去了处理滚动偏移的麻烦。
2.2 终点的获取
我们需要找到购物车图标的位置。使用 uni.createSelectorQuery() 查询元素:
constquery=uni.createSelectorQuery().in(this)
query.select('#cart-icon').boundingClientRect(rect=> {
if (rect) {
constendX=rect.left+rect.width/2
constendY=rect.top+rect.height/2
// 使用终点坐标
}
}).exec()
这里 rect.left 和 rect.top 也是相对于视口的,所以 endX/endY 也是视口坐标。
注意事项:
-
查询操作必须在元素渲染完成后进行,通常在点击时查询(此时元素一定存在)。
-
确保选择器能唯一定位到购物车元素(使用 id 是最稳妥的)。
2.3 位移计算
得到起点 (startX, startY) 和终点 (endX, endY) 后,位移就很简单了:
-
dx = endX - startX -
dy = endY - startY
然后将这两个值通过 CSS 变量设置给小球。
三、跨端实现:如何动态创建小球且不依赖 DOM API?
如果使用 document.createElement,在微信小程序中会报错,因为小程序没有 DOM API。所以我们必须换一种思路:利用 Vue 的响应式渲染。
-
在组件模板中,用
v-for渲染一组小球。 -
维护一个响应式数组
balls,每个小球包含起点坐标、位移量等信息。 -
点击加购时,向数组
push一个新小球,它就会自动渲染并开始动画。 -
动画结束后,通过
@animationend事件从数组中移除该小球。
这样,所有小球的创建和销毁都通过 Vue 的数据驱动完成,完美避开平台差异。而获取坐标、计算位移等逻辑仍然使用 uni API,在所有平台通用。
四、组件设计:我们要做一个怎样的组件?
基于上述原理,我们来设计一个通用组件 FlyToCart,它应该具备以下特点:
-
易用:父组件只需传入购物车元素的选择器,并在点击时调用
start方法传入事件对象即可。 -
健壮:自动计算起点和终点,处理坐标异常。
-
多小球支持:连续点击时,多个小球可以同时存在,互不干扰。
-
精准结束:动画结束后自动销毁小球,避免内存泄漏。
-
可定制:支持自定义小球颜色、大小、图标等。
4.1 Props
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| cartSelector | string | 是 | – | 购物车元素的选择器(如 ‘#cart-icon’) |
| duration | number | 否 | 400 | 动画时长(毫秒) |
| ballColor | string | 否 | ‘#fb603d‘ | 小球背景色 |
| ballSize | number | 否 | 20 | 小球尺寸(px) |
| ballIcon | string | 否 | ” | 小球图标的类名(如 ‘i-material-symbols-add’) |
| zIndex | number | 否 | 9999 | 小球层级 |
4.2 Methods
| 方法名 | 参数 | 说明 |
|---|---|---|
| start | clickEvent: Event | 开始动画,传入点击事件对象 |
4.3 Events
| 事件名 | 说明 |
|---|---|
| start | 动画开始时触发 |
| end | 动画结束时触发 |
| error | 发生错误时触发(如无法获取坐标、找不到购物车) |
五、完整代码实现
下面是组件完整代码,已经过测试,可在 H5、App、微信小程序中正常运行。
<!-- components/FlyToCart.vue -->
<template>
<!-- 固定容器,用于承载所有飞行的小球,不拦截点击事件 -->
<view
class="fly-cart-container"
:style="{
position: 'fixed',
left: 0,
top: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: zIndex
}"
>
<!-- 遍历渲染所有小球 -->
<view
v-for="ball in balls"
:key="ball.id"
class="fly-ball-outer"
:style="{
left: ball.startX + 'px',
top: ball.startY + 'px',
animation: `moveY ${duration}ms cubic-bezier(0.22, 1.4, 1, 1) forwards`,
'--y': ball.dy + 'px',
zIndex: zIndex
}"
>
<!-- 内层:小球本体 + 水平运动 -->
<view
class="fly-ball-inner"
:style="{
width: ballSize + 'px',
height: ballSize + 'px',
backgroundColor: ballColor,
animation: `moveX ${duration}ms linear forwards`,
'--x': ball.dx + 'px'
}"
@animationend="onAnimationEnd(ball.id)"
>
<!-- 图标(可选) -->
<textv-if="ballIcon":class="ballIcon"></text>
</view>
</view>
</view>
</template>
<scriptsetuplang="ts">
import { ref, getCurrentInstance } from'vue'
constprops=defineProps({
cartSelector: {
type: String,
required: true
},
duration: {
type: Number,
default: 400
},
ballColor: {
type: String,
default: '#fb603d'
},
ballSize: {
type: Number,
default: 20
},
ballIcon: {
type: String,
default: ''
},
zIndex: {
type: Number,
default: 9999
}
})
constemit=defineEmits(['start', 'end', 'error'])
// 存储所有活跃的小球
interfaceBall {
id: string|number
startX: number
startY: number
dx: number
dy: number
}
constballs=ref<Ball[]>([])
constinstance=getCurrentInstance()
// 生成唯一ID(简单处理)
letidCounter=0
constgenerateId= () =>`ball-${Date.now()}-${idCounter++}`
/**
* 开始动画(外部调用)
* @param clickEvent 点击事件对象
*/
conststart= (clickEvent: any) => {
// 1. 获取起点坐标(兼容多种事件格式)
letstartX: number, startY: number
if (clickEvent.detail&&typeofclickEvent.detail.x==='number') {
// uni-app 标准事件
startX=clickEvent.detail.x
startY=clickEvent.detail.y
} elseif (clickEvent.touches) {
// 触摸事件
startX=clickEvent.touches[0].clientX
startY=clickEvent.touches[0].clientY
} elseif (clickEvent.clientX!==undefined) {
// 鼠标事件
startX=clickEvent.clientX
startY=clickEvent.clientY
} else {
console.error('无法获取点击坐标')
emit('error', '无法获取点击坐标')
return
}
// 2. 获取购物车位置
constquery=uni.createSelectorQuery().in(instance?.proxy)
query.select(props.cartSelector).boundingClientRect((rect: any) => {
if (!rect) {
console.error('未找到购物车元素')
emit('error', '未找到购物车元素')
return
}
constendX=rect.left+rect.width/2
constendY=rect.top+rect.height/2
// 3. 计算位移
constdx=endX-startX
constdy=endY-startY
// 4. 创建新小球
constnewBall: Ball= {
id: generateId(),
startX,
startY,
dx,
dy
}
balls.value.push(newBall)
emit('start')
}).exec()
}
// 动画结束处理
constonAnimationEnd= (id: string|number) => {
// 从数组中移除对应小球
balls.value=balls.value.filter(ball=>ball.id!==id)
emit('end')
}
// 暴露方法给父组件
defineExpose({ start })
</script>
<stylescoped>
/* 关键帧定义 */
@keyframesmoveX {
to {
transform: translateX(var(--x));
}
}
@keyframesmoveY {
to {
transform: translateY(var(--y));
}
}
/* 小球外层样式(垂直运动) */
.fly-ball-outer {
position: fixed; /* 相对于视口定位 */
pointer-events: none; /* 防止遮挡点击 */
}
/* 小球内层样式(水平运动 + 本体) */
.fly-ball-inner {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
/* 宽高、背景色、动画等已通过内联 style 设置,此处无需重复 */
}
</style>
代码说明:
-
完全响应式:所有小球通过
balls数组驱动渲染,无需手动操作 DOM。 -
跨端兼容:
v-for、@animationend在 H5/App/小程序中都支持。 -
精准坐标:起点从事件获取,终点通过
uni.createSelectorQuery获取,均使用视口坐标系,不受滚动影响。 -
动画结束自动清理:每个小球动画结束后触发
@animationend,从数组中移除,避免内存泄漏。 -
多小球并发:每个小球独立,互不干扰。
六、在页面中使用示例
下面是一个商品列表页的完整示例,包含可滚动的商品列表、底部固定购物车,以及调用动画组件。
<!-- pages/goods/list.vue -->
<template>
<viewclass="goods-page">
<!-- 商品列表,可滚动 -->
<scroll-viewscroll-yclass="goods-scroll">
<viewv-for="item in goodsList":key="item.id"class="goods-item">
<image:src="item.image"class="goods-image"/>
<viewclass="goods-info">
<textclass="goods-name">{{ item.name }}</text>
<textclass="goods-price">¥{{ item.price }}</text>
</view>
<!-- 加购按钮,传入 $event 和商品数据 -->
<viewclass="add-btn"@click="onAdd($event, item)">
<uni-iconstype="cart"size="20"color="#fff"/>
</view>
</view>
</scroll-view>
<!-- 底部固定购物车图标(作为终点) -->
<viewclass="cart-icon"id="cart-icon">
<uni-iconstype="cart-filled"size="28"color="#ff5500"/>
<textv-if="cartCount"class="cart-badge">{{ cartCount }}</text>
</view>
<!-- 引入动画组件,传入购物车选择器 -->
<FlyToCart
ref="flyCart"
cartSelector="#cart-icon"
ballColor="#ff5500"
ballIcon="i-material-symbols-add"
@end="onFlyEnd"
@error="onFlyError"
/>
</view>
</template>
<scriptsetuplang="ts">
import { ref } from'vue'
importFlyToCartfrom'@/components/FlyToCart.vue'
// 模拟商品数据
constgoodsList=ref([
{ id: 1, name: '商品1', price: 99, image: '/static/goods1.png' },
{ id: 2, name: '商品2', price: 199, image: '/static/goods2.png' }
])
constcartCount=ref(0)
constflyCart=ref<InstanceType<typeofFlyToCart>>()
// 加入购物车处理
constonAdd= (event: any, item: any) => {
// 触发动画
flyCart.value?.start(event)
// 实际加入购物车逻辑(请求后端等)
addToCart(item)
}
constaddToCart= (item: any) => {
cartCount.value++
// 可发送请求等
}
constonFlyEnd= () => {
console.log('动画结束')
// 可选:震动反馈
// uni.vibrateShort({ type: 'light' })
}
constonFlyError= (err: string) => {
console.error('动画错误', err)
// 降级提示:至少告诉用户加入成功
uni.showToast({ title: '已加入购物车', icon: 'success' })
}
</script>
<stylescopedlang="scss">
.goods-page {
height: 100vh;
position: relative;
}
.goods-scroll {
height: calc(100vh - 120rpx);
}
.goods-item {
display: flex;
padding: 20rpx;
border-bottom: 1rpxsolid#eee;
position: relative;
}
.add-btn {
position: absolute;
right: 20rpx;
bottom: 20rpx;
width: 60rpx;
height: 60rpx;
background-color: #ff5500;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.cart-icon {
position: fixed;
bottom: 30rpx;
right: 30rpx;
width: 100rpx;
height: 100rpx;
background-color: #fff;
border-radius: 50%;
box-shadow: 04rpx20rpxrgba(0,0,0,0.1);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.cart-badge {
position: absolute;
top: 0;
right: 0;
background-color: #ff5500;
color: #fff;
font-size: 20rpx;
padding: 4rpx8rpx;
border-radius: 20rpx;
}
</style>
七、常见问题与解决方案
❌ 问题1:小球起点偏移(没从点击位置开始)
原因:点击事件坐标获取错误,或小球定位时误加了滚动偏移。解决方案:确认使用的是 event.detail.x/y(uni-app 标准事件)或 clientX/clientY(原生事件),它们已经是相对于视口的坐标。不要手动加上 scrollTop。
❌ 问题2:小球没有落在购物车中心
原因:终点坐标计算错误,或购物车元素查询失败。解决方案:
-
确保购物车元素有唯一的 id,选择器正确(如
#cart-icon)。 -
在
boundingClientRect回调中打印rect检查是否获取到正确尺寸。 -
如果购物车元素是
v-if控制,确保点击时它已存在。
❌ 问题3:连续快速点击,小球重叠或动画错乱
原因:多个小球同时存在,但动画名称相同,会不会冲突?答案:不会。每个小球的位移由自己的 CSS 变量 --x 和 --y 控制,即使动画名称相同,也是独立计算。完全支持并发。
❌ 问题4:动画结束后小球残留
原因:@animationend 事件未触发,可能是动画被中断或监听错误。解决方案:可添加 setTimeout 兜底移除,但一般情况下 @animationend 可靠。如果残留,检查小球是否被意外销毁或样式冲突。
❌ 问题5:小程序中 pointer-events: none 无效
原因:微信小程序中,pointer-events 支持有限,但在 view 上应该有效。如果无效,可能会导致小球遮挡点击。可以在外层容器上设置 position: fixed 且无背景,通常不会遮挡,因为小球本身有 pointer-events: none。如果仍有问题,可以尝试将容器设为 position: absolute,并设置 width/height 为 100%,但 fixed 更稳定。
❌ 问题6:小程序中获取不到购物车位置(返回0)
原因:查询时元素可能尚未渲染完成。解决方案:在点击时查询,此时元素肯定已经渲染。如果还是不行,可以加一个极短的延时(setTimeout 0)再查询。
❌ 问题7:H5 中动画结束后小球没有移除
原因:可能是 @animationend 事件未触发,可尝试用 @webkitAnimationEnd 作为补充。在组件中我们可以同时监听两个事件,或者使用 animationend(现代浏览器都支持)。如果仍然有问题,可以使用 setTimeout 兜底。
八、总结与扩展
今天我们实现了一个真正跨端的加入购物车抛物线动画组件,核心优势:
-
纯CSS动画:性能好,利用硬件加速。
-
跨端兼容:不依赖特定平台API,所有逻辑均使用 uni-app 标准能力。
-
易用性:父组件只需传入购物车选择器,点击时调用
start并传入事件对象即可。 -
健壮性:自动计算坐标,支持多小球并发,动画结束自动清理。
你可以直接复制这份代码到你的项目中,几行代码就能拥有丝滑的加入购物车特效。如果想更炫酷,还可以:
-
用商品小图代替小球(将
ballIcon改为图片)。 -
添加音效(在
end事件中播放)。 -
支持多终点(比如多个购物车图标)。
希望这篇教程能帮你在项目中轻松实现这个有趣的小特效。如果遇到问题,欢迎随时交流!
—— 一个喜欢研究交互动效的老前辈 🛒
加油,未来的全栈大佬!💪如果你也对移动端跨端开发感兴趣,关注我,后续还有更多优质文章分享!


往期相关文章推荐
夜雨聆风
