Skip to main content

Blog Rewired 🍀

Fri Mar 01 2024

利用 animate 特性制作精美的次级指针特效

效果展示

  1. 访问 https://rewired.moe
  2. 使用鼠标、触控屏等任意指针输入设备,在此页面上尝试点按、拖拽。

前置知识

如何实现

外观与排版

首先,在 HTML(此处使用 Pug 语言)中添加一个 span 用于表示次级指针:

span#click-effect

需要注意的是,最好将该元素作为 body 的 child。

然后,在 CSS(此处使用 SASS 语言)中添加类似如下的样式:

#click-effect
position: fixed // 指针永远在可见区,故不用 absolute
top: 50% // 锚点居中
left: 50% // 锚点居中
z-index: 100 // 保持指针在顶层
transform: translate(-50%, -50%) // 中心点居中
height: 0 // 初始为隐藏状态
aspect-ratio: 1 / 1 // 高度与宽度一致
border-radius: 50% // 构造圆形
box-sizing: border-box // 内描边,后续动画使用
filter: blur(1px)
border: solid 0 hotpink // 初始无描边
pointer-events: none // 令指针事件穿透

进入动画

动画部分采用 DOM 操作实现,使用了在 Baseline 2022 中的 Animation API。若要兼容旧浏览器,请考虑增加额外的判断语句,或者基于旧特性 polyfill。

一开始,可以通过 querySelector 获得先前定义的 span 的 DOM 对象:

const effect = document.querySelector("#click-effect")

我们先构造进入动画。进入动画包括「指针从上一个位置迅速移动到当前按下的位置」、「指针变大」、「不透明度逐渐增加」。需要注意的是,我们这里会使用边框来「填充」指针,这个特性后续会用到。初步代码如下:

const {left: fromX, top: fromY} = effect.getBoundingClientRect()
const {clientX: toX, clientY: toY} = event
const sliding = {
left: [`${fromX}px`, `${toX}px`],
top: [`${fromY}px`, `${toY}px`],
height: ["0", "16px"],
opacity: ["0", "20%"],
borderWidth: ["0", "8px"],
}
const timing = {
duration: 200,
fill: "forwards",
}
effect.animate(sliding, timing)

为了让指针更具有生命力,我们想让指针「弹一下再收回」。具体改动后的代码如下:

const {left: fromX, top: fromY} = effect.getBoundingClientRect()
const {clientX: toX, clientY: toY} = event
const sliding = {
left: [`${fromX}px`, `${toX}px`, `${toX}px`],
top: [`${fromY}px`, `${toY}px`, `${toY}px`],
height: ["0", "18px", "16px"],
opacity: ["0", "20%", "20%"],
borderWidth: ["0", "9px", "8px"],
offset: [0, 0.8],
}
const timing = {
duration: 240,
fill: "forwards",
}
effect.animate(sliding, timing)

值得注意的是,这里使用了 forwards 时间轴特性,使动画结束后元素保持在结束时的状态。不过,这一特性会在后续带来一点麻烦。

然后,将上述代码在 documentpointerdown 事件触发时执行。需要注意的是,「指针事件」相比 mousedown 等事件更普适,可以兼容触摸屏、数位板等多种设备。

document.addEventListener(
"pointerdown",
(event) => {
// 此处省略了进入动画代码
}
)

退出动画

退出对应着 pointeruppointercancel 两个事件。因此,在 pointerdown 触发时,我们需要注册这两个事件对应的监听器。与此同时,别忘了在退出时取消已经注册的监听器。

document.addEventListener(
"pointerdown",
(event) => {
const playPopEffect = (_) => {
// 此处省略了退出动画代码
document.removeEventListener("pointerup", playPopEffect)
document.removeEventListener("pointercancel", playPopEffect)
}
document.addEventListener("pointerup", playPopEffect)
document.addEventListener("pointercancel", playPopEffect)
// 此处省略了进入动画代码
}
)

