场景:大概21年4月份的时候,项目预规划了“自定义流程图需求”,并且能够根据后端返回数据,实现动态流程图(节点状态、流程线状态、节点数量等动态变化)。当时项目正有2-3天的空档期,于是就利用这几天,熟悉了一下uni-app中canvas api,基于设计稿,先行绘制出了静态图。并且,基于movable-area和movable-view组件,对流程图添加了手势缩放与拖拽功能。不过到目前,该需求也没有在项目中实际落实。本文将将基于该静态流程图,对uni-app中canvas基本使用做一个介绍。
官网API文档:Canvas API
Canvas组件文档:Canvas
设计图原图展示:
开发:
①:首先是视图层代码,主要的是Canvas的一些配置,其中canvas-id必须配置,且要唯一;并且如果不设置宽高,默认宽高是300px与225px,我这边是根据设计图大小设置的。此外,Canvas开发最佳、最简便的是在vue页面开发,nvue页面需要兼容,并且性能可能不佳。具体见官网Canvas组件介绍。
<template>
<view>
<scroll-view :scroll-x="true" :scroll-left="srcollTo" :scroll-with-animation="true">
<movable-area :scale-area="true" style="width: 750rpx;height: 1257px;">
<movable-view direction="all" :scale-min="0.5" :scale-max="1" :scale="true"
@scale="scaleChange" :disabled="true">
<!--这边关闭了拖拽-->
<!-- <scroll-view :scroll-x="true" :scroll-left="srcollTo" :scroll-with-animation="true"> -->
<canvas id="myCanvas" canvas-id="myCanvas" style="width: 1200px;height: 1257px;"
@touchstart="touchstart" />
<!-- </scroll-view> -->
</movable-view>
</movable-area>
</scroll-view>
</view>
</template>
②:注意事项:
1. 绘制顺序要注意,比如我画一个流程节点,要先画背景,然后画字,否则字会被遮挡。
2. Canvas的draw()方法只需在所有元素绘制完成后调用一次就行。
3.其余还没想到。
③:下面介绍一下关键的绘制函:
1. drawImage:图片绘制,入参:Canvas 上下文、x、y、宽、高;
drawImage(ctx, path, x, y, dWidth, dHeight) {
ctx.drawImage(path, x, y, dWidth, dHeight)
},
2. roundNode:圆角矩形节点绘制,入参:Canvas 上下文、x、y、宽、高、弧度、填充色值;主要原理是通过arcTo方法绘制圆弧,最后填充颜色;
//圆角矩形
roundNode(ctx, x, y, width, height, radius, color) {
ctx.beginPath()
if (width < 2 * radius) radius = width / 2;
if (height < 2 * radius) radius = height / 2;
ctx.moveTo(x + radius, y);
ctx.arcTo(x + width, y, x + width, y + height, radius);
ctx.arcTo(x + width, y + height, x, y + height, radius);
ctx.arcTo(x, y + height, x, y, radius);
ctx.arcTo(x, y, x + width, y, radius);
ctx.setFillStyle(color)
ctx.fill()
},
3. diamondNode:菱形节点绘制,入参:Canvas 上下文、x、y、宽、高、线宽、填充色值;菱形节点绘制是基于圆角节点进行的,arcTo方法相当于一阶贝塞尔曲线,通过控制控制点的位置实现圆弧位置变动。但这个方法绘制出来有个缺口,存在一点瑕疵,后续项目正式将该需求提上计划后再优化
//菱形绘制
diamondNode(ctx, x, y, width, height, lineColor, bgColor) {
ctx.beginPath()
ctx.setLineDash([])
ctx.setLineWidth(4)
ctx.moveTo(x + 20, y + 20);
ctx.arcTo(x + width / 2, y, x + width, y + height / 2, 5);
ctx.arcTo(x + width, y + height / 2, x + width / 2, y + height, 8);
ctx.arcTo(x + width / 2, y + height, x, y + height / 2, 5);
ctx.arcTo(x, y + height / 2, x + width / 2, y, 8);
ctx.setStrokeStyle(lineColor)
ctx.stroke()
ctx.setFillStyle(bgColor)
ctx.fill()
},
4. drawTriangle:三角形绘制,该方法与线段绘制组合使用,入参:Canvas 上下文、x、y、填充色、箭头方向;这边为了方便起见,箭头大小是写死的,我这边的箭头绘制原理是:基于指定点,绘制线段填充实现。
//绘制三角形 type:箭头朝向:bottom、right、left
drawTriangle(ctx, x, y, color, type) {
ctx.beginPath()
let height = 10 //计算等边三角形的高
ctx.moveTo(x, y); //x y开始
switch (type) {
case 'bottom':
ctx.lineTo(x - height / 2, y)
ctx.lineTo(x, y + height)
ctx.moveTo(x, y)
ctx.lineTo(x + height / 2, y)
ctx.lineTo(x, y + height)
break;
case 'left':
ctx.lineTo(x, y - height / 2)
ctx.lineTo(x - height, y)
ctx.moveTo(x, y)
ctx.lineTo(x, y + height / 2)
ctx.lineTo(x - height, y)
break;
case 'right':
ctx.lineTo(x, y - height / 2)
ctx.lineTo(x + height, y)
ctx.moveTo(x, y)
ctx.lineTo(x, y + height / 2)
ctx.lineTo(x + height, y)
break;
default:
break;
}
ctx.setFillStyle(color) //以纯色绿色填充
ctx.fill();
}
5. drawText:文字绘制,入参:Canvas 上下文、x、y、填充色、文字大小;这边文字绘制在水平方向,通过measureText计算文字宽度,进行了适配;而文字竖直方向适配,试了几种网上的方法,一直没有成功,暂时做了统一偏移量处理。
drawText(ctx, text, x, y, color, size) {
//文字部分
ctx.beginPath()
ctx.setTextAlign('center')
ctx.setFillStyle(color)
ctx.setFontSize(size)
const metrics = ctx.measureText(text)
console.log(metrics.width)
//文字统一偏移
ctx.fillText(text, x + metrics.width / 2, y + 17)
},
6. drawLine: 绘制线段,入参:Canvas 上下文、起始点x、起始点y、目标点x、目标点y、填充色、文字大小、箭头朝向、是否带箭头、是否虚线;线段绘制方法比较简单。
drawLine(ctx, fromX, fromY, toX, toY, color, type, isArrow = true, isDash = false) {
ctx.beginPath()
if (isDash) {
ctx.setLineDash([10]);
} else {
ctx.setLineDash([]);
}
ctx.moveTo(fromX, fromY)
ctx.lineTo(toX, toY)
ctx.setLineWidth(1)
ctx.setStrokeStyle(color)
ctx.stroke()
//是否绘制箭头
if (isArrow) {
this.drawTriangle(ctx, toX, toY, color, type)
}
},
7. touchstart:Canvas触摸事件监听,用于实现流程图中节点的点击。实际使用需要记录每个节点的x、y坐标点、宽、高,通过这些参数对点击区域进行计算与判断,并且还要考虑缩放而带来的坐标点变化。
touchstart(e) {
//点击事件有点复杂,要根据点击点、绘制位置、缩放比例判断点击了哪个节点,
let x = e.touches[0].x
let y = e.touches[0].y
this.node.forEach(item => {
// console.log("item.x * this.scale:"+item.x * this.scale)
// console.log("item.y * this.scale:"+item.y * this.scale)
if (x > item.x * this.scale && x < (item.x + item.w) * this.scale
&& y > item.y * this.scale && y < (item.y + item.h) * this.scale) {
//在范围内,根据标记定义节点类型
console.log(item.targe)
uni.showToast({
icon:'none',
title:item.name
})
}
})
console.log("x:"+x + " y:"+y)
},
④:逻辑层代码量比较多,略微复杂,这边全部贴出来,供参考。部分静态图片资源就不提供了,运行的时候替换一下或注释掉都行。
<script>
export default {
data() {
return {
context: null,
srcollTo: 0,
scale: 1, //缩放比例
node:[],//节点坐标与长宽
}
},
onLoad(e) {
//上个页面传递的数据
},
onReady() {
this.srcollTo = 0
this.$nextTick(() => {
this.srcollTo = 435
})
this.context = uni.createCanvasContext('myCanvas')
//画背景
this.drawImage(this.context, '/static/bg.png', 0, 0, 1200, 1257)
//画节点
//开始节点
this.roundNode(this.context, 553, 38, 100, 36, 26, '#1EC1C3')
this.node.push({
x:553,
y:38,
w:100,
h:36,
targe:0
})
//检修发起抄送
this.roundNode(this.context, 755, 89, 157, 36, 26, '#1EC1C3')
//检修前准备
this.roundNode(this.context, 525, 265, 164, 36, 2, '#1EC1C3')
//车间主任审核
this.roundDashNode(this.context, 525, 360, 156, 36, 2)
//质量大班长
this.roundDashNode(this.context, 743, 265, 188, 36, 2)
//是否停电
this.diamondNode(this.context, 308, 120, 108, 62, '#00A1FF', '#FFFFFF')
//停电确认
this.roundNode(this.context, 52, 214, 120, 36, 26, '#B9C2D6')
//停电抄送
this.roundNode(this.context, 298, 265, 128, 36, 2, '#B9C2D6')
//风险等级
this.diamondNode(this.context, 549, 441, 108, 62, '#B9C2D6', '#EEF1F7')
//安全审核
this.roundNode(this.context, 531, 546, 148, 36, 2, '#B9C2D6')
//审批
this.roundNode(this.context, 531, 645, 148, 36, 2, '#B9C2D6')
//检修中
this.roundNode(this.context, 531, 744, 148, 36, 2, '#B9C2D6')
//需要送电
this.diamondNode(this.context, 549, 861, 108, 62, '#B9C2D6', '#EEF1F7')
//完成检修抄送
this.roundNode(this.context, 278, 804, 167, 36, 26, '#B9C2D6')
//送电
this.roundNode(this.context, 772, 876, 118, 36, 2, '#B9C2D6')
//送电抄送
this.roundNode(this.context, 1020, 876, 113, 36, 26, '#B9C2D6')
//维修验收
this.roundNode(this.context, 529, 1009, 148, 36, 2, '#B9C2D6')
//结束节点
this.roundNode(this.context, 553, 1101, 100, 36, 26, '#B9C2D6')
//画线
//开始 to 检修发起抄送
this.drawLine(this.context, 602, 75, 602, 247, '#1EC1C3', 'bottom')
//to 检修前准备
this.drawLine(this.context, 602, 107, 747, 107, '#1EC1C3', 'right')
//to 车间主任审批
this.drawLine(this.context, 602, 306, 602, 346, '#00A1FF', 'bottom', true, true)
//to 质量安全大班长
this.drawLine(this.context, 411, 152, 837, 152, '#00A1FF', '', false, true)
this.drawLine(this.context, 837, 153, 837, 255, '#00A1FF', 'bottom', true, true)
//to 停电确认
this.drawLine(this.context, 362, 182, 362, 252, '#AFB9C5', 'bottom', true, true)
//to 停电抄送
this.drawLine(this.context, 361, 231, 184, 231, '#AFB9C5', 'left', true, true)
//to 风险等级
this.drawLine(this.context, 602, 403, 602, 430, '#AFB9C5', 'bottom', true, true)
//to 安全审核
this.drawLine(this.context, 602, 506, 602, 536, '#AFB9C5', 'bottom', true, true)
//to 审批
this.drawLine(this.context, 602, 589, 602, 629, '#AFB9C5', 'bottom', true, true)
//to 检修中
this.drawLine(this.context, 602, 692, 602, 732, '#AFB9C5', 'bottom', true, true)
//to 需要送电
this.drawLine(this.context, 602, 790, 602, 850, '#AFB9C5', 'bottom', true, true)
//to 完成检修抄送
this.drawLine(this.context, 603, 821, 455, 821, '#AFB9C5', 'left', true, true)
//to 送电
this.drawLine(this.context, 656, 892, 756, 892, '#AFB9C5', 'right', true, true)
//to 送电抄送
this.drawLine(this.context, 900, 892, 1010, 892, '#AFB9C5', 'right', true, true)
// 送电 to 维修验收
this.drawLine(this.context, 835, 921, 835, 966, '#AFB9C5', '', false, true)
this.drawLine(this.context, 602, 966, 835, 966, '#AFB9C5', '', false, true)
//to 维修验收
this.drawLine(this.context, 602, 931, 602, 1000, '#AFB9C5', 'bottom', true, true)
//to 结束
this.drawLine(this.context, 602, 1054, 602, 1090, '#AFB9C5', 'bottom', true, true)
//画文字、图标
this.drawText(this.context, '开始', 587, 45, '#FFFFFF', 18)
this.drawText(this.context, '检修发起抄送', 783, 96, '#FFFFFF', 16)
this.drawIcon('complete', 883, 97)
this.drawText(this.context, '检修前准备', 553, 272, '#FFFFFF', 16)
this.drawIcon('complete', 637, 274)
this.drawText(this.context, '车间主任审核', 552, 367, '#00A1FF', 16)
this.drawIcon('loading', 653, 368)
this.drawIcon('me', 530, 358)
this.drawText(this.context, '质量安全大班长监督', 754, 272, '#00A1FF', 16)
this.drawIcon('loading', 904, 274)
this.drawText(this.context, '需要停电', 328, 140, '#333333', 16)
this.drawText(this.context, '是', 367, 193, '#FFAD10', 16)
this.drawText(this.context, '停电确认', 330, 272, '#FFFFFF', 16)
this.drawIcon('unstart', 397, 273)
this.drawIcon('me', 304, 263)
this.drawText(this.context, '停电抄送', 67, 220, '#FFFFFF', 16)
this.drawIcon('unstart', 140, 221)
this.drawText(this.context, '中危', 587, 461, '#8695AE', 16)
this.drawText(this.context, '安全审核', 558, 553, '#FFFFFF', 16)
this.drawIcon('unstart', 636, 554)
this.drawText(this.context, '审批(中危)', 558, 652, '#FFFFFF', 16)
this.drawIcon('unstart', 636, 653)
this.drawText(this.context, '检修中', 558, 751, '#FFFFFF', 16)
this.drawIcon('unstart', 636, 752)
this.drawText(this.context, '需要送电', 571, 880, '#8695AE', 16)
this.drawText(this.context, '是', 694, 863, '#FFAD10', 16)
this.drawText(this.context, '否', 580, 951, '#FFAD10', 16)
this.drawText(this.context, '完成检修抄送', 302, 811, '#FFFFFF', 16)
this.drawIcon('unstart', 402, 813)
this.drawText(this.context, '送电', 803, 883, '#FFFFFF', 16)
this.drawIcon('unstart', 839, 884)
this.drawText(this.context, '送电抄送', 1033, 883, '#FFFFFF', 16)
this.drawIcon('unstart', 1099, 884)
this.drawText(this.context, '维修验收', 560, 1016, '#FFFFFF', 16)
this.drawIcon('unstart', 627, 1017)
this.drawText(this.context, '结束', 587, 1108, '#FFFFFF', 18)
//画
this.context.draw()
},
methods: {
drawImage(ctx, path, x, y, dWidth, dHeight) {
ctx.drawImage(path, x, y, dWidth, dHeight)
},
//type loading, unstart, complete, me
drawIcon(type, x, y) {
switch (type) {
case 'loading':
this.drawImage(this.context, '/static/loading.png', x, y, 20, 20)
break;
case 'unstart':
this.drawImage(this.context, '/static/unstart.png', x, y, 20, 20)
break;
case 'complete':
this.drawImage(this.context, '/static/finish.png', x, y, 20, 20)
break;
case 'me':
this.drawImage(this.context, '/static/me.png', x, y, 18, 23)
break;
default:
break;
}
},
//圆角矩形
roundNode(ctx, x, y, width, height, radius, color) {
//圆角矩形部分
ctx.beginPath()
if (width < 2 * radius) radius = width / 2;
if (height < 2 * radius) radius = height / 2;
ctx.moveTo(x + radius, y);
ctx.arcTo(x + width, y, x + width, y + height, radius);
ctx.arcTo(x + width, y + height, x, y + height, radius);
ctx.arcTo(x, y + height, x, y, radius);
ctx.arcTo(x, y, x + width, y, radius);
ctx.setFillStyle(color)
ctx.fill()
},
//虚线圆角矩形 样式固定
roundDashNode(ctx, x, y, width, height, radius) {
ctx.beginPath()
if (width < 2 * radius) radius = width / 2;
if (height < 2 * radius) radius = height / 2;
ctx.setLineDash([10])
ctx.setLineWidth(2)
ctx.moveTo(x + radius, y);
ctx.arcTo(x + width, y, x + width, y + height, radius);
ctx.arcTo(x + width, y + height, x, y + height, radius);
ctx.arcTo(x, y + height, x, y, radius);
ctx.arcTo(x, y, x + width, y, radius);
ctx.setStrokeStyle('#00A1FF')
ctx.stroke()
ctx.setFillStyle('#D5EAFE')
ctx.fill()
},
//菱形绘制
diamondNode(ctx, x, y, width, height, lineColor, bgColor) {
ctx.beginPath()
ctx.setLineDash([])
ctx.setLineWidth(4)
ctx.moveTo(x + 20, y + 20);
ctx.arcTo(x + width / 2, y, x + width, y + height / 2, 5);
ctx.arcTo(x + width, y + height / 2, x + width / 2, y + height, 8);
ctx.arcTo(x + width / 2, y + height, x, y + height / 2, 5);
ctx.arcTo(x, y + height / 2, x + width / 2, y, 8);
ctx.setStrokeStyle(lineColor)
ctx.stroke()
ctx.setFillStyle(bgColor)
ctx.fill()
},
//绘制三角形 type:箭头朝向:bottom、right、left
drawTriangle(ctx, x, y, color, type) {
ctx.beginPath()
let height = 10 //计算等边三角形的高
ctx.moveTo(x, y); //x y开始
switch (type) {
case 'bottom':
ctx.lineTo(x - height / 2, y)
ctx.lineTo(x, y + height)
ctx.moveTo(x, y)
ctx.lineTo(x + height / 2, y)
ctx.lineTo(x, y + height)
break;
case 'left':
ctx.lineTo(x, y - height / 2)
ctx.lineTo(x - height, y)
ctx.moveTo(x, y)
ctx.lineTo(x, y + height / 2)
ctx.lineTo(x - height, y)
break;
case 'right':
ctx.lineTo(x, y - height / 2)
ctx.lineTo(x + height, y)
ctx.moveTo(x, y)
ctx.lineTo(x, y + height / 2)
ctx.lineTo(x + height, y)
break;
default:
break;
}
ctx.setFillStyle(color) //以纯色绿色填充
ctx.fill();
},
drawText(ctx, text, x, y, color, size) {
//文字部分
ctx.beginPath()
ctx.setTextAlign('center')
ctx.setFillStyle(color)
ctx.setFontSize(size)
const metrics = ctx.measureText(text)
console.log(metrics.width)
//文字统一偏移
ctx.fillText(text, x + metrics.width / 2, y + 17)
},
// 绘制带箭头线 type:箭头朝向:bottom、right、left
drawLine(ctx, fromX, fromY, toX, toY, color, type, isArrow = true, isDash = false) {
ctx.beginPath()
if (isDash) {
ctx.setLineDash([10]);
} else {
ctx.setLineDash([]);
}
ctx.moveTo(fromX, fromY)
ctx.lineTo(toX, toY)
ctx.setLineWidth(1)
ctx.setStrokeStyle(color)
ctx.stroke()
//是否绘制箭头
if (isArrow) {
this.drawTriangle(ctx, toX, toY, color, type)
}
},
touchstart(e) {
//点击事件有点复杂,要根据点击点、绘制位置、缩放比例判断点击了哪个节点,
let x = e.touches[0].x
let y = e.touches[0].y
this.node.forEach(item => {
// console.log("item.x * this.scale:"+item.x * this.scale)
// console.log("item.y * this.scale:"+item.y * this.scale)
if (x > item.x * this.scale && x < (item.x + item.w) * this.scale
&& y > item.y * this.scale && y < (item.y + item.h) * this.scale) {
//在范围内,根据标记定义节点类型
console.log(item.targe)
}
})
console.log("x:"+x + " y:"+y)
},
scaleChange(e) {
this.scale = e.detail.scale
console.log(this.scale)
}
}
}
</script>
总结:个人观点来看,uni-app端的canvas,与Android原生相比,核心原理都是找点,找到点位后执行相关绘制方法进行绘制,只不过部分方法有所不同。作为一个Android开发者,虽然暂时没具体使用过原生canvas,但对其也有了大致的了解。此外,Canvas还能实现各种效果的动画,这边初步了解了一点,后续实现了默写效果会继续更新文章。