又雙叒叕是一個 canvas 動畫

demonQ發表於2019-01-21

先看效果

純動畫效果

帶音樂特效,僅支援谷歌瀏覽器

程式碼在此

簡單?我們可能不缺少發現簡單的眼睛,但缺少完美簡單的手

這個動畫效果是在網易雲音樂 app 上看到的,抱著複習 canvas 的心態來實現。

現在開始分析並且實現所需要的功能

列出需求

  1. 效果參照已實現的上方連結。
  2. 針對不同使用場景需要可定製引數(大小,顏色等)
  3. 需要根據音樂節奏快慢進行動畫

開始實現

1. 引數設定

根據效果,我們能初步想到的需要設定的引數如下,引數的具體值可以在隨後實現中進一步修改

 const originParams = {
  cover: '',   // 中心的封面圖
  size: 500,  // 畫布 canvas 的尺寸
  radius: 100,  // 封面圖,中心圓的半徑,小於零則為容器的百分比
  interval: [500, 1500],  // 漣漪出現的最小頻率(毫秒)
  centerColor: '#ddd',  // 封面圖位置的顏色(在沒有封面圖時顯示)
  borderWidth: 5,  //  封面圖邊框的寬度
  borderColor: '#aaa',  // 封面圖邊框的顏色
  rippleWidth: 4,  // 漣漪圓環的寬度
  rippleColor: '#fff',  // 漣漪顏色
  pointRadius: 8,  // 漣漪圓點的半徑
  rotateAngle: .3, // 封面圖每幀旋轉的角度
}
複製程式碼

2. 編寫建構函式

我們知道,動畫的原理,就是趁腦子不注意反應不過來的時候偷偷換上一副差不多的畫面,這樣一步一步的替換,就形成了動畫,而這一步一步就可以叫做

所以,在渲染圓圈及圓點時,我們需要一個陣列來儲存他們每一次渲染的位置。

另外,我們需要一些必要的初始化判斷,以及宣告一些公共的引數

綜上,我們基本可以寫出如下的建構函式

class Ripple {
  constructor(container, params = {}) {
    const originParams = {
      cover: '',
      size: 500, 
      radius: 100, 
      interval: [500, 1500], 
      centerColor: '#ddd', 
      borderWidth: 5, 
      borderColor: '#aaa', 
      rippleWidth: 4, 
      rippleColor: '#fff', 
      pointRadius: 8, 
      rotateAngle: .3, 
    }

    this.container = typeof container === "string" ? document.querySelector(container) : container

    this.params = Object.assign(originParams, params)

    this.cover = this.params.cover

    this.radius = this.params.radius < 1 ? this.params.size * this.params.radius : this.params.radius

    this.center = this.params.size / 2  // 中心點

    this.rate = 0  // 記錄播放的幀數
    this.frame = null  // 幀動畫,用於取消
    this.rippleLines = []  // 儲存漣漪圓環的半徑
    this.ripplePoints = []  // 儲存漣漪點距離中心點的距離
  }
}
複製程式碼

3. 初始化容器

canvas 中圖片渲染且旋轉並不容易,所以在 cover 引數傳值時,通過 img 標籤來渲染。

另外的,我們需要一些其他必要的 CSS 新增在元素上

  class Ripple{
    initCanvas() {
      this.container.innerHTML = `<canvas width="${this.params.size}" height="${this.params.size}"></canvas>${this.cover ? `<img src="${this.cover}" alt="">` : ''}`
  
      this.cover = this.container.querySelector('img')
      this.canvas = this.container.querySelector('canvas')
      this.ctx = this.canvas.getContext('2d')
  
      this.rotate = 0
  
      const containerStyle = { ... }
      const canvasStyle = { ... }
      const coverStyle = { ... }
  
      utils.addStyles(this.container, containerStyle)
      utils.addStyles(this.canvas, canvasStyle)
      utils.addStyles(this.cover, coverStyle)
  
      this.strokeBorder()
    }
  }
複製程式碼

4. 畫個圓

canvas 的基本用法,就不多贅述了

class Ripple{
  strokeCenterCircle() {
    const ctx = this.ctx
    ctx.beginPath()
    ctx.arc(this.center, this.center, this.radius, 0, 2 * Math.PI)
    ctx.closePath()
    ctx.fillStyle = this.params.centerColor
    ctx.fill()
   }
  strokeBorder() {
    const ctx = this.ctx
    ctx.beginPath()
    ctx.arc(this.center, this.center, this.radius + this.params.borderWidth / 2, 0, 2 * Math.PI)
    ctx.closePath()
    ctx.strokeStyle = this.params.borderColor
    ctx.lineWidth = 5
    ctx.stroke()
  }
}
複製程式碼

5. 畫個圈圈

