一、预览

之前写过web端的canvas 缩放/拖动/还原/封装和实例。最近小程序也需要用到,但凡是涉及小程序canvas还是比较多坑的,而且难用多了,于是在web的基础上重新写了小程序的相关功能。实现功能有:

  • 支持双指、按钮缩放
  • 支持触摸拖动
  • 支持高清显示
  • 支持节流绘图
  • 支持还原、清除画布
  • 内置简化绘图方法

效果如下:
小程序canvas 缩放/拖动/还原/封装和实例--开箱即用-LMLPHP

二、使用

案例涉及到2个文件,一个是绘图组件canvas.vue,另一个是canvasDraw.js,核心是canvasDraw.js里定义的CanvasDraw类

2.1 创建和配置

小程序获取#canvas对象后就可以创建CanvasDraw实例了,创建实例时可以根据需要设置各种配置,其中drawCallBack是必须的,是用户自定义的绘图方法,程序会在this.canvasDraw.draw()后再回调drawCallBack()来实现用户的绘图。
拖动、缩放画布都会调用this.canvasDraw.draw()。

    /** 初始化canvas */
    initCanvas() {
      const query = wx.createSelectorQuery().in(this)

      query
        .select('#canvas')
        .fields({ node: true, size: true, rect: true })
        .exec((res) => {
          const ele = res[0]
          this.canvasEle = ele

          // 配置项
          const option = {
            ele: this.canvasEle, // canvas元素
            drawCallBack: this.draw, // 必须:用户自定义绘图方法
            scale: 1, // 当前缩放倍数
            scaleStep: 0.1, // 缩放步长(按钮)
            touchScaleStep: 0.005, // 缩放步长(手势)
            maxScale: 2, // 缩放最大倍数(缩放比率倍数)
            minScale: 0.5, // 缩放最小倍数(缩放比率倍数)
            translate: { x: 0, y: 0 }, // 默认画布偏移
            isThrottleDraw: true, // 是否开启节流绘图(建议开启,否则安卓调用频繁导致卡顿)
            throttleInterval: 20, // 节流绘图间隔,单位ms
            pixelRatio: wx.getSystemInfoSync().pixelRatio, // 像素比(高像素比可以解决高清屏幕模糊问题)
          }
          this.canvasDraw = new CanvasDraw(option) // 创建CanvasDraw实例后就可以使用实例的所有方法了
          this.canvasDraw.draw() // 可以按实际需要调用绘图方法
        })
    },

方法

canvasDraw.draw() // 绘图
canvasDraw.clear() // 清除画布
canvasDraw.reset() // 重置画布(恢复到第一次绘制的状态)
canvasDraw.zoomIn() // 中心放大
canvasDraw.zoomOut() // 中心缩小
canvasDraw.zoomTo(scale, zoomCenter) // 缩放到指定倍数(可指定缩放中心点)
canvasDraw.destory() // 销毁
canvasDraw.drawShape(opt) // 内置简化绘制多边形方法
canvasDraw.drawLines(opt) // 内置简化绘制多线段方法
canvasDraw.drawText(opt) // 内置简化绘制文字方法

三、源码

3.1 实例组件

canvas.vue

<template>
  <view class="canvas-wrap">
    <canvas
      type="2d"
      id="canvas"
      class="canvas"
      disable-scroll="true"
      @touchstart="touchstart"
      @touchmove="touchmove"
      @touchend="touchend"
      @tap="tap"
    ></canvas>
  </view>
</template>
<script>
import { CanvasDraw } from './canvasDraw'

