小程序 - 畫一個環形圖( Doughnut)


前言

項目中需要用到一個環形圖來進行數據的展示,效果如圖,參考了第三方開源的小程序圖表庫,大都幾十上百、甚者兩百多k!考慮到體積的因素,且就用到一種圖表,所以打算自己來寫一個。看了微信小程序 canvas 相關的 API,發現舊版本和新版本不兼容,其中遇到一些坑,記錄下。項目使用的是 taro 框架,所以寫法和小程序原生寫法有些出入,但其原理是一樣的。

創建畫布

首先,需要創建一個畫布。由於小程序 canvas 接口版本緣故,舊版本接口停止維護,新版本接口改成 Canvas 2D 跟 HTML 的 canvas 接口看齊。 為了在電腦和手機上顯示正常,需要做一些兼容處理。

<canvas style="width: 200px; height: 200px;"
        id="canvas"
        canvas-id="canvas"
        :type="is2D?'2d':''"
        @touchstart="canvasTouch"></canvas>

舊版本 API 是通過 createCanvasContext 來獲取 canvas 繪圖上下文, 並且 canvas 標簽需要設置 canvas-id 屬性,而新版本 API 是通過 createSelectorQuery 獲取 canvas 實例,且需要設置 id 屬性。

initCanvas() {
  if (this.is2D) {
    nextTick(() => {
      createSelectorQuery()
        .select('#canvas')
        .fields({ node: true, size: true })
        .exec(res => {
          const canvas = res[0].node
          const ctx = canvas.getContext('2d')
          const dpr = getSystemInfoSync().pixelRatio
          // 根據分辨率設置畫布寬高
          canvas.width = res[0].width * dpr
          canvas.height = res[0].height * dpr
          ctx.scale(dpr, dpr)

          this.canvas = canvas
          this.ctx = ctx

          if (ctx) {
            // to draw
          }
        })
    })
  } else {
    this.ctx = createCanvasContext('canvas')
    if (this.ctx) {
      // to draw	
    }
  }
}

畫弧線

拿到 canvas 實例后,我們就可以開始畫弧線了。從圖中我們可以看出,環形圖其實就是由一段段弧線組成。微信小程序提供了畫弧線的方法 CanvasContext.arc ,具體參數可以查看官方文檔。

/**
 * 畫弧線
 * sAngle:開始弧度
 * eAngle:結束弧度
 * border:弧線寬度
 * color :弧線的顏色
 */
drawArc(sAngle, eAngle, border, color) {
    // r:半徑 
    // centerPoint:圓心坐標 
    // ctx:canvas 實例
    const { r, centerPoint, ctx, is2D } = this
    const { x, y } = centerPoint
    // 開始創建一個繪畫路徑
    ctx.beginPath()
    // 設置弧線寬度
    ctx.lineWidth = border
    // 設置弧線的顏色
    ctx.strokeStyle = color
    // 創建一條弧線
    ctx.arc(x, y, r, sAngle, eAngle, false)
    // 畫出弧線的邊框
    ctx.stroke()
    // 關閉繪畫路徑
    ctx.closePath()
    // 將之前在繪圖上下文中的描述(路徑、變形、樣式)畫到 canvas 中
    // canvas 2d 下不需要調用 draw 方法
    // 如果不做判斷會報錯
    if(!is2D) ctx.draw()
}

通過以上方法我們可以大概知道一段弧線是怎么畫出來了,如果需要畫多段弧,則修改弧線的弧度等參數即可。下面我們假設每段弧線的百分比為 20、30、15、35 ,計算出每段弧的開始弧度和結束弧度就能畫出一個完整的環形圖。

drawArcs() {
  const { ctx } = this
  // 各段弧百分比
  const ratios = [20, 30, 15, 35]
  // 各段弧顏色
  const colors = ['#6d77e6', '#fe4e75', '#fcd95c', '#3bdeff']
  // 每段弧開始弧度
  let sAngle = 0

  ratios.forEach((item, index) => {
    // 各段線的弧度
    // 2*Math.PI*弧線百分比/100
    const angle = (item * Math.PI) / 50
    // 結束弧度,需要加上上一段弧線的結束弧度
    const eAngle = sAngle + angle

    this.drawArc(sAngle, eAngle, border, colors[index])

    sAngle = eAngle
  })
}

畫提示文字

drawText() {
  const { is2D, ctx } = this
  const size = 8
  const text = '要繪制的文本'
  const x = 60
  const y = 0

  // 兼容文本繪制的字體和字體顏色設置
  if (is2D) {
    ctx.font = size
    ctx.fillStyle = 'white'
  } else {
    ctx.setFontSize(size)
    ctx.setFillStyle('white')
  }

  ctx.fillText(text, x, y)
}

文字的繪畫不難,難點在於獲取繪畫文字的坐標位置。根據設計稿可以看出,提示文字位於每段弧線的“中心”位置,因此,我們需要在繪制弧線時獲取每段弧線的“中心”位置。

drawArcs(ratios) {
  // 省略...

  // 半徑
  const r = 60
  const { x: _x, y: _y } = centerPoint
  const _textPoints = []

  ratios.forEach((item, index) => {
    // 省略...

    // 要繪制文本所在點的弧度
    // 需要注意的是:
    // 要加上一段弧線的結束弧度
    // 不然文字繪畫不能居於弧線“中心”位置
    const _angle = sAngle + angle / 2
    // 求圓上某點
    const x = _x + r * Math.cos(_angle)
    const y = _y + r * Math.sin(_angle)

    _textPoints.push({ x, y, value: item })

    // 省略...
  })
  // 獲取各弧線“中心”位置坐標
  this.textPoints = _textPoints
},
drawText() {
  const { is2D, ctx, textPoints } = this
  const size = 8

  // 兼容文本繪制的字體和字體顏色設置
  if (is2D) {
    ctx.font = size
    ctx.fillStyle = 'white'
  } else {
    ctx.setFontSize(size)
    ctx.setFillStyle('white')
  }

  textPoints.forEach((item, index) => {
    if (item.value > 0) {
      // 獲取文本寬度
      const { width } = ctx.measureText(`${item.value}%`)
      const x = item.x - width / 2
      const y = item.y + tipsSize / 2
      const text = `${item.value}%`

      ctx.fillText(text, x, y)
    }
  })
}

