用初中數學知識擼一個canvas環形進度條

Tusi發表於2019-11-09

週末好,今天給大家帶來一款接地氣的環形進度條元件vue-awesome-progress。近日被設計小姐姐要求實現這麼一個環形進度條效果,大體由四部分組成,分別是底色圓環,進度弧,環內文字,進度圓點。設計稿截圖如下:

環形進度條設計稿

我的第一反應還是找現成的元件,市面上很多元件都實現了前3點,獨獨沒找到能畫進度圓點的元件,不然稍加定製也能複用。既然沒有現成的元件,只有自己用vue + canvas擼一個了。

我也很無奈啊

效果圖

先放個效果圖,然後再說下具體實現過程,各位看官且聽我慢慢道來。

環形進度條效果圖

安裝與使用

原始碼地址,歡迎star和提issue

安裝

npm install --save vue-awesome-progress
複製程式碼

使用

全域性註冊

import Vue from 'vue'
import VueAwesomeProgress from "vue-awesome-progress"
Vue.use(VueAwesomeProgress)
複製程式碼

區域性使用

import VueAwesomeProgress from "vue-awesome-progress"

export default {
    components: {
        VueAwesomeProgress
    },
    // 其他程式碼
}
複製程式碼

script標籤引入元件

同時也支援直接使用script標籤引入哦,滿足有這部分需求的朋友。

<!DOCTYPE html>
<html>
<head>
  <script src="https://cdn.bootcss.com/vue/2.6.10/vue.min.js"></script>
  <script src="path-to/vue-awesome-progress.min.js"></script>
</head>
<body>
  <div id="app"></div>
  <script>
    new Vue({
      el: "#app",
      template: '<vue-awesome-progress :percentage="40"></vue-awesome-progress>'
    })
  </script>
</body>
</html>
複製程式碼

靜態展示

任何事都不是一蹴而就的,我們首先來實現一個靜態的效果,然後再實現動畫效果,甚至是複雜的控制邏輯。

確定畫布大小

第一步是確定畫布大小。從設計稿我們可以直觀地看到,整個環形進度條的最外圍是由進度圓點確定的,而進度圓點的圓心在圓環圓周上。

環形進度條模型

因此我們得出虛擬碼如下:

// canvasSize: canvas寬度/高度
// outerRadius: 外圍半徑
// pointRadius: 圓點半徑
// circleRadius: 圓環半徑
canvasSize = 2 * outerRadius = 2 * (pointRadius + circleRadius)
複製程式碼

據此我們可以定義如下元件屬性:

props: {
  circleRadius: {
    type: Number,
    default: 40
  },
  pointRadius: {
    type: Number,
    default: 6
  }
},
computed: {
  // 外圍半徑
  outerRadius() {
    return this.circleRadius + this.pointRadius
  },
  // canvas寬/高
  canvasSize() {
    return 2 * this.outerRadius + 'px'
  }
}
複製程式碼

那麼canvas大小也可以先進行繫結了

<template>
    <canvas ref="canvasDemo" :width="canvasSize" :height="canvasSize" />
</template>
複製程式碼

獲取繪圖上下文

getContext('2d')方法返回一個用於在canvas上繪圖的環境,支援一系列2d繪圖API

mounted() {
  // 在$nextTick初始化畫布,不然dom還未渲染好
  this.$nextTick(() => {
    this.initCanvas()
  })
},
methods: {
  initCanvas() {
    var canvas = this.$refs.canvasDemo;
    var ctx = canvas.getContext('2d');
  }
}
複製程式碼

畫底色圓環

完成了上述步驟後,我們就可以著手畫各個元素了。我們先畫圓環,這時我們還要定義兩個屬性,分別是圓環線寬circleWidth和圓環顏色circleColor

circleWidth: {
  type: Number,
  default: 2
},
circleColor: {
  type: String,
  default: '#3B77E3'
}
複製程式碼

canvas提供的畫圓弧的方法是ctx.arc(),需要提供圓心座標,半徑,起止弧度,是否逆時針等引數。

ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise);
複製程式碼

我們知道,Web網頁中的座標系是這樣的,從絕對定位的設定上其實就能看出來(topleft設定正負值會發生什麼變化),而且原點(0, 0)是在盒子(比如說canvas)的左上角哦。

web座標系

對於角度而言,x軸正向,預設是順時針方向旋轉。

圓環的圓心就是canvas的中心,所以x, youterRadius的值就可以了。

ctx.strokeStyle = this.circleColor;
ctx.lineWidth = this.circleWidth;
ctx.beginPath();
ctx.arc(this.outerRadius, this.outerRadius, this.circleRadius, 0, this.deg2Arc(360));
ctx.stroke();
複製程式碼

注意arc傳的是弧度引數,而不是我們常理解的360°這種概念,因此我們需要將我們理解的360°轉為弧度。

// deg轉弧度
deg2Arc(deg) {
  return deg / 180 * Math.PI
}
複製程式碼

畫圓環

畫文字

