最近在使用美图秀秀小程序时,发现它的四宫格拼图功能非常实用。出于对技术的好奇,我仔细琢磨了一下背后的原理,并基于 Taro 自己动手实现了一套类似的功能。本文将分享在微信小程序中实现拼图功能的核心思路和代码。
一、基础版:固定位置的静态拼图
静态拼图的实现相对简单:用户选择好图片后,按顺序存入数组,然后利用 Canvas 将图片按指定位置重新绘制,最后导出并下载即可。
以下是核心代码示例。我们需要在页面中放置一个隐藏的 Canvas(例如设置 z-index: -9999),然后将宫格和照片依次绘制上去。需要特别注意的是,ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight) 会将照片缩放至指定的宽高,并将其左上角定位到 (drawX, drawY)。为了确保图片不超出各自的宫格区域,我们在绘制前使用了 ctx.rect() 和 ctx.clip() 进行裁剪,这样溢出的部分就会被自动隐藏。
Taro.createSelectorQuery()
.select("#puzzleCanvas")
.fields({ node: true, size: true })
.exec(async (res) => {
if (!res[0] || !res[0].node) {
reject(new Error("Canvas not found"));
return;
}
const canvas = res[0].node;
const ctx = canvas.getContext("2d");
const dpr = Taro.getSystemInfoSync().pixelRatio;
const canvasWidth = 750;
const canvasHeight = 750;
canvas.width = canvasWidth * dpr;
canvas.height = canvasHeight * dpr;
ctx.scale(dpr, dpr);
// 填充白色背景
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
const photoInfo = await Taro.getImageInfo({ src: photo.path });
const img = canvas.createImage();
img.src = photoInfo.path;
ctx.save();
ctx.beginPath();
// 裁剪出当前宫格的区域
ctx.rect(rect.x, rect.y, rect.w, rect.h);
ctx.clip();
// 绘制图片
ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
ctx.restore();
});
二、进阶版:支持拖拽与缩放的动态拼图
为了提升用户体验,拼图工具通常需要允许用户手动调整图片在宫格中的位置和大小。这种情况下,核心难点在于如何将用户在视图层的拖拽(挪动)和缩放操作,精确地换算为 Canvas 绘制时的 drawX, drawY, drawWidth, drawHeight 参数。
下面是计算绘制参数的核心逻辑。我们需要根据图片的原始比例、目标宫格的比例,以及用户操作产生的偏移量(transform.x, transform.y)和缩放比例(transform.scale),来动态计算最终在 Canvas 上的绘制坐标和尺寸:
const transform = photoTransforms[index];
let drawWidth, drawHeight, drawX, drawY;
if (
transform &&
transform.frameW &&
transform.frameH &&
transform.viewW &&
transform.viewH &&
transform.imgW &&
transform.imgH
) {
// 使用基础 Canvas 矩形作为目标边界框
const targetRatio = rect.w / rect.h;
const imgRatio = transform.imgW / transform.imgH;
let baseDrawWidth, baseDrawHeight;
if (imgRatio > targetRatio) {
baseDrawHeight = rect.h;
baseDrawWidth = rect.h * imgRatio;
} else {
baseDrawWidth = rect.w;
baseDrawHeight = rect.w / imgRatio;
}
// 计算 DOM 节点到 Canvas 的缩放比例
const domToCanvasScale = baseDrawWidth / transform.viewW;
const transformScale = transform.scale || 1;
drawWidth = baseDrawWidth * transformScale;
drawHeight = baseDrawHeight * transformScale;
// 计算最终的绘制坐标
drawX = rect.x + transform.x * domToCanvasScale;
drawY = rect.y + transform.y * domToCanvasScale;
} else {
// 默认的 Aspect Fill(等比例缩放填充)计算逻辑
const imgRatio = photoInfo.width / photoInfo.height;
const targetRatio = rect.w / rect.h;
if (imgRatio > targetRatio) {
drawHeight = rect.h;
drawWidth = rect.h * imgRatio;
drawX = rect.x + (rect.w - drawWidth) / 2;
drawY = rect.y;
} else {
drawWidth = rect.w;
drawHeight = rect.w / imgRatio;
drawX = rect.x;
drawY = rect.y + (rect.h - drawHeight) / 2;
}
}
在视图层,我们可以借助小程序的 MovableArea 和 MovableView 组件来轻松捕获用户的拖拽和双指缩放手势,并将这些变换状态(Transform)保存下来,供后续 Canvas 绘制时使用:
const handleMoveChange = (
index: number,
e: BaseEventOrig<MovableViewProps.onChangeEventDetail>
) => {
// 只响应用户的实际触摸操作,忽略回弹等动画带来的触发
if (
e.detail.source === "touch" ||
e.detail.source === "touch-out-of-bounds" ||
e.detail.source === "friction"
) {
const transform = photoTransforms?.[index];
if (transform) {
onTransformChange?.(index, {
...transform,
x: e.detail.x,
y: e.detail.y,
});
}
}
};
const handleScaleChange = (
index: number,
e: BaseEventOrig<MovableViewProps.onScaleEventDetail>
) => {
const transform = photoTransforms?.[index];
if (transform) {
onTransformChange?.(index, {
...transform,
x: e.detail.x,
y: e.detail.y,
scale: e.detail.scale,
});
}
};
// 视图层渲染
<MovableArea style={{ width: "100%", height: "100%", overflow: "hidden" }}>
<MovableView
direction="all"
outOfBounds={false}
scale={true}
scaleMin={1}
scaleMax={2}
scaleValue={initialTransforms[index]?.scale || 1}
x={initialTransforms[index]?.x ?? 0}
y={initialTransforms[index]?.y ?? 0}
style={{
width: transform ? `${transform.viewW}px` : "100%",
height: transform ? `${transform.viewH}px` : "100%",
}}
onChange={(e) => handleMoveChange(index, e)}
onScale={(e) => handleScaleChange(index, e)}
>
<Image
src={photo.path}
style={{ width: "100%", height: "100%", display: "block" }}
onLoad={(e) => {
setImageSizes((prev) => ({
...prev,
[index]: {
width: Number(e.detail.width),
height: Number(e.detail.height),
},
}));
}}
/>
</MovableView>
</MovableArea>;
const handleTransformChange = (index: number, transform: PhotoTransform) => {
setPhotoTransforms((prev) => ({
...prev,
[index]: transform,
}));
};
// 变换状态的数据结构定义
export interface PhotoTransform {
/** MovableView 的 x 轴偏移量(包含拖拽和缩放带来的视觉偏移) */
x: number;
/** MovableView 的 y 轴偏移量(包含拖拽和缩放带来的视觉偏移) */
y: number;
/** 用户双指缩放的比例(1为原始大小) */
scale: number;
/** MovableView 容器的计算宽度(为了让图片短边撑满边框计算出的基础视图宽度) */
viewW: number;
/** MovableView 容器的计算高度(为了让图片短边撑满边框计算出的基础视图高度) */
viewH: number;
/** 外层可视边框(MovableArea)的实际 DOM 宽度 */
frameW: number;
/** 外层可视边框(MovableArea)的实际 DOM 高度 */
frameH: number;
/** 原图片的实际像素宽度 */
imgW: number;
/** 原图片的实际像素高度 */
imgH: number;
}
通过上述两部分的结合,我们就能在微信小程序中实现一个体验流畅、支持拖拽和缩放的自定义拼图功能了。