這不就是一個圈加一個圓嗎

    class Ripple{
      drawRipple() {
        const ctx = this.ctx
  
        // 畫外圈
        ctx.beginPath()
        ctx.arc(this.center, this.center, 200, 0, Math.PI * 2)
        ctx.strokeStyle = 'rgba(255,255,255,0.4)'
        ctx.lineWidth = this.params.rippleWidth
        ctx.stroke()
  
        // 畫點
        ctx.beginPath()
        ctx.arc(this.center - 200/Math.sqrt(2), this.center - 200/Math.sqrt(2), this.params.pointRadius, 0, 2 * Math.PI)
        ctx.closePath()
        ctx.fillStyle = 'rgba(255,255,255,0.4)'
        ctx.fill()
      }
    }
複製程式碼

於是出現了下面的問題

又雙叒叕是一個 canvas 動畫

出現的原因也很簡單,兩個半透明的圖形的重合部分透明度肯定是會加重的,所以只能通過畫一個不完整的圓圈(正好把圓點的部分隔過去)來解決了,解決方式如下圖:

又雙叒叕是一個 canvas 動畫

為了容易看到,連線略微向內移動了一點,所以我們的問題就是已知 r、R,求角度 θ,解答就不做詳解啦,算是高中數學的應用。我們可以得到角度為 Math.asin(R / r / 2) * 4

6. 繼續畫圈圈

圓環和圓點的重合已經解決了,現在需要的是在每次重新整理時更新他們的位置,如果達到了條件,則需要新新增一個圓環和點,如果圓環的半徑超出了畫布,則刪掉對應的資料

  class Ripple{
    strokeRipple() {
      // 當圓環大小超出畫布時,刪除改圓環資料
      if (this.rippleLines[0] > this.params.size) {
        this.rippleLines.shift()
        this.ripplePoints.shift()
      }
  
      // 當達到條件時,新增資料
      if (this.rate - this.lastripple >= this.minInterval) {
        this.rippleLines.push({
          r: this.radius + this.params.borderWidth + this.params.rippleWidth / 2,
          color: utils.getRgbColor(this.params.rippleColor)
        })
  
        this.ripplePoints.push({
          angle: utils.randomAngle()
        })
        // 更新新增時間
        this.lastripple = this.rate
      }
  
      // 計算下一次渲染的位置資料
      this.rippleLines = this.rippleLines.map((line, index) => ...)
  
      this.ripplePoints = this.rippleLines.map((line, index) => ...)
  
      // 根據新的資料渲染
      this.strokeRippleLine()
      this.strokeRipplePoint()
    }
  }
複製程式碼

6. 開始動畫

每渲染一次即更新一次資料,將 requestAnimationFrame 儲存於 this.frame 中,方便取消。

class Ripple{
    animate() {
      this.ctx.clearRect(0, 0, this.params.size, this.params.size)
  
      this.strokeRipple()
  
      this.strokeBorder()
  
      ...
  
      var that = this
      this.frame = requestAnimationFrame(function () {
        that.animate()
      })
    }
}
複製程式碼

7. 新增音樂節奏

原理如下:

  1. 建立一個 <audio>,做好相關設定(自動播放、控制元件顯示等等)
  2. 建立一個 AudioContext,將音訊來源設定為這個
  3. 建立一個 AnalyserNode,將 AnalyserNode 與 AudioContext 的音訊來源連結起來
  4. 如果需要達到一邊播放一遍顯示的效果,需要將 Analyser 再連出 AudioContext,否則資料只進不出沒有聲音
  5. 建立一個可以繪製的地方(可以是普通的 HTML元素,或者 canvas,SVG,WebGL等等)
  6. 定時從 AnalyserNode 獲取音訊資料(可以是時間域或頻率域的資料,定時可以用 requestAnimationFrame 或者是 setTimeout 來做)
  7. 利用獲取到的資料,進行視覺化設計,繪製影像

但是,可能是 audiocontext 的相容問題,在 safari 中,無法實時獲取到音訊資訊,如果有大神知曉,望不吝賜教。

所以這部分的實現並沒有什麼好講的了,有興趣的可以直接檢視 原始碼實現

寫在最後

上述動畫的實現的確並不複雜,但是在實現的過程中,可能考慮到的更多的是如何組織程式碼,如何設計介面(怎麼方便使用,增加定製度,減少操作度)。這些東西在寫上面教程的時候輕貓淡寫,一筆帶過或者壓根沒提過,但只有在自己寫時才能體會到切實的需要,所以,如果你能看到這裡,不妨放下剛才看到的程式碼實現,只看效果,自己也來寫一個(帶音樂的)這樣的動畫,畢竟,我們不缺少發現簡單的眼睛,缺少的可能是完美簡單的手。

一些其他專案

我覺得挺好但好像並沒人知道...

css tricks

js tricks

animate_resume

參考

developer.mozilla.org/en-US/docs/…

developer.mozilla.org/zh-CN/docs/…

相關文章