日常中隐藏的动画
在日常的开发中,动画其实充斥在各个角落中,但它们基本都隐藏于我们日常使用的第三方库中,我们也基本不会主动去关注这部分,因为它们通常能够满足所有的日常所需
比如 ant-design
中对于 <Button/>
的波浪效果,<Tree/>
的平滑折叠,再比如所有 UI 库几乎都会提供的,折叠组件,手风琴效果等
简单动画"能动起来"的底层实现理论
动画比较常见的种类个人感觉都可以总结成:渐进式变化类, 它们都属于是将某些元素,让其从某个状态,平滑的过度到另一个状态,或者是有多个阶段,然后分别过度过去
在实现上可以分为,CSS/JS
两种实现
对于复杂的特效这部分基本非 Js
莫属,它应该由更专业的第三方提供支持,比如 gasp
对于简单的动画则应该选择 CSS
,因为它的性能更佳,而 CSS
的动画大致就是用各种属性结合 transition,animate
属性做文章
虽然这里我将它叫做 CSS
动画,这不意味着就全都是通过 CSS
实现,而是说,实现这种动画的核心应该是围绕着 CSS
展开的
动画上的挑战 与 拆解
动画往往不仅仅是将某个盒子,做做平移,缩放下的事,这种复杂度只能算基础,它比简单还简单
实际上的所谓简单动画是
- 将各种这些基础的技巧进行不同的搭配组合
- 应用的对象大概率是一坨子,这一坨子还有层级,比如对多层级的
div
应用不同的动画 - 动画之间存在衔接和接替,比如前一个结束了才能开始下一个
为了更好的理解这里面之间的关系,和解决这些概念,我们可以把它们抽象提取成以下问题
- 对单个对象做乱七八糟的动画
- 对2个对象做乱七八糟的动画
- 对列表做乱七八糟的动画
对于不同数量的对象做动画,要思考的点和面临的问题绝大多数是不一样的
而对于没提到的怎么处理动画之间的衔接和搭配,这在单个对象上也能做。比如写两个 animate
动画,想办法让它们按照某种顺序和规则执行
2个对象的动画
单个对象的和这类动画应该是最最常见的,2个对象的动画往往伴随着切换的行为
比如 1 个数字变成了另一个,那么旧的要做渐隐,新的要做渐入
比如让某个元素跟随鼠标移动,需要做到一种拖尾现象,这里和上面的几乎一样,旧的要做渐隐,新的要做渐入,然后渐变的过程持续时间长点,过程中要连续触发下就会形成视觉上的拖尾
这类动画做的越高级,视觉感受上就会越像是列表动画,往往会感觉到有超过2个以上的元素在动一样,原则上是要想明白,对于动画的切换为主的运用
对于 2 个对象的动画个人感觉难点更多的不是代码怎么写,而是需要巧思,即使代码能力再强,但如果想不到那就怎样的都写不出,从难点上来说,个人感觉它是简单动画中最最复杂的,除了大量积累相关的经验没什么好办法,也提炼不出来什么特别通用的技巧
列表动画
列表动画在数量上比 2 个对象的动画多了很多,但复杂度上往往要小很多,因为实际上我们对于列表动画的需求,都可以分为以下三类来作文章
- 新增元素
- 删除元素
- 移动元素
列表上如果引用了复杂的动画很容易让页面的行为变得非常奇怪,所以在实现上不会追求复杂,而是在追求简单达到效果,最最常见就是引用各种,可见度opacity
和 各种平移 的变化。而这是我们学习动画最开始就会接触的效果,所以思考的重心就变成了,应该怎样让不同子元素在动画的同时,能够看起来很协调
如果我们使用了框架,比如 Vue/React
的话,有些人会认为纯 js
操作各种属性不太适合,那么如果依赖框架的行为做动画,这里还得思考怎么排除掉框架更新带来的不可控因素
在做列表动画时就不得不提一个很通用的实现技巧,FLIP
,这个词我第一次知道是在 Vue
官网上看到的,用中文解释的大概意思就是
将动画反方向播放
比如我们想要将一个盒子,位置坐标是 (0,0)
,向右边平移 100px
,意味着需要在盒子的当前位置上,加上向右平移的距离,最终位置就是 (100, 0)
。而反方向播放是指,先将盒子放到最终位置 (100, 0)
,然后通过样式让它回到开始的位置 (0, 0)
,在让它回到(100, 0)
这样做的好处是,动画不害怕被打断,因为动画永远是从正确的,最终的位置开始计算。过程中可能会因为动画被打断变得乱七八糟,但结果一定是正确的。这对于经常写动画的人来说应该是深有体会的,因为大家应该都体会过,在使用框架 React/Vue
时,动画的过程中发生了响应式更新,元素不是没了就飞了的效果
FLIP 应用于列表的实现技巧
在实现套路上,比较求稳的方式是,我们能将动画不同的时间拆分成以下阶段
enter-from
进入前enter-from-active
正在进入的过程中enter-to
进入后leave-from
离开前leave-from-active
正在离开的过程中leave-to
离开后move
正在移动中
每个阶段都是一个 class
,过程中主要是给加上过度效果 transition
,然后一前一后就字面意思,这个过程涵盖了列表动画的所有的生命周期阶段,当所有阶段结束后记得把动画加的临时属性去掉即可
写成代码这里有个简陋的 demo
,可以结合更下方的代码行为理解
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<style>
.list-item-enter-active,
.list-item-leave-active,
.list-item-move {
transition: all 0.5s ease;
}
.list-item-enter-from,
.list-item-leave-from {
opacity: 1;
transform: translateX(0px);
}
.list-item-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.list-item-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>
<div>
<!-- 通过 addItem 把输入的内容添加到下面 -->
<input type="text" id="newItemInput" autocomplete="off" />
<button id="addItem">Add</button>
<!-- 洗牌操作,只有交换 -->
<button id="shuffle">shuffle</button>
<!-- 混合洗牌,同时存在 新增,移动,删除 -->
<button id="mixinShuffle">mixinShuffle</button>
</div>
<ol id="itemList"></ol>
<script type="module" src="./index.js"></script>
</body>
</html>
class AnimatedList {
constructor() {
this.element = document.querySelector("#itemList")
this.items = Array.from(document.querySelectorAll(".list-item"))
this.init()
}
init() {
// 绑定一些基础属性
document.getElementById("addItem").addEventListener("click", () => {
const value = document.getElementById("newItemInput").value.trim()
if (value) {
this.addItem(value)
document.getElementById("newItemInput").value = ""
}
})
document.querySelector("#shuffle").onclick = () => {
this.shuffle()
}
document.querySelector("#mixinShuffle").onclick = () => {
// 使用纯 css 实现,繁琐
// this.mixinShuffle();
// 使用 浏览器API,简洁
this.mixinShuffle1()
}
}
addItem(value) {
// 创建子元素
const itemElement = document.createElement("li")
itemElement.textContent = value
itemElement.classList.add("list-item", "list-item-enter-from")
itemElement.style.cursor = "pointer"
// 先添加进入,变成进入前的样式
this.element.appendChild(itemElement)
// 在下一帧,让元素进入到过渡中的状态
// 并设置 enter-to 让其开始过度
requestAnimationFrame(() => {
itemElement.classList.add("list-item-enter-active")
itemElement.classList.add("list-item-enter-to")
itemElement.classList.remove("list-item-enter-from")
})
// 过度结束后清理没用的属性
itemElement.addEventListener(
"transitionend",
() => {
itemElement.classList.remove(
"list-item-enter-to",
"list-item-enter-active"
)
},
{ once: true }
)
// 添加一个点了能删除自身的回调
itemElement.addEventListener("click", e => {
this.removeItem(e.currentTarget)
})
}
removeItem(itemElement) {
// 设置删除前的样式
itemElement.classList.add("list-item-leave-from", "list-item-leave-active")
// 在下一帧,开始过度
requestAnimationFrame(() => {
itemElement.classList.remove("list-item-leave-from")
itemElement.classList.add("list-item-leave-to")
})
// 过度完成后删除自身
itemElement.addEventListener(
"transitionend",
() => {
itemElement.remove()
},
{ once: true }
)
}
shuffle() {
const children = Array.from(this.element.children)
const prePosition = {}
// 先记录移动前的位置
// 设置个临时属性,用来做后边查找变化前的元素是哪个
children.forEach((ch, i) => {
ch.setAttribute("data-key", i)
prePosition[i] = ch.getBoundingClientRect()
})
// 给元素随便排个顺序
children.forEach((e, i) => {
const to = Math.floor(Math.random() * (children.length - 1))
if (to !== i) {
this.element.insertBefore(e, children[to])
}
})
children.forEach(ch => {
// 通过上边的自定义属性能够拿到之前的位置
const pRect = prePosition[ch.dataset.key]
// 获取当前的位置
const rect = ch.getBoundingClientRect()
// 拿到(如果需要)移动的插值
// 因为此时界面上已经是最终的位置,这里的插值是在算,从最终位置还原到初始为止的距离,所以的 前-后
const diffX = pRect.x - rect.x
const diffY = pRect.y - rect.y
// 如果不存在移动就跳过不操作
if (diffX === 0 && diffY === 0) {
return
}
// 确保还原到原始为止时,这个效果是立马完成,不能存在过度效果
ch.classList.remove("list-item-move")
ch.style.transition = null
// 让元素立马回到原始为止
ch.style.transform = `translate(${diffX}px, ${diffY}px)`
// 下一帧时,开始过度
requestAnimationFrame(() => {
ch.classList.add("list-item-move")
ch.style.transform = `translate(0px, 0px)`
})
ch.addEventListener(
"transitionend",
() => {
ch.classList.remove("list-item-move")
ch.style.transform = null
},
{ once: true }
)
})
}
mixinShuffle() {
const children = Array.from(this.element.children)
// 删除
const delTargetIndex = Math.floor(Math.random() * (children.length - 1))
const delTarget = children[delTargetIndex]
delTarget.setAttribute("data-index", "-1")
delTarget.style.transition = null
delTarget.classList.add("list-item-leave-from")
requestAnimationFrame(() => {
delTarget.classList.remove("list-item-leave-from")
delTarget.classList.add("list-item-leave-active", "list-item-leave-to")
})
delTarget.addEventListener(
"transitionend",
() => {
this.element.removeChild(delTarget)
},
{ once: true }
)
// 移动
const prePosition = {}
children.forEach((ch, i) => {
if (ch.dataset.index === "-1") {
return
}
ch.setAttribute("data-key", i)
prePosition[i] = ch.getBoundingClientRect()
})
children.forEach((e, i) => {
if (e.dataset.index === "-1") {
return
}
const to = Math.floor(Math.random() * (children.length - 1))
if (to !== i) {
this.element.insertBefore(e, children[to])
}
})
children.forEach(ch => {
if (ch.dataset.index === "-1") {
return
}
const pRect = prePosition[ch.dataset.key]
const rect = ch.getBoundingClientRect()
const diffX = pRect.x - rect.x
const diffY = pRect.y - rect.y
if (diffX === 0 && diffY === 0) {
return
}
ch.classList.remove("list-item-move")
ch.style.transition = null
ch.style.transform = `translate(${diffX}px, ${diffY}px)`
requestAnimationFrame(() => {
ch.classList.add("list-item-move")
ch.style.transform = `translate(0px, 0px)`
})
ch.addEventListener(
"transitionend",
() => {
ch.classList.remove("list-item-move")
ch.style.transform = null
},
{ once: true }
)
})
// 新增
const itemElement = document.createElement("li")
itemElement.textContent = Math.random()
itemElement.classList.add("list-item", "list-item-enter-from")
this.element.insertBefore(itemElement, this.element.children[0])
itemElement.classList.add("list-item-enter-active")
requestAnimationFrame(() => {
itemElement.classList.remove("list-item-enter-from")
itemElement.classList.add("list-item-enter-to")
})
itemElement.addEventListener(
"transitionend",
() => {
itemElement.classList.remove(
"list-item-enter-to",
"list-item-enter-active"
)
},
{ once: true }
)
}
mixinShuffle1() {
const children = Array.from(this.element.children)
// 删除
const delTargetIndex = Math.floor(Math.random() * (children.length - 1))
const delTarget = children[delTargetIndex]
delTarget
.animate(
[
{ transform: "translateX(0px)", opacity: 1 },
{ transform: "translateX(30px)", opacity: 0 }
],
{ duration: 500, easing: "ease", fill: "forwards" }
)
.finished.then(() => {
delTarget.remove()
})
// 移动
const prePosition = {}
children.forEach((ch, i) => {
if (ch.dataset.index === "-1") {
return
}
ch.setAttribute("data-key", i)
prePosition[i] = ch.getBoundingClientRect()
})
children.forEach((e, i) => {
if (e.dataset.index === "-1") {
return
}
const to = Math.floor(Math.random() * (children.length - 1))
if (to !== i) {
this.element.insertBefore(e, children[to])
}
})
children.forEach(ch => {
if (ch.dataset.index === "-1") {
return
}
const pRect = prePosition[ch.dataset.key]
const rect = ch.getBoundingClientRect()
const diffX = pRect.x - rect.x
const diffY = pRect.y - rect.y
if (diffX === 0 && diffY === 0) {
return
}
ch.animate(
[
{ transform: `translateY(${diffY}px)` },
{ transform: `translateY(0px)` }
],
{ duration: 500, easing: "ease" }
)
})
// 新增
const itemElement = document.createElement("li")
itemElement.textContent = Math.random()
this.element.insertBefore(itemElement, this.element.children[0])
itemElement.animate(
[
{ transform: "translateX(-30px)", opacity: 0 },
{ transform: "translateX(0px)", opacity: 1 }
],
{ duration: 500, easing: "ease", fill: "forwards" }
)
}
}
new AnimatedList()
代码行为解释
- 为了能以更好的性能快速开始动画,所以可以多使用
requestAnimationFrame
。浏览器会对不同帧进行动画过度,帧是浏览器的 fps,通常是一分钟 60帧,requestAnimationFrame
可以将代码放到下一帧绘制前执行 - 浏览器提供了
transitionend
事件的监听,在这里可以很方便的知道什么时候结束了,然后第一时间清理掉无用的临时属性 - 使用
js
控制过程会很繁琐,使用animate()
函数可以让过程控制的更轻松,常用的属性,大部分浏览器也都支持了