详解canvas实现图片的拖拽、旋转、缩放(更新在Vue中使用的版本)

16,709 阅读15分钟

前言

这些功能是用在前几天写的一个小程序上的,所以本文是以小程序为主要框架讲解。但其实你不懂小程序也没关系,懂js就可以啦,重点讲如何用canvas实现这些功能,学会活学活用运算canvas的坐标才是关键。😄

更新记录

  • 更新了Vue版本,并且将Vue版本发布到线上了

线上预览

查看效果点击 线上预览【只支持移动端,PC端请切换到移动端,掘金自带的浏览器不能读取图片还请用别的浏览器】

效果示范

实现思路

总体思路为在index中获取图片,并push进数组中,将数组传入canvas-drag组件中,由它来实现拖拽缩放等功能

实现图片的上传与存储

//index.js
addImg(e){
    const this1 = this
    //小程序上传图片的api
    wx.chooseImage({
      count: 1,
      sizeType: ["original", "compressed"],
      sourceType: ["album"],
      success(res) {
        const tempFilePaths = res.tempFilePaths[0]
        //小程序获取图片信息的api
        wx.getImageInfo({
          src: tempFilePaths,
          success(res) {
            const {width,height} = res
            const scale = this1.getScale(width,height) //如果图片宽高太大会缩放,实现的思路有很多
            const obj = {
              width:width/scale,
              height:height/scale,
              url: tempFilePaths
            }
            const imgArr = this1.data.imgArr
            imgArr.push(obj)
            this1.setData({
              imgArr
            })
          }
        })
      }
    })
  }

获取canvas对象

在拖拽组件内通过小程序的apiwx.createCanvasContext(string canvasId, Object this)获取canvas对象,由于我们是在组件内调用,所以我们必须要把this传过去,表示在组件内部寻找对应id的canvas。

//canvas-drag.js
//在组件的生命周期ready中调用获取
ready(){
    this.data.ctx = wx.createCanvasContext("canvas",this);
  },

定义图片的类

这个类的构造函数接收两个参数,一个是包含图片宽高与链接的对象,一个是canvas对象。

class dragImg {
  constructor(img,ctx){
      //x y为初始坐标
      this.x = 100;
      this.y = 100;
      //w,h为初始宽高
      this.w = img.width;
      this.h = img.height;
      this.url = img.url;
      this.ctx = ctx;
      this.rotate = 0;
      this.selected = true;
  }
 }

接收并监听外部数组,并在canvas中绘制图片

api的介绍与类方法的定义

要在canvas上绘制图片,先从两个api说起

  • ctx.drawImage 描述或描绘图片到canvas。注意:小程序版本中先描述再描绘,非小程序版是直接描绘到画布上
  • ctx.draw 将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中。 注意:只在有小程序版本有此api
  • ctx.clearRect 清除指定区域内容 用在非小程序版重绘画布

我们在图片的类上定义一个类方法paint方法用于描述图片到canvas上

  paint() {
    //计算图片中心的坐标,后续要用上
    this.centerX = this.x + this.w / 2;
    this.centerY = this.y + this.h / 2;
    // 描述图片
    this.ctx.drawImage(this.url, this.x, this.y, this.w, this.h);
    // 如果是选中状态,绘制选择虚线框,和缩放图标、删除图标
    if (this.selected) {
      //对于canvas其他的描述api,因为不是重点就不详细描述出来,相信你们聪明得脑袋动动小手百度一下就懂了
      this.ctx.setLineDash([10, 10]);
      this.ctx.setLineWidth(2);
      this.ctx.setStrokeStyle("red");
      this.ctx.lineDashOffset = 10;
      this.ctx.strokeRect(this.x, this.y, this.w, this.h);
      this.ctx.drawImage(CloseIcon, this.x - 15, this.y - 15, 24, 24);
      this.ctx.drawImage(ScaleIcon, this.x + this.w - 15, this.y + this.h - 15, 24, 24);
    }
  }

小程序版本绘制与重绘

