1. 前言
以前在看微信視訊号直播的時候,經常點選右下角的點贊按鈕。看着它的數字慢慢從一位數變成五位數,還是挺有氛圍感的。特别是長按的時候,有個手機震動的回報,很帶感。
雖然之前很好奇這些飄動的點贊動效是怎麼實作的,但沒有特别去鑽研。直到前陣子投入騰訊課堂 H5 直播間的需求,需要自己去實作一個這樣的效果時,才開始摸索。
先看看最後的效果:
相比視訊号的點贊動效,軌迹複雜了很多。可以看到課堂直播間的這一段點贊動效,大概分為這麼三個階段:
- 從無到有,在上升過程中放大成正常大小
- 上升過程中左右搖曳,且搖曳的幅度随機
- 左右搖曳上升的過程中,漸隐并縮小
在動手之前,我先想到了使用 CSS animation 去實作這種運動軌迹。在完成之後,又用 Canvas 重構了一版,優化了性能。
接下來我們分别來看看這兩種實作方式。
2. CSS 實作點贊動效
2.1 軌迹分析
由于點贊動畫是在一個二維平面上的,我們可以将它的運動軌迹拆分為 x 軸 和 y 軸 上的兩段。
在 y 軸 上非常簡單,我們的點贊圖示會做一段垂直上升的勻速運動,從容器底部上升到容器頂部。
而 x 軸 上是左右搖曳的,用數學的角度說,是一段簡諧運動。
但用 css 實作的時候,其實不用這麼精細。為了簡化計算,我們可以用幾個關鍵幀來串聯這段運動軌迹,例如:
@keyframes bubble_swing {
0% {
中間
}
25% {
最左
}
75% {
最右
}
100% {
中間
}
}
2.2 軌迹設計
根據上面的分析,我們可以設計一段相同的上升軌迹,以及幾段不同的左右搖曳軌迹。
上升軌迹很簡單,同時我們還可以加上透明度(opacity)、大小(transform)的變化,如下:
@keyframes bubble_y {
0% {
transform: scale(1);
margin-bottom: 0;
opacity: 0;
}
5% {
transform: scale(1.5);
opacity: 1;
}
80% {
transform: scale(1);
opacity: 1;
}
100% {
margin-bottom: var(--cntHeight);
transform: scale(0.8);
opacity: 0;
}
}
其中,--cntHeight 指的是容器的高度。也就是說,我們通過讓 margin-bottom 不斷增大,來控制點贊圖示從容器底部上升到容器頂部。
而對于橫向運動的軌迹,為了增加運動軌迹的多樣性,我們可以設計多段左右搖曳的軌迹,比如說一段 “中間 -> 最左 -> 中間 -> 最右” 的軌迹:
@keyframes bubble_swing_1 {
0% {
// 中間
margin-left: 0;
}
25% {
// 最左
margin-left: -12px;
}
75% {
// 最右
margin-left: 12px;
}
100% {
margin-left: 0;
}
}
這裡同樣使用 margin 來控制圖示的左右移動。類似的,我們還可以設計幾段别的軌迹:
// 任意軌迹
@keyframes bubble_swing_2 {
0% {
// 中間
margin-left: 0;
}
33% {
// 最左
margin-left: -12px;
}
100% {
// 随機位置
margin-left: 6px;
}
}
// 簡諧反向
@keyframes bubble_swing_3 {
0% {
// 中間
margin-left: 0;
}
25% {
// 最右
margin-left: 12px;
}
75% {
// 最左
margin-left: -12px;
}
100% {
margin-left: 0;
}
}
接下來我們把 x 軸 和 y 軸 的軌迹(@keyframes)結合起來,并設定一個随機的動畫時間,比如說:
@for$i from 1 through 3 {
@for$j from 1 through 2 {
.bl_#{$i}_#{$j} {
animation: bubble_y calc(1.5s + $j * 0.5s) linear 1 forwards,
bubble_swing_#{$i} calc(1.5s + $j * 0.5s) linear 1 forwards;
}
}
}
這裡生成了 3 * 2 = 6 種不同的軌迹。針對這類重複的選擇器,用 SCSS 中的循環文法,可以少寫很多代碼。
2.3 随機選擇圖檔(雪碧圖)
我們每次點贊會出現不同的圖示,于是這裡設計了一系列選擇器給不同的圖示,讓它們呈現不同的圖檔。首先我們要準備一張雪碧圖,保持所有圖示的大小一緻,然後同樣使用 SCSS 的循環文法:
@for$i from 0 through 7 {
.b#{$i} {
background: url('../../images/like_sprites.png') calc(#{$i} * -24px) 0;
}
}
像上面生成了 8 個選擇器,我們在程式執行時就可以随機給圖示賦予一個選擇器。
2.4 生成一個點贊圖示
CSS 的部分差不多了,我們現在來看 JS 是怎麼執行的。我們需要有一個容器 div,讓它來裝載要生成的點贊圖示。以及一個按鈕來綁定點選事件:
const cacheRef = useRef<LikeCache>({
bubbleCnt: null,
likeIcon: null,
bubbleIndex: 0,
timer: null,
});
useEffect(() {
cacheRef.current.bubbleCnt = document.getElementById('like-bubble-cnt');
cacheRef.current.likeIcon = document.getElementById('like-icon');
}, []);
在點選事件中,生成一個新的 div 元素,并為它設定 className。接着将它 append 到容器下,最後在一段時間後銷毀這個點贊圖示元素。如下:
/**
* 添加 bubble
*/
const addBubble = () => {
const { bubbleCnt } = cacheRef.current;
cacheRef.current.bubbleIndex %= maxBubble;
const d = document.createElement('div');
// 圖檔類 b0 - b7
// 随機動畫類 bl_1_1 - bl_3_2
const swing = Math.floor(Math.random() * 3) + 1;
const speed = Math.floor(Math.random() * 2) + 1;
d.className = `like-bubble b${cacheRef.current.bubbleIndex} bl_${swing}_${speed}`;
bubbleCnt?.appendChild(d);
cacheRef.current.bubbleIndex++;
// 動畫結束後銷毀元素
setTimeout(() => {
bubbleCnt?.removeChild(d);
}, 2600);
};
到這裡,我們就實作得差不多了。不過,我們還可以給點選的圖示加點動畫,讓它有一個被按壓後彈起的效果:
/**
* 點選“喜歡”
*/
const onClick = () => {
const { timer, likeIcon } = cacheRef.current;
if (!likeIcon) {
return;
}
if (timer) {
clearTimeout(timer);
cacheRef.current.timer = null;
}
likeIcon.classList.remove('bounce-click');
// 删除并重新添加類,需要延遲添加
setTimeout(() => {
likeIcon.classList.add('bounce-click');
}, 0);
cacheRef.current.timer = window.setTimeout(() => {
likeIcon.classList.remove('bounce-click');
clearTimeout(timer!);
cacheRef.current.timer = null;
}, 300);
addBubble();
};
2.5 最終效果
最後來看看效果吧!
3. Canvas 實作點贊動效
我們都知道 Canvas 的繪制更流暢一些,能夠帶來更好的體驗。但苦于編碼比較複雜,也有一定的學習成本,實作起來要比 CSS 複雜不少。
接下來我們看看基于 Canvas 的點贊動效實作。
3.1 畫布建立
首先我們讀取一個 Canvas 元素的 id,并通過 getContext 擷取它的上下文。除此之外,還傳入了一個 canvasScale,指的是畫布放大的比例,這個在之後會用到:
constructor(canvasId: string, canvasScale: number) {
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
this.context = canvas.getContext('2d')!;
this.width = canvas.width;
this.height = canvas.height;
this.canvasScale = canvasScale;
this.img = null;
this.loadImages();
}
3.2 預加載圖檔(雪碧圖)
在 constructor 這裡,我們還通過 loadImages 這個函數,預加載了雪碧圖:
import likeSprites from'../../images/like_sprites.png';
/**
* 預加載圖檔
*/
loadImages = () => {
const p = newPromise((resolve: (image: HTMLImageElement) => void) => {
const img = new Image();
img.onerror = () => resolve(img);
img.onload = () => resolve(img);
img.src = likeSprites;
});
p.then((img) => {
if (img && img.width > 0) {
this.img = img;
} else {
// error('[live-connect]預加載喜歡動效圖檔失敗');
}
});
};
3.3 軌迹拆解
同樣的,我們需要從 Canvas 的視角來拆解點贊圖示的運動軌迹。
y 軸 的運動和 CSS 一樣,我們知道起始位置和終止位置就可以得出。
x 軸 的運動可以好好推敲。由于 Canvas 是逐幀繪制的,我們可以模拟出一個比較逼真的簡諧運動。這裡要來講一講大家耳熟能詳的國中數學了,下面是我們要使用的一條正弦函數的公式:
y = A sin(Bx + C) + D
參數說明:
- 振幅是 A
- 周期是 2π/B
- 相移是 −C/B
- 垂直移位是 D
套入點贊動效:
- 賦予圖示元素随機的振幅 A。
- 賦予圖示元素随機的周期,即 B 是随機的。
- 取 C = 0,即相移為 0。
- 取 D = 0,即不需要垂直移位。
y = A sinBx。
3.4 橫豎位移計算
确定位移軌迹之後,我們先定義一些常量,如下:
/** 圖檔顯示寬高 */
const IMAGE_WIDTH = 30;
/** 圖檔原始寬高 */
const SOURCE_IMAGE_WIDTH = 144;
/** 圖檔數量 */
const IMG_NUM = 8;
/** 放大階段(百分比)*/
const ENLARGE_STAGE = 0.1;
/** 收縮漸隐階段(百分比)*/
const FADE_OUT_STAGE = 0.8;
首先我們可以設計 x 軸 和 y 軸 兩個方向上的位移計算函數,函數參數 progress 是 0 到 1 之間的數值,表示一個過程量(0 -> 1)。
// 起始位置
const basicX = this.width / 2;
// 正弦頻率
const frequency = random(2, 10);
// 正弦振幅
const amplitude = random(5, 20) * (random(0, 1) ? 1 : -1) * this.canvasScale;
/**
* 擷取橫向位移(x軸)
*/
const getTranslateX = (progress: number) => {
if (progress < ENLARGE_STAGE) {
// 放大期間,不進行搖擺位移
return basicX;
}
return basicX + amplitude * Math.sin(frequency * (progress - ENLARGE_STAGE));
};
/**
* 擷取豎向位移(y軸)
*/
const getTranslateY = (progress: number) => {
return IMAGE_WIDTH / 2 + (this.height - IMAGE_WIDTH / 2) * (1 - progress);
};
3.5 大小和透明度計算
要繪制的圖示大小怎麼控制呢?在 Canvas 中,其實就是計算一個 scale,表示放縮的比例。
我們根據放大/收縮階段的過程常量和 progress 變量來調節它的大小。起始階段先線性放大至 1,最後階段再線性縮小至 0。
透明度同理,在消失之前都是傳回 1,其餘時刻線性縮小。
/**
* 擷取放縮比例
*/
const getScale = (progress: number) => {
let r = 1;
if (progress < ENLARGE_STAGE) {
// 放大
r = progress / ENLARGE_STAGE;
} elseif (progress > FADE_OUT_STAGE) {
// 縮小
r = (1 - progress) / (1 - FADE_OUT_STAGE);
}
return r;
};
/**
* 擷取透明度
*/
const getAlpha = (progress: number) => {
if (progress < FADE_OUT_STAGE) {
return 1;
}
return 1 - (progress - FADE_OUT_STAGE) / (1 - FADE_OUT_STAGE);
};
3.6 Canvas 繪制
繪制時,我們先挑選一張圖檔。如下:
// 按順序讀取圖檔
const { curImgIndex } = this;
// 更新順序
this.curImgIndex = ++this.curImgIndex % IMG_NUM;
3.6.1 畫布元素清晰度
接下來需要用到我們之前提到的 canvasScale 了:
const newWidth = IMAGE_WIDTH * this.canvasScale;
為什麼這裡要乘以一個 canvasScale 呢?因為 Canvas 是位圖模式的,它會根據裝置的 dpi 來渲染圖檔。
首先先介紹一下高分屏的概念:
高分屏:在同樣大小的螢幕面積上顯示更多的像素點,也就是更多的可視資訊。常見的就是 SXGA(1400 * 1050),UXGA(1600 * 1200)。1024 * 768 分辨率的螢幕叫普通屏,也就是 XGA 的螢幕,這個分辨率以上的螢幕叫高分屏。
在高分屏上,每平方英寸會有更多的像素。原來在普通屏上繪制的 1 個像素,為了适應高分屏,被迫放大,變成了 4 個像素或者更多。
可以想象成,一張清晰度正常的普通圖檔為了布滿整個背景被強行放大 n 倍,是以看起來模糊了。
為了解決這個問題,就需要我們将繪制的圖檔放大。同時還要控制 Canvas 畫布在 CSS 中的寬高。做到繪制内容變大的同時,畫布依然呈現原來的大小。這樣一來,圖檔就會因為繪制了更多的内容,而在高分屏上變得清晰且細膩。
3.6.2 繪制元素
繪制我們用到了 drawImage。在調用它之前,我們需要根據計算出的 translateX 和 translateY,調整繪制的起點。并且調整放縮比例和透明度,即
context.scale()
和
context.globalAlpha
。如下:
return(progress: number) => {
// 動畫過程 0 -> 1
if (progress >= 1) return true;
context.save();
const scale = getScale(progress);
const translateX = getTranslateX(progress);
const translateY = getTranslateY(progress);
context.translate(translateX, translateY);
context.scale(scale, scale);
context.globalAlpha = getAlpha(progress);
context.drawImage(
this.img!,
SOURCE_IMAGE_WIDTH * curImgIndex,
0,
SOURCE_IMAGE_WIDTH,
SOURCE_IMAGE_WIDTH,
-newWidth / 2,
-newWidth / 2,
newWidth,
newWidth,
);
context.restore();
return false;
};
3.6.3 建立繪制執行個體
我們用一個 start 函數來生成點贊動畫,每當調用它時,都會建立一個 render 方法,并塞入一個 renderList。renderList 中存放的就是目前所有點贊圖示的繪制任務。如下:
start = () => {
const render = this.createRender();
const duration = random(2100, 2600);
if (!render) {
return;
}
this.renderList.push({
render,
duration,
timestamp: Date.now(),
});
if (!this.scanning) {
this.scanning = true;
requestAnimationFrame(this.scan);
}
return this;
};
3.6.4 實時繪制
知道了需要繪制哪些對象之後,就需要通過下面的 scan 方法,讓 Canvas 在每一幀都去繪制内容。
每次繪制分為這麼幾個過程:
- 清空畫布為透明。
- 從繪制清單中取出一個點贊圖示的 render 方法,并調用它。
- 假如它傳回了 true,代表點贊圖示已經完整經曆了整個動效的過程,需要将它從繪制清單中剔除出去。
- 重複 2、3 過程,直至清單中沒有任務需要執行。
- 通過
調用 scan 方法自身,等待下一幀重新調用 scan 繪制内容。requestAnimationFrame
scan = () => {
this.context.clearRect(0, 0, this.width, this.height);
let index = 0;
let { length } = this.renderList;
if (length > 0) {
requestAnimationFrame(this.scan);
this.scanning = true;
} else {
this.scanning = false;
}
while (index < length) {
const child = this.renderList[index];
if (!child || !child.render || child.render.call(null, (Date.now() - child.timestamp) / child.duration)) {
// 結束了,删除該動畫
this.renderList.splice(index, 1);
length--;
} else {
index++;
}
}
};
3.7 調用
接下來我們隻需要在點選的時候,調用一下
start
方法即可。
/**
* 點選“喜歡”
*/
const onClick = () => {
cacheRef.current.LikeAni?.start?.();
};
return (
<div className={cn('like-wrap', className)}>
<canvas id={CANVAS_ID} width={CANVAS_WIDTH} height={CANVAS_HEIGHT} className="like-bubble-cnt" />
<div className={cn('like-icon-cnt', className)} onClick={onClick}>
<i id="like-icon" className="like-icon" />
</div>
</div>
);
在直播場景下,還有很多不同的觸發方式。除了自己點選,我們還可以接受來自其他使用者的回報(網絡請求)來觸發
start
方法。或者根據線上人數,多次調用
start
方法來生成一定數量的點贊圖示。
3.8 最終效果
4. 性能比較
以下内容是在 MacBook Pro 16 的螢幕上測試的。
4.1 Frame Rendering Stats
在 chrome devtools 中,有兩個小功能可以來觀察我們繪制的性能情況:
- Paint flashing:可以高亮目前發生重繪的區域。
- Frame Rendering Stats,可以觀察動畫的 fps 和 GPU 使用情況。我們分别來看看 CSS 和 Canvas 兩種實作方式的性能情況。
這兩個功能,可以在 chrome devtools 中使用快捷鍵 Command + Shift + P,呼起指令搜尋的 Panel 來搜尋到。
CSS 性能
我們可以看到高亮區域在頻繁閃動,以及 GPU 記憶體的使用比率較高,這是因為 CSS 的實作方式是不斷生成新的元素(并在随後銷毀),會消耗更多的記憶體。
Canvas 性能
相反,Canvas 是集中在畫布上繪制并輸出的,不會反複建立和銷毀元素。會比 CSS 的實作更加流暢,性能更好一點。
除了流暢以外,Canvas 還能夠放大畫布和畫布元素,這也是一個非常重要的優勢。這意味着 Canvas 能夠繪制出更清晰的内容,生成出來的點贊圖示更加細膩。
4.2 Performance
在 chrome devtools 中切換到 Performance 面闆,還可以觀察動畫繪制過程中,頁面的一些性能名額。
CSS 性能
CSS 的實作之是以看起比較卡頓,主要是因為繪制任務太頻繁。
具體到每一幀,我們可以觀察到 LayoutShift 的警告。
每次可視元素在兩次渲染幀中的起始位置不同時,就說是發生了 LS(Layout Shift)。改變了起始位置的元素被認為是不穩定元素。
Canvas 性能
Canvas 實作的性能情況看起來就比較正常,即使繪制清晰一些的圖檔也不在話下。
5. 相關
實作參考:https://github.com/antiter/praise-animation