export default {
  data() {
    this.canvasDraw = null // 绘图对象
    this.canvasEle = null // canvas元素对象
    return {}
  },

  created() {},
  beforeDestroy() {
    /** 销毁对象 */
    if (this.canvasDraw) {
      this.canvasDraw.destroy()
      this.canvasDraw = null
    }
  },
  mounted() {
    /** 初始化 */
    this.initCanvas()
  },
  methods: {
    /** 初始化canvas */
    initCanvas() {
      const query = wx.createSelectorQuery().in(this)

      query
        .select('#canvas')
        .fields({ node: true, size: true, rect: true })
        .exec((res) => {
          const ele = res[0]
          this.canvasEle = ele

          // 配置项
          const option = {
            ele: this.canvasEle, // canvas元素
            drawCallBack: this.draw, // 必须:用户自定义绘图方法
            scale: 1, // 当前缩放倍数
            scaleStep: 0.1, // 缩放步长(按钮)
            touchScaleStep: 0.005, // 缩放步长(手势)
            maxScale: 2, // 缩放最大倍数(缩放比率倍数)
            minScale: 0.5, // 缩放最小倍数(缩放比率倍数)
            translate: { x: 0, y: 0 }, // 默认画布偏移
            isThrottleDraw: true, // 是否开启节流绘图(建议开启,否则安卓调用频繁导致卡顿)
            throttleInterval: 20, // 节流绘图间隔,单位ms
            pixelRatio: wx.getSystemInfoSync().pixelRatio, // 像素比(高像素比可以解决高清屏幕模糊问题)
          }
          this.canvasDraw = new CanvasDraw(option) // 创建CanvasDraw实例后就可以使用实例的所有方法了
          this.canvasDraw.draw() // 可以按实际需要调用绘图方法
        })
    },

    /** 用户自定义绘图内容 */
    draw() {
      // 默认绘图方式-圆形
      const { ctx } = this.canvasDraw
      ctx.beginPath()
      ctx.strokeStyle = '#f00'
      ctx.arc(150, 150, 120, 0, 2 * Math.PI)
      ctx.stroke()

      // 组件方法-绘制多边形
      const shapeOption = {
        points: [
          { x: 127, y: 347 },
          { x: 151, y: 304 },
          { x: 173, y: 344 },
          { x: 214, y: 337 },
          { x: 184, y: 396 },
          { x: 143, y: 430 },
          { x: 102, y: 400 },
        ],
        fillStyle: '#00f',
      }
      this.canvasDraw.drawShape(shapeOption)

      // 组件方法-绘制多线段
      const linesOption = {
        points: [
          { x: 98, y: 178 },
          { x: 98, y: 212 },
          { x: 157, y: 236 },
          { x: 208, y: 203 },
          { x: 210, y: 165 },
        ],
        strokeStyle: '#0f0',
      }
      this.canvasDraw.drawLines(linesOption)

      // 组件方法-绘制文字
      const textOption = {
        text: '组件方法-绘制文字',
        isCenter: true,
        point: { x: 150, y: 150 },
        fillStyle: '#000',
      }
      this.canvasDraw.drawText(textOption)
    },

    /** 中心放大 */
    zoomIn() {
      this.canvasDraw.zoomIn()
    },

    /** 中心缩小 */
    zoomOut() {
      this.canvasDraw.zoomOut()
    },

    /** 重置画布(回复初始效果) */
    reset() {
      this.canvasDraw.reset()
    },

    /** 事件绑定 */
    tap(e) {
      const p = {
        x: (e.detail.x - this.canvasEle.left) / this.canvasDraw.scale,
        y: (e.detail.y - this.canvasEle.top) / this.canvasDraw.scale,
      }
      console.log('点击坐标:', p)
    },
    touchstart(e) {
      this.canvasDraw.touchstart(e)
    },
    touchmove(e) {
      this.canvasDraw.touchmove(e)
    },
    touchend(e) {
      this.canvasDraw.touchend(e)
    },
  },
}
</script>
<style scoped>
.canvas-wrap {
  position: relative;
  flex: 1;
  width: 100%;
  height: 100%;
}

.canvas {
  width: 100%;
  flex: 1;
}
</style>

3.2 核心类

canvasDraw.js

/**
 * @Author: 卢景滔
 * @Date: 2022-05-23 18:54:29
 * @LastEditTime: 2022-05-24 12:46:55
 * @LastEditors: 卢景滔
 * @Description: 自定义小程序绘图类
 */

/**
 * 绘图类
 * @param {object} option
 */