外部数组每次push进来一个图片item,组件内部就将其实例化并且执行实例内部的paint方法,最后调用canvas对象上的draw方法绘制到canvas上。但是由于调用draw方法后,描述的内容就清空了,意味着如果有新的图片item插入,下次执行draw方法的时候,之前描述的图片就不见了,这与我们要的效果不一致。
因此我们要将每个图片实例对象push到dragArr数组中管理,每次有新的图片item时,遍历dragArr数组,执行每个图片实例的paint方法,循环结束后再调用ctx的draw方法。由于这段绘制代码以后会重复使用,我们把他封装成一个方法来调用。

//canvas-drag.js
 properties: {
    imgArr: {
      type: Array,
      value: [],
      observer: "onArrChange"
    },
  },
  onArrChange(arr){
      if(arr.length){
        //取到新的图片item
        const newImg = arr.slice(-1)[0]
        const item = new dragImg(newImg, this.data.ctx)
        this.data.dragArr.push(item)
        this.draw()
      }
    },
  draw(){
    this.data.dragArr.forEach((item) => {
      item.paint()
    })
    this.data.ctx.draw()
  }

非小程序版本绘制与重绘

在非小程序版本中,如果你不清除画板,是会一直往里面绘制的,因此我们每次遍历数组重绘时都要先清空画板,达到与小程序版本一样的效果。

draw () {
      //清空画板
      this.ctx.clearRect(0, 0, this.c.width, this.c.height)
      this.dragArr.forEach((item) => {
        item.paint()
      })
      //由于ctx.draw是小程序独有的所以这里不再调用
    },

不管是绘制新的图片到canvas上,还是对图片进行平移旋转缩放这些重绘效果,我们最后通过调用draw方法来更新画板的内容 至此,我们就可以在canvas描绘图片了,让我们来看看效果

点击定位(初版)

问题来了,由于我们只能在canvas绑定点击事件,我们该怎么判断当前点击的地方是空白处还是图片处,如果在图片处,又是图片的哪里,是点击删除还是点击拖放或是点击图片本身。我们先来看看点击canvas后派发出来的点击事件有什么值吧。

可以看到,我们可以获取两个非常重要的参数,他们就是点击处位于canvas的xy坐标,通过计算点击处的xy坐标与每个图片对象内的坐标的关系,我们就可以判断究竟点击在哪了。

新的方法isInWhere

那怎么通过代码实现呢,这时我们要在图片的实例上新加一个方法isInWhere,通过传入点击处的xy坐标,返回xy坐标与该图片实例的关系。

isInWhere(x, y) {

    // 变换区域左上角的坐标和区域的高度宽度
    let transformW = 24;
    let transformH = 24;
    let transformX = this.x + this.w ;
    let transformY = this.y + this.h ;
    // 删除区域左上角的坐标和区域的高度宽度
    let delW = 24;
    let delH = 24;
    let delX = this.x ;
    let delY = this.y ;
    移动区域的坐标
    let moveX = this.x;
    let moveY = this.y;
    if (x - transformX >= 0 && y - transformY >= 0 && transformX + transformW - x >= 0 && transformY + transformH - y >= 0) {
      // 缩放区域
      return "transform";
    }
    else if (x - delX >= 0 && y - delY >= 0 && delX + delW - x >= 0 && delY + delH - y >= 0) {
      // 删除区域
      return "del";
    }
    else if (x - moveX >= 0 && y - moveY >= 0 && moveX + this.w - x >= 0 && moveY + this.h - y >= 0) {
      // 移动区域
      return "move";
    }
    // 不在选择区域里面
    return false;
  }

在点击时调用

在canvas上绑定点击事件,每次点击时遍历dragArr数组,调用每个实例的isInWhere方法

start(e){
      const {x,y} = e.touches[0]
      this.data.dragArr.forEach((item)=>{
        const place = item.isInWhere(x,y)
      })
    }

console.log(place)打印结果看看效果

多个图层下的点击

这时有细心的童鞋就要问了,要是点击的地方有多个图层,岂不是要直接起飞

先别急着飞飞飞飞飞飞飞,马上交出解决方案,补全代码。
假如点击处有多个图片,我们只取到图层最高的图片实例即可

   start(e) {
      //初始化一个数组用于存放所有被点击到的图片对象
      this.data.clickedkArr = []
      const { x, y } = e.touches[0]
      this.data.dragArr.forEach((item) => {
        const place = item.isInWhere(x, y)
        item.place = place
        //先将所有的item的selected变为flase
        item.selected = false
        if (place==='move'&&'transform') {
          //如果place不是false或者del就push进这个数组中
          this.data.clickedkArr.push(item)
        }
      })
      const length = this.data.clickedkArr.length
      if (length) {
        //我们知道cavans绘制的图片的层级是越来越高的,因此我们取这个数组的最后一项,保证取到的图片实例是层级最高的
        const lastImg = this.data.clickedkArr[length - 1]
        //将该实例的被选值设为true,下次重新绘制将绘制边框
        lastImg.selected = true
        //保存这个选中的实例
        this.data.lastImg = lastImg
        //保存这个实例的初始值,以后会用上
        this.data.initialXY = {
          initialX: lastImg.x,
          initialY: lastImg.y,          
          initialH:lastImg.h,
          initialW:lastImg.w,
          initialRotate:lastImg.rotate
        }
      }
      //重新绘制
      this.draw()
      //保存点击的坐标,move时要用
      this.data.startTouch = { startX : x, startY : y }
    },

好了,我们来看看效果

实现平移效果

平移只要根据平移量来改变选中的实例中的x与y坐标,然后重新绘制即可。

move(e) {
      const { x, y } = e.touches[0]
      const { initialX, initialY } = this.data.initialXY
      const { startX, startY } = this.data.startTouch
      const lastImg = this.data.lastImg
      if (this.data.clickedkArr.length) {
        if (palce) {
          //算出移动后的xy坐标与点击时xy坐标的差(即平移量)与图片对象的初始坐标相加即可
          lastImg.x = initialX + (x - startX)
          lastImg.y = initialY + (y - startY)
        }
        //transform后续将补全
        if (this.data.lastImg.place === 'transform') {
        }        
        this.draw()
      }
    }

康康效果

实现旋转效果

为了实现旋转,我们先介绍需要用到的关键方法函数

  • Math.atan2 用于计算手指滑动时移动的角度
  • ctx.rotate 用于旋转canvas

利用Math.atan2计算旋转角度

语法:Math.atan2(y,x) 注意:这里的第一个参数是y的坐标
根据MDN的说法,atan2方法返回一个-pi 到pi之间的数值,表示点 (x, y) 对应的偏移角度。嗯...这谁听得懂,我们需要有图有真相

可以看到atan2( √3/2 , 1/2 )=π / 3,即坐标( 1/2 , √3/2 )的点与原点( 0 , 0 )确立出的直线与x轴形成的倾斜角为60度,所以原式又可以写成这样atan2( √3/2 - 0 , 1/2 - 0 ) = π / 3。
明白了这个函数的使用方法后,我们就可以计算出手指的坐标与图片中心坐标形成的角度了。

    //接上段move中的transform
    if (this.data.lastImg.place === 'transform'){
      const { centerX, centerY }= lastImg
      const { initialRotate } = this.data.initial
      //算出手指按下时形成的角度,注意y坐标在第一个参数
      const angleBefore = Math.atan2(startY - centerY, startX - centerX) / Math.PI * 180;
      //算出手指移动时形成的角度,注意y坐标在第一个参数
      const angleAfter = Math.atan2(y - centerY, x - centerX) / Math.PI * 180;
      // 旋转的角度
      lastImg.rotate = initialRotate + angleAfter - angleBefore;
    }
    this.draw()

利用ctx.rotate旋转图片

好了,我们知道了旋转的角度之后,就可以用ctx.rotate来实现旋转了。让我们先来了解一下这个api吧。
ctx.rotate是用于旋转画布的坐标轴并非是旋转里面某个图片,以画布的原点,就是左上角(0,0)进行旋转(当然这个原点是可以更改的),我们在新的坐标轴上描述图片,因此就看起来就像是图片被旋转了一样。
从上文中看到,我们的旋转是以图片的中心来进行旋转的,而rotate默认以原点(0,0)旋转坐标轴明显是不符合我们的意愿的。我们来看看如果以原点来旋转会发生什么样的事情

