乐于分享
好东西不私藏

手把手打造跨平台自由画板组件(UniApp实战)

手把手打造跨平台自由画板组件(UniApp实战)

书接上篇,我们今天继续来挑战来挑战一个稍微复杂一点但很有意思的组件:自由画板。它可以让你在屏幕上随心所欲地绘制线条、形状,甚至签名,还能擦除、清空,并且支持跨平台(H5/小程序/App),附带实现思路和所有源码示例,以及常见避坑指南和开发实践。

此系列文章将带领你从移动端跨平台开发入门到精通,如果你也喜欢关注APP、小程序、公众号、H5等等应用的开发,可以持续关注后续更新,避免错过宝贵的知识分享。

致开发者的忠告: AI编程盛行的今天,我们并不是不需要学习技术,而是更应该专研技术,拥有把控全局的架构设计思维才能在AI盛行的未来有立足之地。

言归正传,咱们今天继续来聊一聊如何实现一个自由画板组件,支持绘制线条、各种常见形状的组件,且可以用于签名和简单的图片绘制功能,本文仅做抛砖引玉,提供基础实现0到1,1到100还得各位看官继续深入研究,再接再厉!

你可能会想:“画板不就是用canvas画图吗?有什么难的?”但等你真正开始写,就会发现:

  • 不同平台的canvas API有差异,有的需要draw,有的直接渲染。

  • 触摸事件坐标转换要小心,canvas坐标系和设备像素比搞错就画歪了。

  • 想要管理多个图形(矩形、圆形、线条)并支持层级调整,得设计好数据结构。

  • 橡皮擦怎么实现?是擦除整个图形还是擦除像素?

  • 跨端保存图片时,canvasToTempFilePath的参数各不相同。

别担心,今天我就带你一步步实现一个功能完整的画板组件,并解决上述所有问题。


一、需求分析:我们要做什么样的画板?

在动手前,先明确功能清单:

  • 绘制工具:画笔(自由线条)、矩形、圆形、三角形、扇形、椭圆形。

  • 图形管理:每个图形独立对象,支持调整层级(上移/下移/置顶/置底)。

  • 签名模式:自由绘制,可以连续画线。

  • 擦除模式:类似橡皮擦,擦除笔画(像素级擦除)。

  • 清空画板:一键清除所有内容。

  • 撤销/重做(可选,但可以后续扩展)。

  • 保存图片:将画板内容保存为图片。

跨平台要求:H5、微信小程序、App(iOS/Android)


二、核心难点与设计思路

2.1 图形对象模型

为了支持独立图形的管理和层级,我们需要为每个绘制的图形创建一个对象,存储其类型、坐标、样式等信息。所有图形按绘制顺序存放在一个数组中,数组索引即层级(后面的在上层)。

interfaceShape {
idstring;           // 唯一标识
typestring;         // 'line', 'rect', 'circle', 'triangle', 'sector', 'ellipse'
points?number[];    // 自由线条的点集
x?number;           // 矩形/椭圆/扇形/三角形的起点x
y?number;           // 起点y
width?number;       // 矩形/椭圆的宽
height?number;      // 矩形/椭圆的高
radius?number;      // 圆形半径
startAngle?number;  // 扇形起始角度
endAngle?number;    // 扇形结束角度
style: {
colorstring;      // 填充/描边颜色
lineWidthnumber;  // 线宽
fillboolean;      // 是否填充
strokeboolean;    // 是否描边
  };
}

绘制时,遍历shapes数组依次绘制。

2.2 橡皮擦如何实现?

考虑两种橡皮擦模式:

  • 对象擦除:点击图形选中,删除该对象。适合矢量图形管理。

  • 像素擦除:像真正的橡皮擦,擦除画笔痕迹。

为了简单且符合直觉,我们提供两种模式:

  • 图形选择模式:点击图形高亮,按删除键删除该图形。

  • 橡皮擦模式:以圆形区域擦除像素(通过globalCompositeOperation = 'destination-out')。

