乐于分享
好东西不私藏

uniapp+vue3+ts:小程序实现用户自定义海报

uniapp+vue3+ts:小程序实现用户自定义海报

在小程序中增加了一个自定义海报绘制的功能,用户根据自己的喜好绘制海报。示例中的数据都是静态的,仅作为功能展示。

已关注

关注

重播 分享

一、功能
✔动态背景图,可选切换

✔头像元素,矩形或者圆形,动态大小,拖动位置
✔文字输入,大小颜色更改,位置拖动
✔生成图片保存到本地
二、功能分析
为能让用户最大意义上的能自定义,首先需要知道海报中需要的元素(如背景、头像、昵称、文字等),把它们作为一个独立的元素,在放入到绘制的集合,实现海报的绘制。
1、核心的功能:画布(canvas中的元素再次绘制,首先创建元素的集合(elements
const elements = ref([]) //画布中的元素集合
因为绘制的元素 有文字、图片;图片中又有圆形和矩形,所以需要按类型绘制。如下代码:
//绘制所有元素const redraw = () => {if (!canvas.valuereturn//画布对象是否存在const ctx = canvas.value;// 清空画布ctx.clearRect(00, cSize.value.width, cSize.value.height)// 重新绘制所有元素elements.value.forEach(element => {if (element.type === 'circle' && element.radius) { //圆形 且存在半径radiusctx.fillStyle = element.color//填充颜色// 绘制圆形路径ctx.beginPath();ctx.arc(element.x, element.y, element.radius0Math.PI * 2);ctx.closePath();ctx.fill(); //填充else if (element.type === 'text' && element.text) { //文字ctx.fillStyle = element.color;ctx.font = element.font + "px Arial";ctx.fillText(element.text, element.x, element.y);else if (element.type === 'images' && element.path) { //图片ctx.drawImage(element.path, element.x, element.y, element.width, element.height);else if (element.type === 'cImages' && element.path) { //圆形图片ctx.save(); // 保存当前状态// 绘制圆形路径ctx.beginPath();ctx.arc(element.x + element.width / 2, element.y + element.height / 2Math.min(element.width,element.height) / 20Math.PI * 2);ctx.closePath();ctx.clip(); // 设置裁剪区域,裁剪为圆形 ctx.drawImage(element.path, element.x, element.y, element.width, element.height); // 绘制图片到圆形区域内ctx.restore(); // 恢复状态else { //默认矩形ctx.fillStyle = element.color//填充颜色ctx.fillRect(element.x, element.y, element.width, element.height);}})}
2、元素的绘制和更改(以背景为例子)
如,背景的属性,把属性加入到元素的集合(elements)中
const bgInfo ref({x0,y0,width80,height80,path"/static/bg/01.jpg",type'images',cate'bgCate', //背景}); //背景的属性//初始化const initView async () => {const {windowWidth,windowHeight} = uni.getSystemInfoSync(); //获取系统屏幕信息//设置画布大小cSize.value.width = windowWidth;cSize.value.height = windowHeight;//背景图大小bgInfo.value.width = windowWidth;bgInfo.value.height = windowHeight;await nextTick(); //DOM 加载完成//加入到元素列表中 - 可用于添加之后的重绘elements.value.push(bgInfo.value); //把背景加入元素集合中canvas.value = uni.createCanvasContext("myCanvas");redraw(); //绘制元素canvas.value.draw();};

切换则需要更改路径(path)的值,再绘制

//背景切换const drawRectBg = (img) => {elements.value.map((item) => {if (item.cate == 'bgCate' && item.type == 'images') {item.path = img;}})redraw(); //绘制元素canvas.value.draw();};

3、元素的移动(以头像的移动为例子)

如头像的属性为(坐标、长宽、类型、路径)

const avatar ref({x30,y30,width80,height80,path"/static/logo.png",type'images',cate'avatarCate', //头像}); //头像的属性

画布(canvas)中需要定义触摸事件,用于捕捉当前的坐标位置:

<!--画布--><canvasclass="pb-canvas" canvas-id="myCanvas" id="myCanvas":style="'width:'+cSize.width+'px;height:'+cSize.height+'px'" @touchstart="tsEvent" @touchmove="tmEvent"@touchend="teEvent"></canvas>

捕抓到的坐标用于判断是否点触在头像的矩形范围内,如果是则定义一个存储触点偏移量的变量,并赋值:

const tsEvent = (e) => {const res = e.changedTouches[0];const mouseX = res.clientX;const mouseY = res.clientY;//在非按下时 可操作if(!isDragging.value){const rect = avatar.value;//判读焦点是否在范围内 - 头像的选中if (mouseX >= rect.x && mouseX <= (rect.width + rect.x&& mouseY >= rect.y && mouseY <= (rect.y + rect.height)) {isDragging.value = true//按下了offset.value.x = mouseX - avatar.value.x//记录当前的左边X轴offset.value.y = mouseY - avatar.value.y;offset.value.type = "avatar"//头像}

确定了触点在头像的矩形框中,则在画布(canvas的触摸移动事件中更改头像元素的坐标值,再进行绘制 ,则可实现了元素在画布中移动:

//移动事件const tmEvent = (e) => {const res = e.changedTouches[0];const size = cSize.value//画布大小const rect = avatar.value//矩形属性 - 头像if (isDragging.value) {avatar.value.x = res.clientX - offset.value.x;avatar.value.y = res.clientY - offset.value.y;//边界-最左边if (res.clientX < 0) {avatar.value.x = 0;}//边界-最右边if (res.clientX >= (size.width - rect.width)) {avatar.value.x = size.width - rect.width;}//边界-最上边if (res.clientY >= (size.height - rect.height)) {avatar.value.y = size.height - rect.height;}//边界-最底部if (res.clientY < 0) {avatar.value.y = 0;}}//先删除const arr = elements.value;const newArr = arr.filter(item => item.cate != 'avatarCate'); //筛选出不是 images的元素 为新的数组elements.value = newArr;//新增元素elements.value.push(avatar.value);}

4、生成图片保存

 先把画布(canvas)导出临时路径,再下载到移动端中:

//保存为图片const saveToImage =async()=>{const res = await new Promise((resolve,reject)=>{//导出临时路径uni.canvasToTempFilePath({x:0,y:0,width:cSize.value.width,height:cSize.value.height,destWidth:cSize.value.width*2,  // 高清倍率destHeight:cSize.value.height*2,canvasId:"myCanvas",success:resolve,fail:reject})});//成功if(res.errMsg == "canvasToTempFilePath:ok"){const tempPath = res.tempFilePath//获取结果//保存到相册uni.saveImageToPhotosAlbum({filePath:tempPath,success(res) => {uni.showToast({title:"已保存至相册"})},fail(e) => {console.error("错误:",e);uni.showModal({ contentJSON.stringify(e), showCancelfalse })}})}};
以上仅是功能的实现详细流程,仅供参考,可以按需求自己封装或者优化代码。下面附上完整的代码示例:
<template><viewclass="poster-box"><!--画布--><canvasclass="pb-canvas"canvas-id="myCanvas"id="myCanvas":style="'width:'+cSize.width+'px;height:'+cSize.height+'px'" @touchstart="tsEvent" @touchmove="tmEvent"			@touchend="teEvent"></canvas><!--工具栏--><view:class="['pb-tool',isShowTool?'pb-tool-show':'pb-tool-hiden']"v-if="isShowTool"><viewclass="pb-t-block uni-column"><viewclass="pb-tb-item uni-column"><labelclass="pb-tbi-label">头像</label><viewclass="pb-tbi-block uni-row"><viewclass="pb-tbi-img pb-tbi-rect" @tap="drawRectAvatar"><image:src="avatar.path"mode="aspectFill"></image></view><viewclass="pb-tbi-img pb-tbi-circle" @tap="drawCircleAvatar"><image:src="avatar.path"mode="aspectFill"></image></view><viewclass="pb-tbi-img uni-row"><textclass="pb-tbi-i-btn"								@tap="imageSizeChangeSmall()">缩小</text><textclass="pb-tbi-i-btn"								@tap="imageSizeChangeBig()">放大</text></view></view></view><viewclass="pb-tb-item uni-column"><labelclass="pb-tbi-label">背景</label><viewclass="pb-tbi-block uni-row"><viewclass="pb-tbi-img pb-tbi-rect"v-for="(item,index) in bgArr":key="index"><image:src="item"mode="aspectFill" @tap="drawRectBg(item)"></image></view></view></view><viewclass="pb-tb-item uni-column"><labelclass="pb-tbi-label">文字</label><viewclass="pb-tbi-block uni-row"><viewclass="pb-tbi-img pb-tbi-text uni-row"><inputclass="pb-tbi-t-input"v-model="myText.text"placeholder="输入文字" /><textclass="pb-tbi-t-btn" @tap="drawText">添加文字</text></view></view><viewclass="pb-tbi-block uni-row"><viewclass="pb-tbi-img pb-tbi-size uni-row"><textclass="pb-tbi-ts-label">大小</text><viewclass="pb-tbi-ts-slider"><sliderclass="pdb-tbic-i-slider":value="myText.font"min="10"block-size="20"max="50"activeColor="#FFCC33backgroundColor="#fffffffore-color="#8A6DE9"									@changing="textSizeChange" /></view><textclass="pb-tbi-ts-label">{{ myText.font +'px'}}</text></view></view><viewclass="pb-tbi-block-color uni-row"><viewclass="pb-tbi-img pb-tbi-color uni-row"v-for="(item,index) in textColors":key="index"><textclass="pb-tbi-c-text":style="'background-color:'+item+';'" @tap="drawTextColor(item)"></text></view></view></view><viewclass="saveBtn" @tap="saveToImage">保存为图片</view></view></view><!--折叠--><viewclass="pb-tool-close" @tap="showOrCloseTool">{{ btnText }}</view><!--end--></view></template><scriptsetup>	import {		ref,		onMounted,		nextTick	} from "vue";	const cSize = ref({		width: 300,		height: 300	}) //画布大小	const canvas = ref(null); //画布	const bgInfo = ref({		x: 0,		y: 0,		width: 80,		height: 80,		path: "/static/bg/01.jpg",		type: 'images',		cate: 'bgCate', //背景	}); //背景的属性	const bgArr = ref(['/static/bg/01.jpg', '/static/bg/02.jpg', '/static/bg/03.jpg', '/static/bg/04.jpg']); //背景数组	const avatar = ref({		x: 30,		y: 30,		width: 80,		height: 80,		path: "/static/logo.png",		type: 'images',		cate: 'avatarCate', //头像	}); //头像的属性	const avatarScale = ref(1); //头像放大倍率	const myText = ref({		x: 120,		y: 120,		font: 30,		color: "#ff6100",		text: '文本内容',		type: 'text',	}) //文字的属性	const elements = ref([]) //画布中的元素集合	const isDragging = ref(false); //按下了	const offset = ref({		x: 0,		y: 0,		type:'text', //选中的元素 text =>文字 avatar=>头像	}); //当前元素的偏移量	const isShowTool = ref(true); //是否显示工具栏	const textColors = ref([		'#FF5733',		'#33FF57',		'#3357FF',		'#F333FF',		'#FF33A1',		'#33FFF3',		'#F3FF33',		'#800000',		'#008000',		'#000080',		'#808000',		'#800080'	]); //字体颜色	const btnText = ref("关"); //右下角按钮文字	onMounted(() => {		initView(); //初始化页面	})	//初始化	const initView = async () => {		const {			windowWidth,			windowHeight		} = uni.getSystemInfoSync(); //获取系统屏幕信息		//设置画布大小		cSize.value.width = windowWidth;		cSize.value.height = windowHeight;		//背景图大小		bgInfo.value.width = windowWidth;		bgInfo.value.height = windowHeight;		await nextTick(); //DOM 加载完成		//加入到元素列表中 - 可用于添加之后的重绘		elements.value.push(bgInfo.value); //把背景加入元素集合中		canvas.value = uni.createCanvasContext("myCanvas");		redraw(); //绘制元素		canvas.value.draw();	};	//背景切换	const drawRectBg = (img) => {		elements.value.map((item) => {			if (item.cate == 'bgCate' && item.type == 'images') {				item.path = img;			}		})		redraw(); //绘制元素		canvas.value.draw();	};	//绘制矩形头像:头像只能绘制一个 不是矩形的就是圆形的	const drawRectAvatar = () => {		//先删除		const arr = elements.value;		const newArr = arr.filter(item => item.cate != 'avatarCate'); //筛选出不是 cImages的元素 为新的数组		elements.value = newArr;		//新增元素		avatar.value.type = "images";		elements.value.push(avatar.value);		redraw(); //绘制元素		canvas.value.draw();	};	//绘制圆形头像:头像只能绘制一个 不是矩形的就是圆形的	const drawCircleAvatar = () => {		//先删除		const arr = elements.value;		const newArr = arr.filter(item => item.cate != 'avatarCate'); //筛选出不是 images的元素 为新的数组		elements.value = newArr;		//新增元素		avatar.value.type = "cImages";		elements.value.push(avatar.value);		redraw(); //绘制元素		canvas.value.draw();	};	//绘制所有元素	const redraw = () => {		if (!canvas.value) return; //画布对象是否存在		const ctx = canvas.value;		// 清空画布		ctx.clearRect(0, 0, cSize.value.width, cSize.value.height)		// 重新绘制所有元素		elements.value.forEach(element => {			if (element.type === 'circle' && element.radius) { //圆形 且存在半径radius				ctx.fillStyle = element.color; //填充颜色				// 绘制圆形路径				ctx.beginPath();				ctx.arc(element.x, element.y, element.radius, 0, Math.PI * 2);				ctx.closePath();				ctx.fill(); //填充			} else if (element.type === 'text' && element.text) { //文字				ctx.fillStyle = element.color;				ctx.font = element.font + "px Arial";				ctx.fillText(element.text, element.x, element.y);			} else if (element.type === 'images' && element.path) { //图片				ctx.drawImage(element.path, element.x, element.y, element.width, element.height);			} else if (element.type === 'cImages' && element.path) { //圆形图片				ctx.save(); // 保存当前状态				// 绘制圆形路径				ctx.beginPath();				ctx.arc(element.x + element.width / 2, element.y + element.height / 2, Math.min(element.width,					element.height) / 2, 0, Math.PI * 2);				ctx.closePath();				ctx.clip(); // 设置裁剪区域,裁剪为圆形 				ctx.drawImage(element.path, element.x, element.y, element.width, element.height); // 绘制图片到圆形区域内				ctx.restore(); // 恢复状态			} else { //默认矩形				ctx.fillStyle = element.color; //填充颜色				ctx.fillRect(element.x, element.y, element.width, element.height);			}		})	}	//图片放大	const imageSizeChangeBig = () => {		avatarScale.value = 1;		avatarScale.value += 0.1; //放大		avatar.value.width = avatar.value.width * avatarScale.value;		avatar.value.height = avatar.value.height * avatarScale.value;		redraw(); //绘制元素		canvas.value.draw();	}	//图片缩小	const imageSizeChangeSmall = () => {		avatarScale.value = 1;		avatarScale.value = avatarScale.value - 0.1; //缩小		if (avatarScale.value <= 0.1) {			avatarScale.value = 0.1		}		avatar.value.width = avatar.value.width * avatarScale.value;		avatar.value.height = avatar.value.height * avatarScale.value;		redraw(); //绘制元素		canvas.value.draw();	};	//添加文字	const drawText = () => {		elements.value.push(myText.value);		redraw(); //绘制元素		canvas.value.draw();	};	//字体大小调整	const textSizeChange = (e) => {		myText.value.font = e.detail.value;		elements.value.map((item) => {			if (item.type == 'text') {				item.font = e.detail.value;			}		})		redraw(); //绘制元素		canvas.value.draw();	};	//字体颜色	const drawTextColor =(color)=>{		myText.value.color = color;		elements.value.map((item) => {			if (item.type == 'text') {				item.color = color;			}		})		redraw(); //绘制元素		canvas.value.draw();	}	//按下事件	const tsEvent = (e) => {		// isShowTool.value = false; //隐藏		showOrCloseTool();		const res = e.changedTouches[0];		const mouseX = res.clientX;		const mouseY = res.clientY;		//在非按下时 可操作		if(!isDragging.value){		 const rect = avatar.value;		//判读焦点是否在范围内 - 头像的选中		if (mouseX >= rect.x && mouseX <= (rect.width + rect.x) && mouseY >= rect.y && mouseY <= (rect.y + rect				.height)) {			isDragging.value = true; //按下了			offset.value.x = mouseX - avatar.value.x; //记录当前的左边X轴			offset.value.y = mouseY - avatar.value.y;			offset.value.type = "avatar"; //头像		}		const textRect = myText.value;		const textWidth = canvas.value.measureText(textRect.text).width;		//判读焦点是否在范围内 - 文字的选中		if (mouseX >= textRect.x && mouseX <= (textWidth + textRect.x) && mouseY >= (textRect.y-textRect.font) && mouseY <= (textRect.y + textRect				.font)) {			isDragging.value = true; //按下了			offset.value.x = mouseX - myText.value.x; //记录当前的左边X轴			offset.value.y = mouseY - myText.value.y;			offset.value.type = "text"; //文字		}		}	}	//移动事件	const tmEvent = (e) => {		const res = e.changedTouches[0];		const size = cSize.value; //画布大小		//根据类别移动		if(offset.value.type === "text"){			const rect = myText.value;  //文字			 const textWidth = canvas.value.measureText(rect.text).width;			if (isDragging.value) {				myText.value.x = res.clientX - offset.value.x;				myText.value.y = res.clientY - offset.value.y;				//边界-最左边				if (res.clientX < 0) {					myText.value.x = 0;				}				//边界-最右边				if (res.clientX >= (size.width - rect.width)) {					myText.value.x = size.width - rect.width;				}				//边界-最上边				if (res.clientY >= (size.height - rect.height)) {					myText.value.y = size.height - rect.height;				}				//边界-最底部				if (res.clientY < 0) {					myText.value.y = 0;				}				// isShowTool.value = false;				showOrCloseTool();			}			//先删除			const arr = elements.value;			const newArr = arr.filter(item => item.type != 'text'); //筛选出不是 images的元素 为新的数组			elements.value = newArr;			//新增元素			elements.value.push(myText.value);		}else{			const rect = avatar.value; //矩形属性 - 头像			if (isDragging.value) {				avatar.value.x = res.clientX - offset.value.x;				avatar.value.y = res.clientY - offset.value.y;				//边界-最左边				if (res.clientX < 0) {					avatar.value.x = 0;				}				//边界-最右边				if (res.clientX >= (size.width - rect.width)) {					avatar.value.x = size.width - rect.width;				}				//边界-最上边				if (res.clientY >= (size.height - rect.height)) {					avatar.value.y = size.height - rect.height;				}				//边界-最底部				if (res.clientY < 0) {					avatar.value.y = 0;				}				// isShowTool.value = false;				showOrCloseTool();			}			//先删除			const arr = elements.value;			const newArr = arr.filter(item => item.cate != 'avatarCate'); //筛选出不是 images的元素 为新的数组			elements.value = newArr;			//新增元素			elements.value.push(avatar.value);		}		redraw(); //绘制元素		canvas.value.draw();	}	//结束-重置属性	const teEvent = (e) => {		isDragging.value = false; //已经放下了		// isShowTool.value = true; //显示		offset.value = {			x:0,y:0,type:'text'		};		showOrCloseTool();	}	//底部工具栏开启关闭	const showOrCloseTool =()=>{		const show = isShowTool.value;		if(show){			isShowTool.value = false; //关闭			btnText.value = "开";		}else{			isShowTool.value = true; //显示			btnText.value = "关";		}	};	//保存为图片	const saveToImage =async()=>{		const res = await new Promise((resolve,reject)=>{			//导出临时路径			uni.canvasToTempFilePath({				x:0,				y:0,				width:cSize.value.width,				height:cSize.value.height,				destWidth:cSize.value.width*2,  // 高清倍率				destHeight:cSize.value.height*2,				canvasId:"myCanvas",				success:resolve,				fail:reject			})		});		//成功		if(res.errMsg == "canvasToTempFilePath:ok"){		const tempPath = res.tempFilePath; //获取结果		//保存到相册		uni.saveImageToPhotosAlbum({			filePath:tempPath,			success: (res) => {				uni.showToast({					title:"已保存至相册"				})			},			fail: (e) => {				console.error("错误:",e);				uni.showModal({ content: JSON.stringify(e), showCancel: false })			}		})		}	};</script><stylelang="scss">.poster-box {width100vw;height100vh;position: relative;	}.pb-canvas {width100%;height100%;	}/**工具栏***/.pb-tool {position: absolute;		// width90%;		// height100rpx;background-colorrgba(0000.3);		// left0;		// right0;		// bottom40rpx;		// margin0rpx auto;opacity0;border-radius20rpx;	}.pb-tool-close{width:80rpx;height:80rpx;position: absolute;bottom40rpx;right5%;border-radius50%;background-color:rgba(0000.3);color#fff;font-size1rem;text-align: center;line-height80rpx;	}.pb-t-block {padding10rpx;	}.pb-tb-item {padding10rpx;.pb-tbi-label {font-size0.9rem;color#fff;		}.pb-tbi-block {padding10rpx;align-items: center;.pb-tbi-img {overflow: hidden;padding10rpx;image {width80rpx;height80rpx;				}			}.pb-tbi-circle {image {width80rpx;height80rpx;border-radius50%;				}			}.pb-tbi-i-btn {padding10rpx 20rpx;background-color#e7e96d;margin-left20rpx;border-radius10rpx;font-size0.8rem;color#333;			}		}.pb-tbi-text {padding10rpx 20rpx;background-color#fff;border-radius10rpx;align-items: center;flex-grow1;.pb-tbi-t-input {flex2;			}.pb-tbi-t-btn {padding5rpx 10rpx;background-color#e7e96d;border-radius10rpx;color#333;font-size0.9rem;			}		}.pb-tbi-size {flex-grow1;align-items: center;.pb-tbi-ts-label {font-size0.9rem;color#333;			}.pb-tbi-ts-slider {flex2;			}		}.pb-tbi-block-color{padding10rpx;align-items: center;flex-wrap: wrap;.pb-tbi-color{padding:10rpx;.pb-tbi-c-text{width50rpx;height50rpx;border-radius10rpx;			}		}		}	}.saveBtn{width50%;text-align: center;font-size1rem;font-weight: bold;background-color#e7e96d;color#333;padding:20rpx 10rpx;border-radius10rpx;	}.pb-tool-show {animation: showToolBoxAni 1s forwards;	}.pb-tool-hiden {animation: closeToolBoxAni 1s forwards;	}@keyframes showToolBoxAni {0% {width80rpx;height80rpx;bottom40rpx;right5%;opacity0;padding0rpx;		}100% {width90%;height60vh;bottom40rpx;right5%;opacity1;padding0rpx;			// margin0rpx auto;		}	}@keyframes closeToolBoxAni {0% {width90%;height60vh;bottom40rpx;right5%;opacity1;padding0rpx;		}100% {width80rpx;height80rpx;bottom40rpx;right5%;opacity0;padding0rpx;		}	}</style>