退出动画包括「指针变大」、「不透明度减少」、「边框变细直到消失」。因为此前使用边框作为「填充」,所以也可以理解为「填充通过变大的圆形蒙版逐渐消失」。具体代码如下:

const popping = {
height: ["16px", "32px"],
opacity: ["20%", "0"],
borderWidth: ["8px", "0"],
}
const timing = {
duration: 400,
fill: "forwards",
}
effect.animate(popping, timing)

拖拽时跟随

当用户拖拽指针时,我们想让次级指针跟随拖拽动作。这一效果可以通过监听 pointermove 事件实现。一种较为直观的做法如下:

const followCursor = (event) => {
const {clientX: toX, clientY: toY} = event
effect.style.left = `${clientX}px`
effect.style.top = `${clientY}px`
}
document.addEventListener("pointermove", followCursor)

同时,别忘了在退出动画结束后注销这个监听器。

const playPopEffect = (_) => {
// 此处省略了先前介绍的代码
document.removeEventListener("pointermove", followCursor)
}

然而,这种做法实际上是不奏效的,次级指针并不会跟随拖拽。这是因为进入动画的 forwards 时间轴特性,导致元素会一直保持动画所述的样式,即使更改了 inline style。所以,我们只能使用一个无过渡的动画来实现拖拽时跟随。

const followCursor = (event) => {
const {clientX: toX, clientY: toY} = event
const sliding = {
left: [`${toX}px`, `${toX}px`],
top: [`${toY}px`, `${toY}px`],
}
const timing = {
duration: 0,
fill: "forwards",
}
effect.animate(sliding, timing)
}

到此为止,我们实现了完整的次级指针特效。

完整代码

前文提到的所有 JavaScript 代码,整合后如下:

const effect = document.querySelector("#click-effect")
document.addEventListener(
"pointerdown",
(event) => {
const followCursor = (event) => {
const {clientX: toX, clientY: toY} = event
const sliding = {
left: [`${toX}px`, `${toX}px`],
top: [`${toY}px`, `${toY}px`],
}
const timing = {
duration: 0,
fill: "forwards",
}
effect.animate(sliding, timing)
}
document.addEventListener("pointermove", followCursor)
const playPopEffect = (_) => {
const popping = {
height: ["16px", "32px"],
opacity: ["20%", "0"],
borderWidth: ["8px", "0"],
}
const timing = {
duration: 400,
fill: "forwards",
}
effect.animate(popping, timing)
document.removeEventListener("pointermove", followCursor)
document.removeEventListener("pointerup", playPopEffect)
document.removeEventListener("pointercancel", playPopEffect)
}
document.addEventListener("pointerup", playPopEffect)
document.addEventListener("pointercancel", playPopEffect)
const {left: fromX, top: fromY} = effect.getBoundingClientRect()
const {clientX: toX, clientY: toY} = event
const sliding = {
left: [`${fromX}px`, `${toX}px`, `${toX}px`],
top: [`${fromY}px`, `${toY}px`, `${toY}px`],
height: ["0", "18px", "16px"],
opacity: ["0", "20%", "20%"],
borderWidth: ["0", "9px", "8px"],
offset: [0, 0.8],
}
const timing = {
duration: 240,
fill: "forwards",
}
effect.animate(sliding, timing)
}
)

讨论

相比于基于 CSS transition 的动画特效,利用 animate 能实现更丰富和精细化控制的动画,也更方便设计者构思。

这个设计的局限性在于,当用户触摸拖拽并页面发生滚动时(或者有其他浏览器处理的事件),将不能实现拖拽时跟随。这是因为没有使用 setPointerCapture 锁定并跟踪指针。不过,由于使用 setPointerCapture 会导致浏览器忽视点击、拖拽等操作的实际意义,会导致无法点击页面上的按钮,也无法通过触摸屏滚动页面,因此不能使用。

Leave a comment

0 / 560
CAPTCHA is loading...

Comments

Loading comments...