destination-out在不同平台表现一致,需要注意。擦除后无法恢复,所以最好配合撤销功能。我们将在基础版本中实现像素橡皮擦,后续可以扩展撤销。

2.3 触摸坐标转换

canvas的绘图坐标需要相对于canvas元素,且要考虑设备像素比(dpr)来保证高清显示。

在触摸事件中,我们获取到的是屏幕坐标,需要通过uni.createSelectorQuery()获取canvas的边界,然后转换为canvas相对坐标。

functiongetCanvasCoords(e) {
consttouch=e.touches[0];
returnnewPromise((resolve=> {
uni.createSelectorQuery().in(this).select('#canvas').boundingClientRect(rect=> {
constx= (touch.clientX-rect.left*dpr;
consty= (touch.clientY-rect.top*dpr;
resolve({ xy });
    }).exec();
  });
}

注意:dpr是设备像素比,canvas的宽度和高度应设置为width * dpr,CSS宽度设置为width,以保证清晰。

2.4 跨平台canvas API差异

  • 小程序:通过uni.createCanvasContext创建上下文,绘图命令需要调用ctx.draw()才会实际渲染。

  • H5/App:直接使用标准canvas API,绘图即实时渲染。

为了统一,我们可以在每次绘制后调用draw,但H5中多余的draw会报错。所以我们采用双模式:如果是小程序环境,绘图后调用ctx.draw();H5环境直接渲染。

更好的办法是抽象渲染层:用一个函数封装绘制操作,内部判断环境。

2.5 图形层级调整

因为图形存储在数组中,数组索引决定了绘制顺序。调整层级只需移动数组元素。

// 上移一层
functionmoveUp(index) {
if (index<shapes.value.length-1) {
consttemp=shapes.value[index];
shapes.value[index=shapes.value[index+1];
shapes.value[index+1=temp;
  }
}
// 置顶
functionmoveTop(index) {
constitem=shapes.value.splice(index1)[0];
shapes.value.push(item);
}

2.6 撤销/重做

可以利用历史栈存储shapes数组的快照。


三、组件设计

3.1 Props

参数 类型 默认值 说明
width Number 300 canvas宽度(px)
height Number 300 canvas高度(px)
tool String ‘brush’ 当前工具:brush/rect/circle/triangle/sector/ellipse/eraser
color String #000000 画笔颜色
lineWidth Number 2 线宽
fill Boolean false 是否填充(对矩形/圆形有效)

3.2 Events

事件名 说明
change 画板内容变化时触发
save 保存图片时返回临时路径

3.3 内部状态

constshapes=ref([])         // 图形数组
constcurrentShape=ref(null// 正在绘制的图形(用于预览)
consthistory=ref([])        // 历史记录

3.4 方法

  • clear(): 清空画板

  • undo(): 撤销

  • redo(): 重做

  • saveAsImage(): 保存图片

  • moveUp(index)moveDown(index)moveTop(index)moveBottom(index): 层级调整


四、完整代码实现

下面给出画板组件的完整代码,已考虑跨平台兼容。

4.1 组件:FreeDraw.vue

<template>
<viewclass="free-draw">
<!-- 画板容器 -->
<viewclass="canvas-container">
<canvas
id="freeCanvas"
class="canvas"
:style="{ width: width + 'px', height: height + 'px' }"
:canvas-id="canvasId"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@error="onError"
></canvas>
</view>

<!-- 工具栏(示例,可根据需要扩展) -->
<viewclass="toolbar">
<button@click="setTool('brush')">画笔</button>
<button@click="setTool('rect')">矩形</button>
<button@click="setTool('circle')">圆形</button>
<button@click="setTool('triangle')">三角形</button>
<button@click="setTool('sector')">扇形</button>
<button@click="setTool('ellipse')">椭圆</button>
<button@click="setTool('eraser')">橡皮擦</button>
<button@click="clear">清空</button>
<button@click="undo">撤销</button>
<button@click="redo">重做</button>
<button@click="save">保存</button>
</view>
</view>
</template>

<scriptsetup>
import { refonMountedwatchnextTick } from'vue'

constprops=defineProps({
width: { typeNumberdefault300 },
height: { typeNumberdefault300 },
tool: { typeStringdefault'brush' },
color: { typeStringdefault'#000000' },
lineWidth: { typeNumberdefault2 },
fill: { typeBooleandefaultfalse }
})

constemit=defineEmits(['change''save'])

// canvas相关
constcanvasId=`canvas-${Date.now()}-${Math.random()}`
letctx=null
letdpr=1// 设备像素比

// 画板数据
constshapes=ref([])               // 图形数组
constcurrentShape=ref(null)       // 当前正在绘制的图形
consthistory=ref([])              // 历史栈(用于撤销)
consthistoryIndex=ref(-1)          // 当前历史位置

// 触摸状态
letisDrawing=false
letlastPoint= { x0y0 }

// 初始化canvas
onMounted(async () => {
// 获取设备像素比
constsystemInfo=awaituni.getSystemInfo()
dpr=systemInfo.pixelRatio

// 获取canvas上下文
ctx=uni.createCanvasContext(canvasIdgetCurrentInstance().proxy)
// 设置canvas实际尺寸(高清)
constcanvas=awaitgetCanvasNode()
if (canvas) {
canvas.width=props.width*dpr
canvas.height=props.height*dpr
  }

// 初始绘制
redraw()
})

// 获取canvas节点(兼容小程序和H5)
functiongetCanvasNode() {
returnnewPromise((resolve=> {
constquery=uni.createSelectorQuery().in(getCurrentInstance().proxy)
query.select(`#${canvasId}`).node(res=> {
if (res&&res.node) {
// H5/App通过node获取
resolve(res.node)
      } else {
// 小程序可能不支持node方式,我们通过context设置尺寸
resolve(null)
      }
    }).exec()
  })
}

// 设置工具(由父组件传入或内部调用)
constsetTool= (tool=> {
// 可以通过 emit 通知父组件,或直接修改内部状态
// 这里假设由父组件控制,所以不实现
}

// 监听工具变化,可能需要重置状态
watch(() =>props.tool, () => {
// 切换工具时,可能结束当前绘制
if (currentShape.value) {
// 取消当前绘制
currentShape.value=null
redraw()
  }
})

// 触摸事件处理
constonTouchStart=async (e=> {
constpoint=awaitgetCanvasCoords(e)
isDrawing=true
lastPoint=point

if (props.tool==='brush'||props.tool==='eraser') {
// 开始新路径
currentShape.value= {
idDate.now() +Math.random(),
typeprops.tool==='brush'?'path' : 'eraserPath',
points: [point.xpoint.y],
style: {
colorprops.tool==='eraser'?'#ffffff' : props.color,
lineWidthprops.lineWidth,
fillfalse,
stroketrue
      }
    }
  } else {
// 矢量图形:记录起点
currentShape.value= {
idDate.now() +Math.random(),
typeprops.tool,
xpoint.x,
ypoint.y,
width0,
height0,
style: {
colorprops.color,
lineWidthprops.lineWidth,
fillprops.fill,
stroke!props.fill
      }
    }
  }
}

constonTouchMove=async (e=> {
if (!isDrawing||!currentShape.valuereturn
e.preventDefault()

constpoint=awaitgetCanvasCoords(e)

if (currentShape.value.type==='path'||currentShape.value.type==='eraserPath') {
// 路径:添加点
currentShape.value.points.push(point.xpoint.y)
// 实时绘制路径
drawTemp()
  } else {
// 矢量图形:根据起点和当前点计算尺寸
conststartX=currentShape.value.x
conststartY=currentShape.value.y
currentShape.value.width=point.x-startX
currentShape.value.height=point.y-startY
drawTemp()
  }
lastPoint=point
}

constonTouchEnd= () => {
if (!currentShape.valuereturn

// 完成绘制,将当前图形加入shapes数组
shapes.value.push({ ...currentShape.value })
currentShape.value=null
// 记录历史
pushHistory()
// 重绘所有
redraw()
emit('change'shapes.value)
isDrawing=false
}

// 将触摸点转换为canvas坐标(考虑dpr和边界)
functiongetCanvasCoords(e) {
returnnewPromise((resolve=> {
constquery=uni.createSelectorQuery().in(getCurrentInstance().proxy)
query.select(`#${canvasId}`).boundingClientRect(rect=> {
if (!rectreturnresolve({ x0y0 })
consttouch=e.touches[0]
constx= (touch.clientX-rect.left*dpr
consty= (touch.clientY-rect.top*dpr
resolve({ xy })
    }).exec()
  })
}

// 绘制临时图形(仅当前正在绘制的)
functiondrawTemp() {
redraw(false// 先重绘所有固定图形
if (currentShape.value) {
drawShape(currentShape.value)
  }
drawToCanvas()
}

// 绘制所有图形
functionredraw(includeTemp=true) {
// 清空画布
ctx.clearRect(00props.width*dprprops.height*dpr)

// 绘制所有已完成的图形
shapes.value.forEach(shape=>drawShape(shape))

// 如果需要,绘制临时图形
if (includeTemp&&currentShape.value) {
drawShape(currentShape.value)
  }

drawToCanvas()
}

// 绘制单个图形
functiondrawShape(shape) {
ctx.save()
ctx.lineWidth=shape.style.lineWidth
ctx.strokeStyle=shape.style.color
ctx.fillStyle=shape.style.fill?shape.style.color : 'transparent'

switch (shape.type) {
case'path':
case'eraserPath':
drawPath(shape)
break
case'rect':
drawRect(shape)
break
case'circle':
drawCircle(shape)
break
case'triangle':
drawTriangle(shape)
break
case'sector':
drawSector(shape)
break
case'ellipse':
drawEllipse(shape)
break
  }
ctx.restore()
}

// 绘制路径
functiondrawPath(shape) {
constpoints=shape.points
if (points.length<4return
ctx.beginPath()
ctx.moveTo(points[0], points[1])
for (leti=2i<points.lengthi+=2) {
ctx.lineTo(points[i], points[i+1])
  }
ctx.stroke()
}

// 绘制矩形
functiondrawRect(shape) {
const { xywidthheightstyle } =shape
if (style.fill) {
ctx.fillRect(xywidthheight)
  }
if (style.stroke) {
ctx.strokeRect(xywidthheight)
  }
}

// 绘制圆形
functiondrawCircle(shape) {
const { xywidthheight } =shape
// 以中心为基点?我们使用起点和当前点计算半径
constradius=Math.hypot(widthheight/2
constcenterX=x+width/2
constcenterY=y+height/2
ctx.beginPath()
ctx.arc(centerXcenterYradius0Math.PI*2)
if (shape.style.fillctx.fill()
if (shape.style.strokectx.stroke()
}

// 绘制三角形
functiondrawTriangle(shape) {
const { xywidthheight } =shape
ctx.beginPath()
ctx.moveTo(x+width/2y)
ctx.lineTo(x+widthy+height)
ctx.lineTo(xy+height)
ctx.closePath()
if (shape.style.fillctx.fill()
if (shape.style.strokectx.stroke()
}

// 绘制扇形(简化:以起点为中心,宽度为半径)
functiondrawSector(shape) {
const { xywidthheight } =shape
constradius=Math.hypot(widthheight)
constcenterX=x
constcenterY=y
conststartAngle=0
constendAngle=Math.PI/2// 简化,实际上可以根据需要设置
ctx.beginPath()
ctx.moveTo(centerXcenterY)
ctx.arc(centerXcenterYradiusstartAngleendAngle)
ctx.closePath()
if (shape.style.fillctx.fill()
if (shape.style.strokectx.stroke()
}

// 绘制椭圆
functiondrawEllipse(shape) {
const { xywidthheight } =shape
constcenterX=x+width/2
constcenterY=y+height/2
constrx=Math.abs(width/2
constry=Math.abs(height/2
ctx.save()
ctx.translate(centerXcenterY)
ctx.scale(1ry/rx// 简化,用scale近似椭圆
ctx.beginPath()
ctx.arc(00rx02*Math.PI)
ctx.restore()
if (shape.style.fillctx.fill()
if (shape.style.strokectx.stroke()
}

// 将上下文绘制到canvas(小程序需要draw)
functiondrawToCanvas() {
// #ifdef MP-WEIXIN
ctx.draw(false)
// #endif
// #ifndef MP-WEIXIN
// H5/App不需要额外操作,已经实时渲染
// #endif
}

// 历史记录
functionpushHistory() {
constsnapshot=JSON.parse(JSON.stringify(shapes.value))
history.value=history.value.slice(0historyIndex.value+1)
history.value.push(snapshot)
historyIndex.value++
}

// 撤销
functionundo() {
if (historyIndex.value>0) {
historyIndex.value--
shapes.value=JSON.parse(JSON.stringify(history.value[historyIndex.value]))
redraw()
emit('change'shapes.value)
  }
}

// 重做
functionredo() {
if (historyIndex.value<history.value.length-1) {
historyIndex.value++
shapes.value=JSON.parse(JSON.stringify(history.value[historyIndex.value]))
redraw()
emit('change'shapes.value)
  }
}

// 清空画板
functionclear() {
shapes.value= []
currentShape.value=null
history.value= []
historyIndex.value=-1
redraw()
emit('change'shapes.value)
}

// 保存图片
functionsave() {
uni.canvasToTempFilePath({
canvasIdcanvasId,
success: (res=> {
emit('save'res.tempFilePath)
uni.previewImage({ urls: [res.tempFilePath] })
    },
fail: (err=> {
console.error('保存失败'err)
    }
  }, getCurrentInstance().proxy)
}

// 层级调整
constmoveUp= (index=> {
if (index<shapes.value.length-1) {
consttemp=shapes.value[index]
shapes.value[index=shapes.value[index+1]
shapes.value[index+1=temp
pushHistory()
redraw()
  }
}

constmoveDown= (index=> {
if (index>0) {
consttemp=shapes.value[index]
shapes.value[index=shapes.value[index-1]
shapes.value[index-1=temp
pushHistory()
redraw()
  }
}

constmoveTop= (index=> {
constitem=shapes.value.splice(index1)[0]
shapes.value.push(item)
pushHistory()
redraw()
}

constmoveBottom= (index=> {
constitem=shapes.value.splice(index1)[0]
shapes.value.unshift(item)
pushHistory()
redraw()
}

// 错误处理
functiononError(e) {
console.error('canvas错误'e)
}

// 暴露方法给父组件
defineExpose({
clear,
undo,
redo,
save,
moveUp,
moveDown,
moveTop,
moveBottom
})
</script>

<stylescoped>
.free-draw {
displayflex;
flex-directioncolumn;
}
.canvas-container {
background-color#f5f5f5;
border1pxsolid#ddd;
positionrelative;
}
.canvas {
displayblock;
background-color#fff;
}
.toolbar {
displayflex;
flex-wrapwrap;
gap5px;
margin-top10px;
}
.toolbarbutton {
padding5px10px;
font-size12px;
}
</style>

4.2 使用示例

<template>
<view>
<FreeDraw
ref="drawRef"
:width="300"
:height="400"
:tool="currentTool"
:color="color"
:lineWidth="lineWidth"
:fill="fill"
@change="onChange"
@save="onSave"
/>
<viewclass="controls">
<button@click="setTool('brush')">画笔</button>
<button@click="setTool('rect')">矩形</button>
<button@click="setTool('circle')">圆形</button>
<button@click="setTool('eraser')">橡皮擦</button>
<button@click="clear">清空</button>
<button@click="undo">撤销</button>
<button@click="redo">重做</button>
<button@click="save">保存</button>
</view>
</view>
</template>

<scriptsetup>
import { ref } from'vue'
importFreeDrawfrom'@/components/FreeDraw.vue'

constdrawRef=ref(null)
constcurrentTool=ref('brush')
constcolor=ref('#ff0000')
constlineWidth=ref(2)
constfill=ref(false)

constsetTool= (tool=> {
currentTool.value=tool
}
constclear= () => {
drawRef.value?.clear()
}
constundo= () => {
drawRef.value?.undo()
}
constredo= () => {
drawRef.value?.redo()
}
constsave= () => {
drawRef.value?.save()
}
constonChange= (shapes=> {
console.log('画板内容更新'shapes)
}
constonSave= (path=> {
console.log('保存路径'path)
}
</script>

五、常见错误与解决方案

❌ 错误1:画板内容模糊

现象:绘制的线条边缘模糊,像锯齿。原因:canvas实际像素尺寸与CSS尺寸不一致,没有考虑设备像素比。解决方案:设置canvas的widthheight属性为物理像素尺寸(CSS宽度 * dpr),绘图时坐标也乘以dpr

❌ 错误2:触摸点与画线位置偏移

现象:画线时,线条起点不在手指触摸点。原因:触摸坐标没有转换为canvas相对坐标。解决方案:用uni.createSelectorQuery获取canvas边界,减去偏移量。

❌ 错误3:小程序中绘图不显示

现象:所有绘图命令执行后,画布空白。原因:小程序需要调用ctx.draw()才会实际渲染。解决方案:每次绘图后调用ctx.draw(false)

❌ 错误4:橡皮擦擦出白色背景,但背景不是白色

现象:橡皮擦模式下,擦除后露出黑色或其他颜色。原因:我们设置了画笔颜色为白色,但画板背景是白色,所以看起来像擦除。如果背景有颜色,这种方法不适用。解决方案:使用globalCompositeOperation = 'destination-out'并设置颜色任意,实现真正擦除像素。但需注意不同平台支持。可改为设置合成模式,然后画线条。

ctx.globalCompositeOperation='destination-out';
ctx.strokeStyle='#000000'// 任意颜色
// 画路径
ctx.stroke();
ctx.globalCompositeOperation='source-over';

❌ 错误5:矢量图形绘制时宽度/高度为负数

现象:从左向右画矩形正常,从右向左画矩形宽度为负数,导致显示异常。原因:计算尺寸时未考虑正负。解决方案:存储时记录起点和终点,绘制时统一转换为正宽高。

❌ 错误6:层级调整后,选中图形混乱

现象:调整层级后,无法正确选中图形。原因:选中依赖索引,层级调整后索引改变。解决方案:存储唯一ID,选中时通过ID查找。

❌ 错误7:小程序中canvasToTempFilePath失败

原因:小程序需要指定canvasId,且需在draw回调后调用。解决方案:在ctx.drawcomplete回调中调用,或使用setTimeout延迟执行。


六、总结与扩展

今天我们实现了一个功能丰富的跨平台画板组件。核心收获:

  • 图形对象模型:通过数组管理,支持层级和独立属性。

  • 触摸坐标转换:统一坐标系,考虑dpr。

  • 跨平台适配:通过条件编译和统一的canvas API实现。

  • 橡皮擦:利用合成模式实现像素擦除。

  • 历史记录:简单的快照栈实现撤销重做。

你可以在这个基础上扩展更多功能,比如:

  • 添加文本工具。

  • 支持导入图片。

  • 增加画笔粗细、透明度调节。

  • 导出为SVG或JSON。

如果在实际开发中遇到其他问题,欢迎带着代码来问我。

—— 一个喜欢折腾画板组件的技术老前辈 🎨

加油,未来的全栈大佬!💪如果你也对移动端跨端开发感兴趣,关注我,后续还有更多优质文章分享!

往期相关文章推荐

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 手把手打造跨平台自由画板组件(UniApp实战)

评论 抢沙发

6 + 3 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