前言
技术文章,尤其是前端技术文章具有时效性。
如文中提到的部分内容出现breaking change或出现内容错误(文字错误/错误的理论描述),为尽可能避免对后面的读者造成困扰,如果可以的话,希望在文章的评论区或代码仓库issues中予以指正,十分感谢。
摘要
本文主要介绍了一种基于Web Animations API实现的元素斜抛动画解决方案(视觉近似,并非物理抛物公式)。
本文相比于其他方案不同的是:为了使代码结构更紧凑,复用更方便,本文没有采取@keyframes的方式声明动画,也不需要在html部分声明任何标签,而是直接采用class类的方式用JS集中处理DOM元素的挂载、样式配置、动画播放。
在使用时只需要:
const moveBall = new MoveBall({ startDom, endDom })
moveBall.freeThrow()
// 可选:duration / ballSize / color / verticalEasing
const moveBall2 = new MoveBall({
startDom,
endDom,
duration: 500,
ballSize: 30,
color: 'lightgreen',
// verticalEasing: 'cubic-bezier(0.49, -0.29, 0.75, 0.41)', // 不传则按高低差自动选择
})
moveBall2.freeThrow()
效果预览
实现思路
- 在播放时再取起点、终点坐标(避免列表滚动或布局变化后位置偏差)
- 按起止点高低差,选用两套经验
cubic-bezier缓动,与水平匀速位移合成抛物观感(并非由坐标推导贝塞尔控制点) - 动画播放完后回收DOM元素
代码实现(附用例)
// MoveBall.js
class MoveBall {
constructor({ startDom, endDom, duration, ballSize, color, verticalEasing }) {
this.startDom = startDom;
this.endDom = endDom;
this.duration = duration !== undefined ? duration : 500;
this.ballSize = ballSize !== undefined ? ballSize : 30;
this.color = color !== undefined ? color : 'lightgreen';
this.verticalEasing = verticalEasing;
}
static getCenterCoordinates(domElement) {
const rect = domElement.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
return { x: centerX, y: centerY };
}
createVerticalDom(startXy) {
const verticalDom = document.createElement('div');
verticalDom.style.position = 'fixed';
verticalDom.style.top = `${startXy.y - this.ballSize / 2}px`;
verticalDom.style.left = `${startXy.x - this.ballSize / 2}px`;
verticalDom.style.zIndex = '999';
return verticalDom;
}
createHorizontalDom() {
const horizontalDom = document.createElement('div');
horizontalDom.style.width = `${this.ballSize}px`;
horizontalDom.style.height = `${this.ballSize}px`;
horizontalDom.style.borderRadius = '50%';
horizontalDom.style.background = this.color;
return horizontalDom;
}
freeThrow() {
const startXy = MoveBall.getCenterCoordinates(this.startDom);
const endXy = MoveBall.getCenterCoordinates(this.endDom);
const offsetX = endXy.x - startXy.x;
const offsetY = endXy.y - startXy.y;
const verticalDom = this.createVerticalDom(startXy);
const horizontalDom = this.createHorizontalDom();
verticalDom.appendChild(horizontalDom);
document.body.appendChild(verticalDom);
let verticalEasing = this.verticalEasing;
if (verticalEasing === undefined) {
verticalEasing =
startXy.y < endXy.y
? 'cubic-bezier(0.49, -0.29, 0.75, 0.41)'
: 'cubic-bezier(0.2, 0, 0.8, 1.35)';
}
const verticalAnimate = verticalDom.animate(
[{ transform: `translate3d(0, ${offsetY}px, 0)` }],
{
easing: verticalEasing,
duration: this.duration,
iterations: 1,
}
);
horizontalDom.animate([{ transform: `translate3d(${offsetX}px, 0, 0)` }], {
easing: 'linear',
duration: this.duration,
iterations: 1,
});
verticalAnimate.onfinish = () => {
verticalDom.remove();
};
}
}
用例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
#d1,
#d2,
#d3 {
width: 100px;
height: 100px;
}
#d1 {
background: lightsalmon;
position: fixed;
top: 30%;
}
#d2 {
background: lightblue;
position: fixed;
top: 80%;
left: 0;
}
#d3 {
background: red;
position: fixed;
top: 50%;
left: 50%;
}
</style>
</head>
<body>
<div id="d1"></div>
<div id="d2"></div>
<div id="d3"></div>
<script src="./MoveBall.js"></script>
<script>
const d1 = document.getElementById('d1');
const d2 = document.getElementById('d2');
const d3 = document.getElementById('d3');
d1.onclick = function (e) {
let moveball = new MoveBall({
startDom: d1,
endDom: d3,
});
moveball.freeThrow();
};
d2.onclick = function (e) {
let moveball = new MoveBall({
startDom: d2,
endDom: d3,
duration: 500,
ballSize: 30,
color: 'lightgreen',
});
moveball.freeThrow();
};
</script>
</body>
</html>
斜抛观感的分解
这里的「斜抛」指的是观感上的抛物轨迹,不是按 ( \frac12 gt^2 ) 推导的物理斜抛。实现上由水平运动和垂直运动组合而成。
在水平方向上,小球做匀速的平移运动。
垂直方向上,小球运动轨迹取决于起点坐标和终点坐标的高低差;纵轴缓动使用两套预先选定的经验 cubic-bezier(可经构造参数 verticalEasing 覆盖),并不从起止坐标解控制点。
注意哈,因为getBoundingClientRect()返回值是相对于视图窗口的左上角来计算的,所以当返回的y值越大,代表这个元素视觉上处在越低的位置。
由此我们可以归纳出:
如果起点的纵坐标 小于 终点的纵坐标,则起点目标在垂直方向上高于终点目标(用例中d1)。此时轨迹观感应遵循:先反向远离起点目标再接近终点目标;默认采用偏克制、常见于仿饿了么加购的缓动cubic-bezier(0.49, -0.29, 0.75, 0.41)(via cubic-bezier(0.49, -0.29, 0.75, 0.41) ✿ cubic-bezier.com)。(举例:起点纵坐标100,终点纵坐标200,则其纵坐标变化规律类似于:100=>70=>200)
如果起点的纵坐标 大于 终点的纵坐标,则起点目标在垂直方向上低于终点目标(用例中d2)。此时轨迹观感应遵循:接近终点后再轻微过冲,再回落终点;默认缓动为cubic-bezier(0.2, 0, 0.8, 1.35)(via cubic-bezier(0.2, 0, 0.8, 1.35) ✿ cubic-bezier.com)。(举例:起点纵坐标300,终点纵坐标200,则纵坐标变化规律类似于:300=>200=>165=>200)
默认时长取 500ms,与上述缓动搭配时弧度更接近常见加购交互;若仍觉得「甩」或「肉」,可优先调 duration,再考虑覆盖 verticalEasing。
总结
实现这个效果前,我不是没查阅过用@keyframes实现的文章。但是前段时间受TweenMax GSAP 这些动画库的影响,思维形成惯性,会倾向于用JS解决问题。本文代码实现的关键在于Element.animate()这个API,掌握了这个API,实现动画效果会从容的多。
参考资料·鸣谢
Deja-vuuu/vue-ele: 🥗🥗 vue教程 --- 从0-1高仿饿了么App (github.com)
使用原生Element.animate实现文字与图片动画 - 掘金 (juejin.cn)