呼叫fillText繪製文字,利用canvas.clientWidth / 2canvas.clientWidth / 2取得中點座標,結合控制文字對齊的兩個屬性textAligntextBaseline,我們可以將文字繪製在畫布中央。文字的值由label屬性接收,字型大小由fontSize屬性接收,顏色則取的fontColor

if (this.label) {
  ctx.font = `${this.fontSize}px Arial,"Microsoft YaHei"`
  ctx.fillStyle = this.fontColor;
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.fillText(this.label, canvas.clientWidth / 2, canvas.clientWidth / 2);
}
複製程式碼

畫文字

畫進度弧

支援普通顏色和漸變色,withGradient預設為true,代表使用漸變色繪製進度弧,漸變方向我預設給的從上到下。如果希望使用普通顏色,withGradientfalse即可,並可以通過lineColor自定義顏色。

if (this.withGradient) {
  this.gradient = ctx.createLinearGradient(this.circleRadius, 0, this.circleRadius, this.circleRadius * 2);
  this.lineColorStops.forEach(item => {
    this.gradient.addColorStop(item.percent, item.color);
  });
}
複製程式碼

其中lineColorStops是漸變色的顏色偏移斷點,由父元件傳入,可傳入任意個顏色斷點,格式如下:

colorStops2: [
  { percent: 0, color: '#FF9933' },
  { percent: 1, color: '#FF4949' }
]
複製程式碼

畫一條從上到下的進度弧,即270°90°

ctx.strokeStyle = this.withGradient ? this.gradient : this.lineColor;
ctx.lineWidth = this.lineWidth;
ctx.beginPath();
ctx.arc(this.outerRadius, this.outerRadius, this.circleRadius, this.deg2Arc(270), this.deg2Arc(90));
ctx.stroke();
複製程式碼

畫進度弧

其中lineWidth是弧線的寬度,由父元件傳入

lineWidth: {
  type: Number,
  default: 8
}
複製程式碼

畫進度圓點

最後我們需要把進度圓點補上,我們先寫死一個角度90°,顯而易見,圓點座標為(this.outerRadius, this.outerRadius + this.circleRadius)

90度圓點座標

畫圓點的程式碼如下:

ctx.fillStyle = this.pointColor;
ctx.beginPath();
ctx.arc(this.outerRadius, this.outerRadius + this.circleRadius, this.pointRadius, 0, this.deg2Arc(360));
ctx.fill();
複製程式碼

其中pointRadius是圓點的半徑,由父元件傳入:

pointRadius: {
  type: Number,
  default: 6
}
複製程式碼

90度畫圓點

角度自定義

當然,進度條的角度是靈活定義的,包括開始角度,結束角度,都應該由呼叫者隨意給出。因此我們再定義一個屬性angleRange,用於接收起止角度。

angleRange: {
  type: Array,
  default: function() {
    return [270, 90]
  }
}
複製程式碼

有了這個屬性,我們就可以隨意地畫進度弧和圓點了,哈哈哈哈。

等等,你忘了這個場景

老哥,這種圓點座標怎麼求?

特殊角度怎麼求圓點圓心座標

噗......看來高興過早了,最重要的是根據不同角度求得圓點的圓心座標,這讓我頓時犯了難。

你要冷靜

經過冷靜思考,我腦子裡閃過了一個利用正餘弦公式求座標的思路,但前提是座標系原點如果在圓環外接矩形的左上角才好算。仔細想想,冇問題啦,我先給座標系平移一下,最後求出來結果,再補個平移差值不就行了嘛。

平移座標系後求圓點座標

?畫圖工具不是很熟練,這裡圖沒畫好,線歪了,請忽略細節。

好的,我們先給座標系向右下方平移pointRadius,最後求得結果再加上pointRadius就好了。虛擬碼如下:

// realx:真實的x座標
// realy:真實的y座標
// resultx:平移後求取的x座標
// resultx:平移後求取的y座標
// pointRadius 圓點半徑
realx = resultx + pointRadius
realy = resulty + pointRadius
複製程式碼

求解座標的思路大概如下,分四個範圍判斷,得出求解公式,應該還可以化簡,不過我數學太菜了,先這樣吧。

getPositionsByDeg(deg) {
    let x = 0;
    let y = 0;
    if (deg >= 0 && deg <= 90) {
        // 0~90度
        x = this.circleRadius * (1 + Math.cos(this.deg2Arc(deg)))
        y = this.circleRadius * (1 + Math.sin(this.deg2Arc(deg)))
    } else if (deg > 90 && deg <= 180) {
        // 90~180度
        x = this.circleRadius * (1 - Math.cos(this.deg2Arc(180 - deg)))
        y = this.circleRadius * (1 + Math.sin(this.deg2Arc(180 - deg)))
    } else if (deg > 180 && deg <= 270) {
        // 180~270度
        x = this.circleRadius * (1 - Math.sin(this.deg2Arc(270 - deg)))
        y = this.circleRadius * (1 - Math.cos(this.deg2Arc(270 - deg)))
    } else {
        // 270~360度
        x = this.circleRadius * (1 + Math.cos(this.deg2Arc(360 - deg)))
        y = this.circleRadius * (1 - Math.sin(this.deg2Arc(360 - deg)))
    }
    return { x, y }
}
複製程式碼

