微信小程序实现美图秀秀拼图功能

Database and Ruby, Python, History


最近在使用美图秀秀小程序时,发现它的四宫格拼图功能非常实用。出于对技术的好奇,我仔细琢磨了一下背后的原理,并基于 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;
  }
}

在视图层,我们可以借助小程序的 MovableAreaMovableView 组件来轻松捕获用户的拖拽和双指缩放手势,并将这些变换状态(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;
}

通过上述两部分的结合,我们就能在微信小程序中实现一个体验流畅、支持拖拽和缩放的自定义拼图功能了。