小程序 - 画一个环形图( 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 吧。

posted @ 2021-06-30 16:58  _fn  阅读(1719)  评论(0编辑  收藏  举报