前言
項目中需要用到一個環形圖來進行數據的展示,效果如圖,參考了第三方開源的小程序圖表庫,大都幾十上百、甚者兩百多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 舊版本接口沒有 requestAnimationFrame
和 cancelAnimationFrame
方法,不過我們可以用 setTimeout
和 clearTimeout
來做兼容處理。
使用緩動函數
上面實現了動畫效果,不過動得還不夠“自然”,缺乏一些“節奏”感,生活中一些會動的東西基本都是有一個逐漸加速或逐漸減速的過程,不然的話會顯得很生硬。有了這個需求,我們要怎么實現呢?在 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 吧。