export function CanvasDraw(option) {
  if (!option.ele) {
    console.error('canvas对象不存在')
    return
  }
  if (!option.drawCallBack) {
    console.error('缺少必须配置项:drawCallBack')
    return
  }
  const { ele } = option

  /** 外部可访问属性 */
  this.canvasNode = ele.node // wx的canvas节点
  this.canvasNode.width = ele.width // 设置canvas节点宽度
  this.canvasNode.height = ele.height // 设置canvas节点高度
  this.ctx = this.canvasNode.getContext('2d')
  this.zoomCenter = { x: ele.width / 2, y: ele.height / 2 } // 缩放中心点
  this.touchMoveEvent = null // 触摸移动事件

  /** 内部使用变量 */
  let startPoint = { x: 0, y: 0 } // 拖动开始坐标
  let startDistance = 0 // 拖动开始时距离(二指缩放)
  let curTranslate = {} // 当前偏移
  let curScale = 1 // 当前缩放
  let preScale = 1 // 上次缩放
  let drawTimer = null // 绘图计时器,用于节流
  let touchEndTimer = null // 触摸结束计时器,用于节流
  let fingers = 1 // 手指触摸个数

  /**
   * 根据像素比重设canvas尺寸
   */
  this.resetCanvasSize = () => {
    this.canvasNode.width = ele.width * this.pixelRatio
    this.canvasNode.height = ele.height * this.pixelRatio
  }

  /**
   * 初始化
   */
  this.init = () => {
    const optionCopy = JSON.parse(JSON.stringify(option))
    this.scale = optionCopy.scale ?? 1 // 当前缩放倍数
    this.scaleStep = optionCopy.scaleStep ?? 0.1 // 缩放步长(按钮)
    this.touchScaleStep = optionCopy.touchScaleStep ?? 0.005 // 缩放步长(手势)
    this.maxScale = optionCopy.maxScale ?? 2 // 缩放最大倍数(缩放比率倍数)
    this.minScale = optionCopy.minScale ?? 0.5 // 缩放最小倍数(缩放比率倍数)
    this.translate = optionCopy.translate ?? { x: 0, y: 0 } // 默认画布偏移
    this.isThrottleDraw = optionCopy.isThrottleDraw ?? true // 是否开启节流绘图(建议开启,否则安卓调用频繁导致卡顿)
    this.throttleInterval = optionCopy.throttleInterval ?? 20 // 节流绘图间隔,单位ms
    this.pixelRatio = optionCopy.pixelRatio ?? 1 // 像素比(高像素比解决高清屏幕模糊问题)

    startPoint = { x: 0, y: 0 } // 拖动开始坐标
    startDistance = 0 // 拖动开始时距离(二指缩放)
    curTranslate = JSON.parse(JSON.stringify(this.translate)) // 当前偏移
    curScale = this.scale // 当前缩放
    preScale = this.scale // 上次缩放
    drawTimer = null // 绘图计时器,用于节流
    fingers = 1 // 手指触摸个数

    this.resetCanvasSize()
  }

  this.init()

  /**
   * 绘图(会进行缩放和位移)
   */
  this.draw = () => {
    this.clear()
    this.ctx.translate(this.translate.x * this.pixelRatio, this.translate.y * this.pixelRatio)
    this.ctx.scale(this.scale * this.pixelRatio, this.scale * this.pixelRatio)
    // console.log('当前位移', this.translate.x, this.translate.y, '当前缩放倍率', this.scale)
    option.drawCallBack()
    drawTimer = null
  }

  /**
   * 设置默认值(
   */
  this.setDefault = () => {
    curTranslate.x = this.translate.x
    curTranslate.y = this.translate.y
    curScale = this.scale
    preScale = this.scale
  }

  /**
   * 清除画布(重设canvas尺寸会清空地图并重置canvas内置的scale/translate等)
   */
  this.clear = () => {
    this.resetCanvasSize()
  }

  /**
   * 绘制多边形
   */
  this.drawShape = (opt) => {
    this.ctx.beginPath()
    this.ctx.lineWidth = '1'
    this.ctx.fillStyle = opt.isSelect ? opt.HighlightfillStyle : opt.fillStyle
    this.ctx.strokeStyle = opt.HighlightStrokeStyle

    for (let i = 0; i < opt.points.length; i++) {
      const p = opt.points[i]
      if (i === 0) {
        this.ctx.moveTo(p.x, p.y)
      } else {
        this.ctx.lineTo(p.x, p.y)
      }
    }
    this.ctx.closePath()
    if (opt.isSelect) {
      this.ctx.stroke()
    }
    this.ctx.fill()
  }

  /**
   * 绘制多条线段
   */
  this.drawLines = (opt) => {
    this.ctx.beginPath()
    this.ctx.strokeStyle = opt.strokeStyle
    for (let i = 0; i < opt.points.length; i++) {
      const p = opt.points[i]
      if (i === 0) {
        this.ctx.moveTo(p.x, p.y)
      } else {
        this.ctx.lineTo(p.x, p.y)
      }
    }
    this.ctx.stroke()
  }

  /**
   * 绘制文字
   */
  this.drawText = (opt) => {
    this.ctx.fillStyle = opt.isSelect ? opt.HighlightfillStyle : opt.fillStyle
    if (opt.isCenter) {
      this.ctx.textAlign = 'center'
      this.ctx.textBaseline = 'middle'
    }
    this.ctx.fillText(opt.text, opt.point.x, opt.point.y)
  }

  /**
   * 重置画布(恢复到第一次绘制的状态)
   */
  this.reset = () => {
    this.init()
    this.draw()
  }

  /**
   * 中心放大
   */
  this.zoomIn = () => {
    this.zoomTo(this.scale + this.scaleStep)
  }

  /**
   * 中心缩小
   */
  this.zoomOut = () => {
    this.zoomTo(this.scale - this.scaleStep)
  }

  /**
   * 缩放到指定倍数
   * @param {number} scale 缩放大小
   * @param {object} zoomCenter 缩放中心点(可选
   */
  this.zoomTo = (scale, zoomCenter0) => {
    // console.log('缩放到:', scale, '缩放中心点:', zoomCenter0)
    this.scale = scale
    this.scale = this.scale > this.maxScale ? this.maxScale : this.scale
    this.scale = this.scale < this.minScale ? this.minScale : this.scale

    const zoomCenter = zoomCenter0 || this.zoomCenter
    this.translate.x = zoomCenter.x - ((zoomCenter.x - this.translate.x) * this.scale) / preScale
    this.translate.y = zoomCenter.y - ((zoomCenter.y - this.translate.y) * this.scale) / preScale
    this.draw()
    preScale = this.scale
    curTranslate.x = this.translate.x
    curTranslate.y = this.translate.y
  }

  /**
   * 触摸开始
   */
  this.touchstart = (e) => {
    fingers = e.touches.length
    if (fingers > 2) return
    this.setDefault()
    // 单指
    if (fingers === 1) {
      startPoint.x = e.touches[0].x
      startPoint.y = e.touches[0].y
    } else if (fingers === 2) {
      startDistance = this.get2PointsDistance(e)
    }
  }

  /**
   * 触摸移动
   */
  this.touchmove = (e) => {
    if (fingers > 2) return
    if (this.isThrottleDraw) {
      if (drawTimer) return
      this.touchMoveEvent = e
      drawTimer = setTimeout(this.touchmoveSelf, this.throttleInterval)
    } else {
      this.touchMoveEvent = e
      this.touchmoveSelf()
    }
  }

  /**
   * 触摸移动实际执行
   */
  this.touchmoveSelf = () => {
    const e = this.touchMoveEvent
    // 单指移动
    if (fingers === 1) {
      this.translate.x = curTranslate.x + (e.touches[0].x - startPoint.x)
      this.translate.y = curTranslate.y + (e.touches[0].y - startPoint.y)
      this.draw()
    } else if (fingers === 2 && e.touches.length === 2) {
      // 双指缩放
      const newDistance = this.get2PointsDistance(e)
      const distanceDiff = newDistance - startDistance
      const zoomCenter = {
        x: (e.touches[0].x + e.touches[1].x) / 2,
        y: (e.touches[0].y + e.touches[1].y) / 2,
      }
      this.zoomTo(curScale + this.touchScaleStep * distanceDiff, zoomCenter)
    } else {
      drawTimer = null
    }
  }

  /**
   * 触摸结束
   */
  this.touchend = () => {
    if (this.isThrottleDraw) {
      touchEndTimer = setTimeout(this.setDefault, this.throttleInterval)
    } else {
      this.setDefault()
    }
  }

  /**
   * 销毁
   */
  this.destroy = () => {
    clearTimeout(drawTimer)
    clearTimeout(touchEndTimer)
    drawTimer = null
    touchEndTimer = null
    this.canvasNode = null
    this.ctx = null
    this.touchMoveEvent = null
    option.drawCallBack = null
  }

  /**
   * 获取2触摸点距离
   * @param {object} e 触摸对象
   * @returns 2触摸点距离
   */
  this.get2PointsDistance = (e) => {
    if (e.touches.length < 2) return 0
    const xMove = e.touches[1].x - e.touches[0].x
    const yMove = e.touches[1].y - e.touches[0].y
    return Math.sqrt(xMove * xMove + yMove * yMove)
  }
}

export default CanvasDraw

兄弟,如果帮到你,点个赞再走

12-13 18:34