天天看點

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

1. 前言

以前在看微信視訊号直播的時候,經常點選右下角的點贊按鈕。看着它的數字慢慢從一位數變成五位數,還是挺有氛圍感的。特别是長按的時候,有個手機震動的回報,很帶感。

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

雖然之前很好奇這些飄動的點贊動效是怎麼實作的,但沒有特别去鑽研。直到前陣子投入騰訊課堂 H5 直播間的需求,需要自己去實作一個這樣的效果時,才開始摸索。

先看看最後的效果:

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

相比視訊号的點贊動效,軌迹複雜了很多。可以看到課堂直播間的這一段點贊動效,大概分為這麼三個階段:

  1. 從無到有,在上升過程中放大成正常大小
  2. 上升過程中左右搖曳,且搖曳的幅度随機
  3. 左右搖曳上升的過程中,漸隐并縮小

在動手之前,我先想到了使用 CSS animation 去實作這種運動軌迹。在完成之後,又用 Canvas 重構了一版,優化了性能。

接下來我們分别來看看這兩種實作方式。

2. CSS 實作點贊動效

2.1 軌迹分析

由于點贊動畫是在一個二維平面上的,我們可以将它的運動軌迹拆分為 x 軸 和 y 軸 上的兩段。

在 y 軸 上非常簡單,我們的點贊圖示會做一段垂直上升的勻速運動,從容器底部上升到容器頂部。

而 x 軸 上是左右搖曳的,用數學的角度說,是一段簡諧運動。

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

但用 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 最終效果

最後來看看效果吧!

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

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 倍,是以看起來模糊了。

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

為了解決這個問題,就需要我們将繪制的圖檔放大。同時還要控制 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 在每一幀都去繪制内容。

每次繪制分為這麼幾個過程:

  1. 清空畫布為透明。
  2. 從繪制清單中取出一個點贊圖示的 render 方法,并調用它。
  3. 假如它傳回了 true,代表點贊圖示已經完整經曆了整個動效的過程,需要将它從繪制清單中剔除出去。
  4. 重複 2、3 過程,直至清單中沒有任務需要執行。
  5. 通過 ​

    ​requestAnimationFrame​

    ​ 調用 scan 方法自身,等待下一幀重新調用 scan 繪制内容。
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 最終效果

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

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 的實作方式是不斷生成新的元素(并在随後銷毀),會消耗更多的記憶體。

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

Canvas 性能

相反,Canvas 是集中在畫布上繪制并輸出的,不會反複建立和銷毀元素。會比 CSS 的實作更加流暢,性能更好一點。

除了流暢以外,Canvas 還能夠放大畫布和畫布元素,這也是一個非常重要的優勢。這意味着 Canvas 能夠繪制出更清晰的内容,生成出來的點贊圖示更加細膩。

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

4.2 Performance

在 chrome devtools 中切換到 Performance 面闆,還可以觀察動畫繪制過程中,頁面的一些性能名額。

CSS 性能

CSS 的實作之是以看起比較卡頓,主要是因為繪制任務太頻繁。

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

具體到每一幀,我們可以觀察到 LayoutShift 的警告。

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

每次可視元素在兩次渲染幀中的起始位置不同時,就說是發生了 LS(Layout Shift)。改變了起始位置的元素被認為是不穩定元素。

Canvas 性能

Canvas 實作的性能情況看起來就比較正常,即使繪制清晰一些的圖檔也不在話下。

【實戰】1213- 點贊動畫還可以做得那麼飄逸!

5. 相關

實作參考:https://github.com/antiter/praise-animation