畫圓心區域

圓心區域主要是畫一個圓和一行文本,沒啥好說的,參考上面代碼做一下修改即可。

添加點擊事件

要知道點了哪個區域的弧,小程序 canvas 提供了點擊畫布的事件,我們可以通過計算點擊的位置、距離圓心的角度來判斷是否位於弧線內。

canvasTouch(e) {
  const { centerPoint, r, angles, border, activeIndex } = this
  const { x, y } = e.changedTouches[0]
  const { x: _x, y: _y } = centerPoint
  // 兩點距離
  const len = Math.sqrt(Math.pow(_y - y, 2) + Math.pow(_x - x, 2))
  const borderHalf = border / 2
  // 是否在弧線內
  const isInRing = len > r - borderHalf && len < r + borderHalf
  let current = activeIndex

  if (isInRing) {
    // 獲取圓心角
    let angle = Math.atan2(y - _y, x - _x)
    // 判斷弧度是否為負,為負時需要轉正
    angle = angle > 0 ? angle : 2 * Math.PI + angle

    angles.some((item, index) => {
      // 是否在弧度內
      if (item > angle) {
        current = index
        return true
      }
    })
  } else {
    current = -1
  }
  // 設置當前激活區域
  this.activeIndex = current
}

增加動畫

動畫,無非是特定時間內某個狀態過渡到另外一個狀態。假設我們要動畫持續執行 600 毫秒,則可以計算每次執行繪畫的開始和結束的時間差,並通過時間差總和來判斷是否執行了足夠長的時間進而終止動畫。

requestAnimationFrame(callback, lastTime = 0) {
  const { canvas, is2D } = this
  const intervel = 16
  const start = new Date().getTime()

  if (is2D && canvas && canvas.requestAnimationFrame) {
    this.timer = canvas.requestAnimationFrame(() => {
      const now = new Date().getTime()
      lastTime += now - start
      callback(lastTime)
    })
  } else {
    this.timer = setTimeout(() => {
      const now = new Date().getTime()
      lastTime += now - start
      callback(lastTime)
    }, intervel)
  }
},
cancelAnimationFrame() {
  const { is2D, canvas, timer, ctx } = this
  if (is2D && canvas && canvas.cancelAnimationFrame) {
    canvas.cancelAnimationFrame(timer)
  } else {
    clearTimeout(timer)
  }
},
init() {
  const { is2D, ctx, value, duration, timer } = this
  let ratios = [20, 30, 15, 35]

  if (ctx) {
    if (timer) this.cancelAnimationFrame()

    const callback = lastTime => {
      // 清除畫布內容
      ctx.clearRect(0, 0, 200, 200)

      lastTime = lastTime >= duration ? duration : lastTime

      if (lastTime === duration) {
        // 終止動畫
        this.cancelAnimationFrame()
        return
      }
      // 當前時間各弧線的百分比值
      ratios = ratios.map(i => lastTime*i/duration)

      this.drawArcs(ratios)

      if (!is2D) ctx.draw()

      this.requestAnimationFrame(callback, lastTime)
    }

    this.requestAnimationFrame(callback)
  } else {
    this.initCanvas()
  }
}

小程序 canvas 舊版本接口沒有 requestAnimationFramecancelAnimationFrame 方法,不過我們可以用 setTimeoutclearTimeout 來做兼容處理。

使用緩動函數

上面實現了動畫效果,不過動得還不夠“自然”,缺乏一些“節奏”感,生活中一些會動的東西基本都是有一個逐漸加速或逐漸減速的過程,不然的話會顯得很生硬。有了這個需求,我們要怎么實現呢?在 CSS3 的 animation 中會有 ease、ease-in、ease-in-eout 等預設函數可用,而在 JavaScript 里我們可以使用第三方寫好的緩動函數庫,為了減少體積,我們就自己寫吧。

/**
 * 二次方緩動函數
 * currentTime:當前動畫執行的時長
 * startValue:開始值
 * changeValue:變化量,即動畫執行到最后的值
 * duration:動畫持續執行的時間
 */
easeInQuadratic(currentTime, startValue, changeValue, duration) {
  currentTime /= duration
  return changeValue * currentTime * currentTime + startValue
}

上面的緩動方法是基於數學的指數函數(f(x)=x^2)來寫的,具體怎么演變出來后面有時間可以推導一番。

init() {
  const { is2D, ctx, value, duration, timer } = this
  let ratios = [20, 30, 15, 35]

  if (ctx) {
    if (timer) this.cancelAnimationFrame()

    const callback = lastTime => {
      // 省略...

      // 當前時間各弧線的百分比值
      ratios = value.map(i => this.easeInQuadratic(lastTime, 0, i, duration))

      this.drawArcs(ratios)

      // 省略...
    }

    this.requestAnimationFrame(callback)
  } else {
    this.initCanvas()
  }
}

總結

至此,自行手寫的環形圖算是大致完成了,其中有些幾何數學的知識點有些遺忘了,寫的時候查了公式才曉得,用別人的東西用多了腦子就不好使了,有空真的得多造些輪子才行。文中只是大概的按思路寫了下代碼,具體 完整代碼 可在 Github 上查看,如果覺得有用就請點個 star 吧。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM