小程序Canvas实现手写签名/旋转签名内容并转为图片

3,352 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

使用Canvas实现的手写签名,由page-container弹窗模拟全屏,在弹窗上横屏手写签名之后,将签名转为正常竖屏显示

效果

签名前横屏签名将横屏签名转正
微信图片_20220529202522.jpg微信图片_20220529202604.jpg微信图片_20220529202536.jpg

动画效果:

du85u-jfhy4.gif

实现思路

  1. 弹窗使用 page-container组件。好处是当用户进行返回操作,关闭该容器不关闭页面。返回操作包括三种情形,右滑手势、安卓物理返回键和调用 navigateBack 接口
  2. 使用一个canvas进行手写签名的绘制
  3. 将手写签名的canvas的内容转为图片,将图片旋转绘制到另一个canvas后,在将这个canvas转为图片,最终显示

这里用到了两个canvas,第一个是用来记录手写的,第二个是为了旋转签名内容的。因为用户有可能会重复签名,所以第一个canvas上手写的内容,需要保留,旋转图片只能另外用一个canvas,第二个canvas是隐藏的状态。

page-container

小程序如果在页面内进行复杂的界面设计(如在页面内弹出半屏的弹窗、在页面内加载一个全屏的子页面等),用户进行返回操作会直接离开当前页面,不符合用户预期,预期应为关闭当前弹出的组件。 为此提供“假页”容器组件,效果类似于 popup 弹出层,页面内存在该容器时,当用户进行返回操作,关闭该容器不关闭页面。返回操作包括三种情形,右滑手势、安卓物理返回键和调用 navigateBack 接口

相比自己实现的popup弹窗,其最大的特点就是能响应用户的返回操作,而不至于直接离开当前页面,简单用法:

<page-container show="{{showPage}}" close-on-slideDown="{{false}}">
  <view style="height: 100vh;">
      <!--弹窗内容-->
      <!--放置第一个canvas用于手写签名的绘制-->
  </view>
</page-container>

实现手写canvas

重点代码说明(完整代码请查看后文)

//创建 canvas 的绘图上下文 CanvasContext 对象,第一个参数为canvas 组件 canvas-id 属性值
this.canvasContext = wx.createCanvasContext('canvas');
/* 设置线条颜色 */
this.canvasContext.setStrokeStyle('#0081ff'); //2A2A2A
/* 设置线条粗细 */
this.canvasContext.setLineWidth(4);
/* 设置线条的结束端点样式 */
this.canvasContext.setLineCap('round');

/* 手写画线 */
this.canvasContext.moveTo(this.drawStartX, this.drawStartY);
this.canvasContext.lineTo(tempX, tempY);
this.canvasContext.stroke();
/* draw(xxx),参数默认是false,表示绘制前会将画布清空;true表示不清空画布。这里写true */
this.canvasContext.draw(true);

//清空画布,使用draw()即可,默认参数false,表示绘制前会将画布清空
this.canvasContext.draw();

签名旋转

由第一个canvas生成图片,在将这张图片旋转绘制到新的canvas

画布canvas内容导出生成图片使用wx.canvasToTempFilePath(Object object, Object this) 画布绘制图片使用drawImage方法

1653833707(1).jpg

图片需要旋转270°

1653837637(1).jpg

图片的旋转实际上是要旋转canvas,使用CanvasContext.rotate(number rotate),另外还需要将原点移动到中心,方便图片居中显示。原点移动使用CanvasContext.translate(number x, number y)。旋转后的canvas示意图如下所示:

1653837718(1).jpg

其他细节

  1. 在使用wx.canvasToTempFilePath()将canvas内容转为图片时,需要在 draw() 回调里调用该方法才能保证图片导出成功,示例
_this.canvas.draw(false, ()=>{
      wx.canvasToTempFilePath({})
})
  1. 隐藏canvas,可以使用下列代码
position:fixed;left:100%;

page-container与canvas搭配的bug??

大概率出现的情况:page-container设置为隐藏后,canvas画布没有隐藏,触摸一下屏幕,又隐藏了。目前解决方式就是隐藏page-container后把canvas的style设置为position:fixed;left:100%;,手动将canvas隐藏

微信图片_20220529202543111.jpg

完整代码

.js文件


Page({
  data: {
    sign: null,
    isSignatureFinish: false,
    moveLength: 0, //移动一定距离后才能提交保存
    showPage: false,
    canvasTempWidth: wx.getSystemInfoSync().screenWidth,
    canvasTempHeight: wx.getSystemInfoSync().screenWidth * 0.5,
  },

  onReady() {
    //第一个canvas
    this.canvasContext = wx.createCanvasContext('canvas');
    this.initCanvas()
    //第二个canvas
    this.ctx2 = wx.createCanvasContext('canvas-temp');
  },

  showSignPage() {
    this.setData({
      showPage: true
    });
  },
  hideSignPage() {
    this.setData({
      showPage: false
    });
  },
  confirmSign() {
    let _this = this
    _this.generateSignImage('canvas').then(res => {
      console.log('图片地址:', res)
 
      wx.getImageInfo({
        src: res,
        success(info) {
          let width = info.width
          let height = info.height
          //先重置canvas-temp
          _this.ctx2.draw(false, ()=>{
          let dx = _this.data.canvasTempWidth
          let dy = _this.data.canvasTempHeight
          //把原点移动到中心点位置
          _this.ctx2.translate(dx / 2, dy / 2)
          _this.ctx2.rotate(270 * Math.PI / 180)
          
          let dWidth = dx / height * width
          let dHeight = dx
          //drawImage参数说明
          //imageResource
          //imageResource的左上角在目标 canvas 上 x 轴的位置
          //imageResource的左上角在目标 canvas 上 y 轴的位置
          //在目标画布上绘制 imageResource 的宽度,允许对绘制的 imageResource 进行缩放
          //在目标画布上绘制 imageResource 的高度,允许对绘制的 imageResource 进行缩放
          
          _this.ctx2.drawImage(res, -dWidth / 2,-dHeight / 2, dWidth, dHeight)
          //canvas-temp图片绘制完成后
          _this.ctx2.draw(false, ()=>{
              _this.generateSignImage('canvas-temp').then(res => {
                console.log('图片地址:', res)
                _this.setData({
                  sign: res
                })
                _this.hideSignPage()
              })
          })
        })
        }
      })
    })
  },

  initCanvas() {
    /* 设置线条颜色 */
    this.canvasContext.setStrokeStyle('#0081ff'); //2A2A2A
    /* 设置线条粗细 */
    this.canvasContext.setLineWidth(4);
    /* 设置线条的结束端点样式 */
    this.canvasContext.setLineCap('round');
  },

  /* 触摸开始 */
  handleTouchStart(e) {
    console.log(e)
    this.drawStartX = e.changedTouches[0].x;
    this.drawStartY = e.changedTouches[0].y;
    console.log('触摸开始', this.drawStartX, this.drawStartY)
    this.canvasContext.beginPath();
  },
  /* 触摸移动 */
  handleTouchMove(e) {
    /* 记录当前位置 */
    const tempX = e.changedTouches[0].x;
    const tempY = e.changedTouches[0].y;
    // console.log('触摸移动', tempX, tempY)
    this.data.moveLength += Math.abs(this.drawStartX - tempX) + Math.abs(this.drawStartY - tempY)
    /* 画线 */
    this.canvasContext.moveTo(this.drawStartX, this.drawStartY);
    this.canvasContext.lineTo(tempX, tempY);
    this.canvasContext.stroke();

    /* 旧版draw方法,新版本不需要draw */
    this.canvasContext.draw(true);

    /* 重新记录起始位置 */
    this.drawStartX = tempX;
    this.drawStartY = tempY;
  },
  /* 触摸结束 */
  handleTouchEnd(e) {
    console.log('触摸结束')
    this.canvasContext.save();
    this.setData({
      isSignatureFinish: this.data.moveLength > 100
    })
  },
  /* 触摸取消 */
  handleTouchCancel(e) {
    console.log('触摸取消')
    this.canvasContext.save();
  },
  /* 清空画布 */
  clearCanvas() {
    console.log('清空画布')
    this.canvasContext.draw()
    this.initCanvas()
    this.data.moveLength = 0
    this.setData({
      isSignatureFinish: false
    })
  },
  /* 生成签名图片 */
  generateSignImage(canvasId) {
    console.log('Canvas生成图片')
    return new Promise((resolve, reject) => {
      wx.canvasToTempFilePath({
        x: 0,
        y: 0,
        canvasId: canvasId, // 旧版使用id
        fileType: 'png',
        quality: 1,
        success: res => {
          resolve(res.tempFilePath)
        },
        fail: err => {
          reject(err);
        }
      })
    })
  },

})

.wxml

<view class="image-container" bindtouchend="showSignPage">
  <image mode="aspectFit" src="{{sign}}" />
  <text wx:if="{{!sign}}" class="text-center">签名区域</text>
</view>

<button style="margin-top: 30rpx;" bindtap="submitSignature" disabled="{{!isSignatureFinish}}">提交保存</button>

<page-container show="{{showPage}}" overlay="{{false}}" round="{{false}}" close-on-slideDown="{{false}}" z-index="1000">
  <view class="flex" style="height: 100vh;">
    <view class="sign-btn-container">
      <view class="cu-btn" bindtap="hideSignPage">取消/返回</view>
      <view class="cu-btn {{isSignatureFinish?'bg-blue light':''}}" bindtap="clearCanvas">重新签名</view>
      <view class="cu-btn {{isSignatureFinish?'bg-blue light':''}}" bindtap="confirmSign">完成签名</view>
    </view>
    <canvas id="canvas" style="position:{{showPage?'relative':'fixed'}};left:{{showPage?'':'100%'}};" canvas-id="canvas" class="canvas" :disable-scroll="true" bindtouchstart="handleTouchStart" bindtouchmove="handleTouchMove" bindtouchend="handleTouchEnd" bindtouchcancel="handleTouchCancel" disable-scroll="true" />
  </view>
</page-container>

<canvas id="canvas-temp" style="width: {{canvasTempWidth}}px; height: {{canvasTempHeight}}px;" canvas-id="canvas-temp" class="canvas-temp"></canvas>

wxss

.flex{
  display: flex;
}

/* 签名 */
.canvas {
  width: 90vw;
  height: 100vh;
}

.canvas-temp {
  position:fixed;left:100%;
}

.sign-btn-container {
  display: flex;
  flex-direction: column;
  height: 100%;
  width: 10vw;
  justify-content: space-around;
}

.sign-btn-container view {
  transform: rotate(90deg);
  width: 100px;
  margin-left: -8vw;
}

.image-container {
  margin-top: 50rpx;
  width: 100%;
  height: 320rpx;
  position: relative;
}

.image-container image {
  border: 1rpx dashed #eee;
  position: absolute;
  z-index: 1;
  height: 320rpx;
  width: 98vw;
}

.image-container text {
  position: absolute;
  text-align: center;
  height: 100%;
  width: 100%;
  line-height: 320rpx;
  z-index: 2;
}

完整demo代码下载:wwnl.lanzoul.com/iIeEt0qr3xp…