因此我们要将旋转点变更至图片的中点,这时又涉及另一个api了,那就是ctx.translate()。顾名思义,就是用来平移画布原点的方法。我们将画布的原点平移至图片的中点,旋转坐标轴后,又平移回来(因为后续描述图片还是得以(0,0)为原点。
说到将原点平移回来,细心的童鞋们是不是想问:是不是还要将坐标轴旋转回来?好了各位陈独秀们可以坐下来了😂,如你们所说我们还要讲坐标轴旋转回来,否则在描绘多张图片下,将牵一发而动全身。因此我们使用ctx.save()来保存初始的rotate状态,使用ctx.restore()将旋转后的rotate状态恢复成初始的状态。附上补全的paint绘制函数

  paint() {
    this.ctx.save();
    this.centerX = this.x + this.w / 2;
    this.centerY = this.y + this.h / 2;
    // 变更原点至图片的中点
    this.ctx.translate(this.centerX, this.centerY);
    //根据transform的旋转角度旋转坐标轴
    this.ctx.rotate(this.rotate * Math.PI / 180);
    //变更回来
    this.ctx.translate(-this.centerX, -this.centerY);
    // 描绘图片
    this.ctx.drawImage(this.url, this.x, this.y, this.w, this.h);
    // 如果是选中状态,绘制选择虚线框,和缩放图标、删除图标
    if (this.selected) {
      this.ctx.setLineDash([10, 10]);
      this.ctx.setLineWidth(2);
      this.ctx.setStrokeStyle("red");
      this.ctx.lineDashOffset = 10;
      this.ctx.strokeRect(this.x, this.y, this.w, this.h);
      this.ctx.drawImage(CloseIcon, this.x - 15, this.y - 15, 24, 24);
      this.ctx.drawImage(ScaleIcon, this.x + this.w - 15, this.y + this.h - 15, 24, 24);
    }
    this.ctx.restore();
  }

好了,又到了我们的杰哥不要环节(误)

解决旋转后出现的问题(重难点)

我们看到在上一步我们已经实现了图片的旋转,但是从现在开始才是本篇的核心。什么是旋转后存在的问题,我们来看一张图。

可以看到旋转之后,图片坐标其实并没有改变,在我们计算手指按下时的坐标与图标的位置关系时,我们仍然是根据旋转之前的状态来计算的,因为我们之前计算位置的方法是没有用上rotate角度来计算,相当于每次计算时其实都是按照rotate=0来计算,导致图标在旋转之后还是要点击原来图标所在的位置进行下一次旋转。所以我们要根据rotate属性算出旋转后的图标的坐标究竟在哪。
思考一下,既然我们可以根据两点算出一个角度,那我们能不能根据一点,一个角度,算出另一个点的坐标呢。答案是可以的。
我的思路是这样的,先求出图标的初始角度,即图片第一次绘制出来时,图标的坐标相对图片中心坐标形成的角度,将该角度加上图片旋转角度,即可求出图标的旋转后的角度。再根据一些简单的高中正弦余弦定理,就可以求出旋转后图标的坐标了。补全isInWhere代码

  isInWhere(x, y) {
    // 变换区域左上角的坐标和区域的高度宽度
    let transformW = 24,transformH = 24;
    let transformX = this.x + this.w ;
    let transformY = this.y + this.h ;
    //获得图标旋转后的角度,等于初始角度+图片旋转角度
    let transformAngle = Math.atan2(transformY - this.centerY, transformX - this.centerX) / Math.PI * 180 + this.rotate
    //获得该角度下图标的xy坐标
    let transformXY = this.getTransform(transformX, transformY, transformAngle);
    //将新的坐标赋值给坐标变量
    transformX = transformXY.x, transformY = transformXY.y
    // 删除区域左上角的坐标和区域的高度宽度,删除坐标的计算与上方如法炮制
    let delW = 24;
    let delH = 24;
    let delX = this.x;
    let delY = this.y;
    let delAngle = Math.atan2(delY - this.centerY, delX - this.centerX) / Math.PI * 180 + this.rotate
    let delXY = this.getTransform(delX, delY, delAngle);
    delX = delXY.x, delY = delXY.y
    //移动区域的坐标
    let moveX = this.x;
    let moveY = this.y;
    if (x - transformX >= 0 && y - transformY >= 0 && transformX + transformW - x >= 0 && transformY + transformH - y >= 0) {
      // 缩放区域
      return "transform";
    }
    else if (x - delX >= 0 && y - delY >= 0 && delX + delW - x >= 0 && delY + delH - y >= 0) {
      // 删除区域
      return "del";
    }
    else if (x - moveX >= 0 && y - moveY >= 0 && moveX + this.w - x >= 0 && moveY + this.h - y >= 0) {
      // 移动区域
      return "move";
    }
    // 不在选择区域里面
    return false;
  }
 /**
 * 求新坐标
 * @param {*} x 初始x坐标
 * @param {*} y 初始y坐标
 * @param {*} rotate 图标旋转后的角度
 */
  getTransform(x, y, rotate) {
    //将角度化为弧度
    var angle = Math.PI / 180 * rotate;
    //初始坐标与中点形成的直线长度不管怎么旋转都是不会变的,用勾股定理求出然后将其作为斜边
    var r = Math.sqrt(Math.pow(x - this.centerX, 2) + Math.pow(y - this.centerY, 2));
    //斜边乘sin值等于即可求出y坐标
    var a = Math.sin(angle) * r;
    //斜边乘cos值等于即可求出x坐标
    var b = Math.cos(angle) * r;
    //目前的xy坐标是相对于图片中点为原点的坐标轴,而我们的主坐标轴是canvas的坐标轴,所以要加上中点的坐标值才是标准的canvas坐标
    return {
      x: this.centerX + b-12,
      y: this.centerY + a-12
    };
  }

呼~难度一下子增加了,让我们来看看有没有成功吧

功夫不负有心人,终于可以正确的计算旋转后的坐标了~让我们一鼓作气把剩余的部分弄完吧

实现缩放效果

缩放效果其实也不难,我们看到缩放时,中心的坐标是不变的,也就是说相对的改变xy的坐标与图片的宽高保持中心坐标不变。这是一个很简单的数学问题,x + w / 2 = centerX ,假设w增加了10,要使得centerX不变,x就应该减去5。我们以手指移动时的坐标到中心坐标的直线距离减去手指第一次按下的坐标到中心坐标的直线距离所得的差作为增量或者减量,改变x,y,w,h的值实现缩放。

      //缩放部分
      const { initialH, initialW } = this.data.initial
      //用勾股定理算出距离
      let lineA = Math.sqrt(Math.pow(centerX - startX, 2) + Math.pow(centerY - startY, 2));
      let lineB = Math.sqrt(Math.pow(centerX - x, 2) + Math.pow(centerY - y, 2));
      let w = initialW + (lineB - lineA);
      //由于是等比缩放,所以乘一个宽高比例。
      let h = initialH + (lineB - lineA) * (initialH / initialW);
      //定义最小宽高
      lastImg.w = w <= 5 ? 5 : w;
      lastImg.h = h <= 5 ? 5 : h;
      if (w > 5 && h > 5) {
        // x与y减去增量的一半保持中心坐标不变
        lastImg.x = initialX - (lineB - lineA) / 2;
        lastImg.y = initialY - (lineB - lineA) / 2;
      }

效果:

实现删除图片(送分题)

终于迎来最后一个功能了,这个功能有很多种方法实现,我说下我的思路吧,当图片被点击时,在图片实例对象上保存他的index,然后判断点击的地方如果是del的话,就在dragArr通过index找到该实例然后删除,然后调用draw()更新画布。

if(lastImg.place ==='del'){
          this.data.dragArr.splice(lastImg.index,1)
          //重新绘制
          this.draw()
          return
        }

总结

我们可以看到,对于这些功能的实现,基本都离不开对坐标的运算,所以基本的数学知识还是不能还太多给老师呀(上个学期高63分擦边过高数的我如实说),当然也需要懂得一些便利的api的使用以及对canvas有一定基础的了解。其实整个功能流程做下来的话,会遇到不少瓶颈与坑,这些都是我们需要克服与挑战的地方,然后总结整理一下免得下次再踩坑。写的不好的地方,欢迎大佬们批评指正,我也是第一次写这么长的文章。谢谢大家能看到这里啦,如果觉得写得还不错的话,希望可以点个赞支持一下。(//▽//)

完整代码

github(小程序版本)
github(Vue版本)
如果对您有帮助,希望可以得到一枚您的Star~。(〃'▽'〃)