最後再補上偏移值即可。

const pointPosition = this.getPositionsByDeg(nextDeg);
ctx.arc(pointPosition.x + this.pointRadius, pointPosition.y + this.pointRadius, this.pointRadius, 0, this.deg2Arc(360));
複製程式碼

任意角度畫弧線和圓點

這樣,一個基本的canvas環形進度條就成型了。

動畫展示

靜態的東西逼格自然是不夠的,因此我們需要再搞點動畫效果裝裝逼。

基礎動畫

我們先簡單實現一個線性的動畫效果。基本思路是把開始角度和結束角度的差值分為N段,利用window.requestAnimationFrame依次執行動畫。

比如從30°90°,我給它分為6段,每次畫10°。要注意canvas畫這種動畫過程一般是要重複地清空畫布並重繪的,所以第一次我畫的弧線範圍就是30°~40°,第二次我畫的弧線範圍就是30°~50°,以此類推......

基本的程式碼結構如下,具體程式碼請參考vue-awesome-progress v1.1.0版本,如果順手幫忙點個star也是極好的。

animateDrawArc(canvas, ctx, startDeg, endDeg, nextDeg, step) {
  window.requestAnimationFrame(() => {
    // 清空畫布
    ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
    // 求下一個目標角度
    nextDeg = this.getTargetDeg(nextDeg || startDeg, endDeg, step);
    // 畫圓環
    // 畫文字
    // 畫進度弧線
    // 畫進度圓點
    if (nextDeg !== endDeg) {
      // 滿足條件繼續呼叫動畫,否則結束動畫
      this.animateDrawArc(canvas, ctx, startDeg, endDeg, nextDeg, step)
    }
  }
}
複製程式碼

線性動畫

緩動效果

線性動畫顯得有點單調,可操作性不大,因此我考慮引入貝塞爾緩動函式easing,並且支援傳入動畫執行時間週期duration,增強了可定製性,使用體驗更好。這裡不列出實現程式碼了,請前往vue-awesome-progress檢視。

<vue-awesome-progress label="188人" :duration="10" easing="0,0,1,1" />

<vue-awesome-progress
  label="36℃"
  circle-color="#FF4949"
  :line-color-stops="colorStops"
  :angle-range="[60, 180]"
  :duration="5"
/>

// 省略部分...

<vue-awesome-progress label="188人" easing="1,0.28,0.17,0.53" :duration="10" />

<vue-awesome-progress
  label="36℃"
  circle-color="#FF4949"
  :line-color-stops="colorStops"
  :angle-range="[60, 180]"
  :duration="5"
  easing="0.17,0.67,0.83,0.67"
/>
複製程式碼

環形進度條緩動效果

可以看到,當傳入不同的動畫週期duration和緩動引數easing時,動畫效果各異,完全取決於使用者自己。

其他效果

當然根據元件支援的屬性,我們也可以定製出其他效果,比如不顯示文字,不顯示圓點,弧線線寬與圓環線寬一樣,不使用漸變色,不需要動畫,等等。我們後續也會考慮支援更多能力,比如控制進度,數字動態增長等!具體使用方法,請參考vue-awesome-progress

其他效果案例

更新日誌


2019年11月10日更新

由於我從業務場景出發做了這個元件,沒有考慮到大部分場景都是傳百分比控制進度的,因此在v1.4.0版本做了如下修正:

  1. 廢棄angle-range,改用percentage控制進度,同時提供start-deg屬性控制起始角度;

  2. with-gradient改為use-gradient

  3. 通過show-text控制是否顯示進度文字

  4. 支援通過format函式自定義顯示文字的規則

v1.4.0版本效果

結語

寫完這個元件有讓我感覺到,程式設計師最終不是輸給了程式碼和技術的快速迭代,而是輸給了自己的邏輯思維能力和數學功底。就vue-awesome-progress這個元件而言,根據這個思路,我們也能迅速開發出適用於ReactAngular以及其他框架生態下的元件。工作三年有餘,接觸了不少框架和技術,經歷了MVVMHybrid小程式跨平臺大前端serverless的大火,也時常感慨“學不動了”,在這個快速演進的程式碼世界裡常常感到失落。好在自己還沒有丟掉分析問題的能力,而不僅僅是呼叫各種API和外掛,這可能是程式設計師最寶貴的財富吧。前路坎坷,我輩當不忘初心,願你出走半生,歸來仍是少年!

我是Tusi,一個創業公司前端小leader,每天依然為寫不完的業務程式碼煩惱,在打磨產品道路上沉澱技術,探索成長路線。如果你與我一樣,正在思考自己的技術成長與價值,歡迎加我微信交流探討,微訊號ice_lloly。我會在公眾號大前端技術沙龍和小程式Tusi部落格同步部落格內容,快來撩我!

歡迎關注

相關文章