在刷短视频或者玩一些小游戏的时候,你可能见过这样一个有趣的效果:
用户用手指在屏幕上随意涂抹,原本被遮挡的内容逐渐显露出来,像是“刮刮乐”一样的交互。
这个效果看似神奇,其实用前端就能实现。无论是 <canvas> 还是 <svg>,只要结合合适的绘制方式和混合模式,就能轻松做出这种“涂抹消除”的体验。
本文会带你从 基础原理 到 完整实现,逐步拆解这个效果:
-
如何用 canvas 监听手指/鼠标轨迹并绘制透明区域
-
如何用 svg 的
mask来实现类似效果
看完后,你也能快速写出一个“神奇的涂抹消除”小 demo,用在活动页、小游戏或者有趣的交互场景里。
一 前置,存储数据的设计
对于普通的功能来说,其实可以不用考虑到对应的数据的结构设计,但是我们可以考虑相应的扩展
例如我们可以基于绘制的路径,实现后退前进
当然我一样可以自定义画笔的大小
所以针对这方面的功能,我们需要设计一个可维护的有效数据结构
// 一个基础的
type BasePoint = {
left: number
top: number
}
// 一个线段是由多个点构成的
type BaseLine = {
points: BasePoint[],
size: number // 画笔大小
mode: 'brush' | 'eraser' // 涂抹还是消除
}
// 线段的类型
type Lines = BaseLine[]
以上就是先简单定义的 线段类型,便于后续扩展,可以往里面添加一系列属性。
二 前置,对应的方法了解
对于我们涂抹消除,想的还是尽可能让 pc 和 h5 端都可以使用
所以采用的绑定事件使用的是 Pointer events 指针事件
pointerdown当某指针得以激活时触发。
pointermove当某指针改变其坐标时触发。
pointerup当某指针不再活跃时触发。
接下来就开始我们具体的实现吧!
三 神奇的 canvas
1、准备画布
首先,准备一张图片和一个 canvas 画布,画布的大小和 canvas 大小统一
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
user-select: none;
position: relative;
}
.container img {
touch-action: none;
pointer-events: none;
user-select: none;
}
.container canvas {
position: absolute;
left: 0px;
top: 0px;
z-index: 1;
opacity: 0.6;
}
.active {
color: blue;
}
</style>
</head>
<body>
<div class="container">
<img
src="https://x-design-release.stariicloud.com/poster/7545732a3f987e398c9337020f344335.png"
alt=""
width="511px"
height="511px"
/>
<canvas id="canvas" />
</div>
<div>
<button id="brush" class="active">涂抹</button>
<button id="eraser">消除</button>
</div>
<script>
</script>
</body>
</html>
页面效果如下图
2、处理事件
核心逻辑:
- 定义核心数据 let lines = [] 存储总的线段数据 let currrentLines = { points: [] } 存储当前操作的数据
- 鼠标在滑动操作时,记录当前的鼠标点位,并把其放入 currrentLines 的 points 中,开启绘制
- 把 currrentLines 数据推入 lines
<script>
const canvas = document.querySelector('#canvas');
const ctx = canvas.getContext('2d');
let isDown = false;
canvas.width = 511;
canvas.height = 511;
const BRUSH_SIZE = 40; // 画笔大小
let mode = 'brush' // 刷子类型
let lines = []
let currentLines = {
points: []
}
const pointerDown = () => {
isDown = true;
currentLines.mode = mode
currentLines.size = BRUSH_SIZE
};
const pointerMove = (e) => {
e.stopPropagation();
e.preventDefault();
if (!isDown) return;
const { clientX, clientY } = e;
curentLines = {
...currentLines,
points: [
...currentLines.points,
{
left: clientX,
top: clientY
}
]
}
draw([...lines, curentLines]); // 暂未实现
};
const pointerUp = () => {
if (!isDown) return
isDown = false;
lines = [...lines, curentLines]
currentLines = {
points: []
}
};
const bindEvent = () => {
canvas.addEventListener('pointerdown', pointerDown);
canvas.addEventListener('pointermove', pointerMove);
window.addEventListener('pointerup', pointerUp);
brush.addEventListener('click', (e) => {
mode = 'brush'
e.target.classList.add('active')
eraser.classList.remove('active')
})
eraser.addEventListener('click', (e) => {
mode = 'erase'
e.target.classList.add('active')
brush.classList.remove('active')
})
};
bindEvent();
</script>
3、绘制
在绘制之前,先了解下 canvas 的 globalCompositeOperation 融合属性
globalCompositeOperation 属性设置要在绘制新形状时应用的合成操作的类型。
实现消除效果属性值:
"destination-out"仅保留现有画布内容和新形状不重叠的部分。
const draw = (lines) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
lines.forEach(({ points, mode, size }) => {
ctx.save();
ctx.beginPath();
points.forEach(({ left, top }, index) => {
if (!index) {
ctx.moveTo(left, top);
ctx.lineTo(left, top);
} else {
ctx.lineTo(left, top);
}
})
if (mode === 'erase') {
ctx.globalCompositeOperation = 'destination-out'; // 擦除模式
}
ctx.strokeStyle = '#3388FF'; // 线条颜色
ctx.lineWidth = size; // 线条宽度
ctx.stroke();
ctx.closePath();
ctx.restore();
})
}
效果如下
但是目前看到,绘制的线段还不太完美,我们可以再优化下
ctx.lineCap = 'round'; // 设置线段的末端的样式
ctx.lineJoin = 'round'; // 设置线段连接的样式
好了 完美!
四 神奇的 svg
与 svg 的差别,就是绘制层的区别,逻辑层可复用,所以我们只需要对上面的 html 内容进行稍许的改造,就可以让 绘制逻辑 从 canvas 替换到 svg
在进行具体的实现之前,先介绍一些会用到标签
1、defs: SVG 允许我们定义以后需要重复使用的图形元素。建议把所有需要再次使用的引用元素定义在
defs元素里面。这样做可以增加 SVG 内容的易读性和无障碍。在defs元素中定义的图形元素不会直接呈现。你可以在你的视口的任意地方利用<use>元素呈现这些元素。简洁的说,就是可以通过 defs 用来定义一些不在页面直接呈现的一些元素,后续可以通过一些其他的方式来使用 2、g: 元素是一个容器,用于将其他 SVG 元素进行分组。设置的样式会同步到组内的元素当中
3、image: 用于展示图片
好了,介绍完一些标签之后,我们按照老规矩,先把静态的 html 写出来
1、准备画布 & 处理事件
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
height: 511px;
user-select: none;
position: relative;
}
.container .svg-main {
position: absolute;
left: 0px;
top: 0px;
z-index: 1;
}
#blueLayer {
opacity: 0.6;
}
.active {
color: blue;
}
</style>
</head>
<body>
<div class="container">
<svg
class="svg-main"
width="511px"
height="511px"
xmlns="http://www.w3.org/2000/svg"
viewbox="0 0 511 511"
. >
<defs>
<!-- mask 用于消除蓝色路径 -->
<mask id="eraseMask" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="511" height="511" fill="white" />
<!-- 黑色区域表示要隐藏的路径 -->
</mask>
</defs>
<image
href="https://x-design-release.stariicloud.com/poster/7545732a3f987e398c9337020f344335.png"
x="0"
y="0"
width="511px"
height="511px"
/>
<!-- 蓝色路径图层 -->
<g id="blueLayer" mask="url(#eraseMask)"></g>
</svg>
</div>
<div>
<button id="brush" class="active">涂抹</button>
<button id="eraser">消除</button>
</div>
<script>
const svgMain = document.querySelector(".svg-main");
const gLayer = document.querySelector("#blueLayer");
const maskLayers = document.querySelector("#eraseMask");
let isDown = false;
const BRUSH_SIZE = 40;
let mode = "brush";
let lines = [];
let currentLines = {
points: [],
};
const draw = (lines) => {};
const pointerMove = (e) => {
e.stopPropagation();
e.preventDefault();
if (!isDown) return;
const { clientX, clientY } = e;
curentLines = {
...currentLines,
points: [
...currentLines.points,
{
left: clientX,
top: clientY
}
]
}
draw([...lines, curentLines]); // 暂未实现
};
const pointerUp = () => {
if (!isDown) return
isDown = false;
lines = [...lines, curentLines]
currentLines = {
points: []
}
};
const bindEvent = () => {
canvas.addEventListener('pointerdown', pointerDown);
canvas.addEventListener('pointermove', pointerMove);
window.addEventListener('pointerup', pointerUp);
brush.addEventListener('click', (e) => {
mode = 'brush'
e.target.classList.add('active')
eraser.classList.remove('active')
})
eraser.addEventListener('click', (e) => {
mode = 'erase'
e.target.classList.add('active')
brush.classList.remove('active')
})
};
bindEvent();
</script>
</body>
</html>
页面的效果就呈现成这样啦
2、绘制
梳理下核心逻辑
1、在按下绘制时,我们可以往 g 标签中插入一个 path 路径节点,这样就可以展现涂抹的部分
2、当切换消除时,我们利用 svg mask 的能力,往 mask 标签中也插入一个 path 标签,因为 mask 本生就应用到了 g 标签组上,所以“消除”也有了,
消除 mask 核心原理:mask 是一张灰度图,白色区域显示,黑色区域隐藏,灰色半透明。 应用在元素上时,相当于把“目标元素”用这张灰度图重新裁剪、混合
接下来看下具体的代码
const createPath = (points, color, width) => {
const path = document.createElementNS(
"http://www.w3.org/2000/svg",
"path"
);
const d = points
.map((p, i) =>
i === 0
? `M ${p.left} ${p.top} L ${p.left} ${p.top}`
: `L ${p.left} ${p.top}`
).join(" ");
path.setAttribute("d", d);
path.setAttribute("stroke", color);
path.setAttribute("stroke-width", width);
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
path.setAttribute("fill", "none");
return path;
};
const draw = (lines) => {
gLayer.innerHTML = "";
maskLayers.innerHTML =
'<rect x="0" y="0" width="511" height="511" fill="white" />';
lines.forEach(({ points, mode, size }) => {
const path = createPath(points, "#3388FF", size);
if (mode === "brush") {
gLayer.appendChild(path);
} else {
maskPath.setAttribute("stroke", "black");
maskLayers.appendChild(path)
}
});
};
效果展示
但是此时发现了一个问题,我消除过的区域,如果想要再次涂抹呢,发现是不行的。
原因是:mask 是「不可逆」的 —— 一旦挖掉,就不会因为后续 brush 再恢复。
所以接下来我们换个方式优化下
3、优化持续涂抹消除
上面讲到,我们 mask 是不可逆的,又知道 mask 区域 黑色表示是消除的部分,白色表示是显示的部分
那这样 我们是不是可以,在重新涂抹的时候,一样也往 mask 里面添加一个 填充色为白色的 path,来把 黑的消除的部分给盖住呢??
直接来试试,具体看代码
const draw = (lines) => {
gLayer.innerHTML = "";
maskLayers.innerHTML =
'<rect x="0" y="0" width="511" height="511" fill="white" />';
lines.forEach(({ points, mode, size }) => {
const path = createPath(points, "#3388FF", size);
if (mode === "brush") {
gLayer.appendChild(path);
}
// 首先克隆出一个节点
const maskPath = path.cloneNode(true);
// 根据是消除还是涂抹进行变化颜色
maskPath.setAttribute("stroke", mode === "brush" ? "white" : "black");
// 添加到 mask 中
maskLayers.appendChild(maskPath);
});
};
具体效果
ok 可行很完美
至此我们就完成了 svg 的涂抹和消除效果
五,历史记录的回退和撤销
还记得我们之前用来存储 lines 的数据不,
所以由此我们扩展回退和撤销就很简单了,只要去操作 lines 即可
大家可以试试看!!
六、结语
距离上一篇文章期间间隔属实有点久了,最近也是因为换了公司,所以一直在忙,接下来,我会继续给大家更新一些好玩的东西,
最后感谢各位的观看
祝大家生活如意,工作如意!