【前端功能点】刮刮卡效果实现

1,275 阅读4分钟

核心思路(主要是三个步骤)

  1. 绘制刮刮卡的封面

1711534137271.png

  1. 鼠标按下并移动时清除灰色

1711534216140.png

  1. 清除后重新绘制新的图

1711534280207.png

核心点

canvas绘制对象的属性globalCompositeOperation='destination-out' 也称为混合模式

具体实现步骤(只写函数体的部分)

  1. 获取props 以及定义内部状态
const {
    width, // 画布的宽
    height, // 画布的高
    maskText, // 蒙层的文字
    maskColor, // 蒙层文字颜色
    maskFont, // 蒙层文字字体大小
    maskBackground, // 蒙层的底色
    cleanAreaRate = 0.8, // 清除到一定比例时全部清除
    contextId = '2d', //
    options = {}, //
    drawResult, // 清除后展示的内容
  } = props;
const canvasRef = React.useRef(null); // canvas元素ref
const [ctx, setCtx] = React.useState(null); // 保存canvas绘画对象
let canDraw: boolean = false; // 判断鼠标是否按下的状态
let showPrize: boolean = true; // 判断可以清除的时机
  1. 保存绘制对象以及初始化刮刮卡蒙层
   React.useEffect(() => { // 保存绘制对象
        if (canvasRef.current && width && height) {
             setCtx(canvasRef.current.getContext(contextId, {
                 willReadFrequently: true, ...options
              }));
        }
    }, [width, height]);
    const initDraw = React.useCallback(() => { // 绘制蒙层
      if (!canvasRef?.current || !ctx) return;
      showPrize = false; // 禁止清除
      ctx.globalCompositeOperation = 'source-over';
      ctx.beginPath();
      ctx.fillStyle = maskBackground; // 设置灰色背景色
      ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); // 绘制灰色背景色
      ctx.closePath();
      ctx.beginPath();
      ctx.font = maskFont;
      ctx.fillStyle = maskColor;
      ctx.textAlign = 'center'; // 文字水平对齐
      ctx.textBaseLine = 'middle'; // 文字垂直方向对齐
      ctx.fillText(maskText, Number(width) / 2, (Number(height) + 10) / 2); // 设置遮罩的文字
      ctx.closePath();
      showPrize = true; // 可以清除
   }, [ctx, showPrize, width, height]);
   
  React.useEffect(() => { // 组件初始化时执行
    initDraw();
  }, [initDraw]);
}
  1. 处理鼠标按下的逻辑
  const onStart = (e) => {
    if (!canvasRef?.current || !ctx || !showPrize) return;
    try {
      canDraw = true;
      document.getElementsByTagName('body')[0].style.overflow = 'hidden'; // 刮奖时设置当前屏幕不可滚动
      drawCircle(e); // 清除的逻辑
    } catch (error) {
      console.error(error);
      canDraw = false;
    }
 };
  1. 处理鼠标移动的逻辑(清除)
  const onMove = (e) => {
    if (!canvasRef?.current || !ctx || !showPrize) return;
    try {
      if (canDraw && showPrize) {
        drawCircle(e); // 清除的逻辑
      }
    } catch (error) {
      console.error(error);
      canDraw = false;
    }
  };
  1. 处理鼠标抬起的逻辑
  const onEnd = React.useCallback(() => {
    if (!canvasRef?.current || !ctx || !showPrize) return;
    try {
      document.getElementsByTagName('body')[0].style.overflow = ''; // 刮奖操作结束设置当前屏幕可滚动
      canDraw = false;
    } catch (error) {
      console.error(error);
      canDraw = false;
    }
  }, [ctx]);

完整代码

import React from 'react';
const Scratch = (props) => {
    const {
        width, // 画布的宽
        height, // 画布的高
        maskText, // 蒙层的文字
        maskColor, // 蒙层文字颜色
        maskFont, // 蒙层文字字体大小
        maskBackground, // 蒙层的底色
        cleanAreaRate = 0.8, // 清除到一定比例时全部清除
        contextId = '2d', //
        options = {}, //
        drawResult, // 清除后展示的内容
      } = props;
      
    const canvasRef = React.useRef(null); // canvas元素ref
    const [ctx, setCtx] = React.useState(null); // 保存canvas绘画对象
    let canDraw: boolean = false; // 判断鼠标是否按下的状态
    let showPrize: boolean = true; // 判断可以清除的时机
    
    React.useEffect(() => { // 保存绘制对象
        if (canvasRef.current && width && height) {
             setCtx(canvasRef.current.getContext(contextId, {
                 willReadFrequently: true, ...options
              }));
        }
    }, [width, height]);
    
    const initDraw = React.useCallback(() => {
      if (!canvasRef?.current || !ctx) return;
      showPrize = false; // 禁止清除
      ctx.globalCompositeOperation = 'source-over';
      ctx.beginPath();
      ctx.fillStyle = maskBackground; // 设置灰色背景色
      ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); // 绘制灰色背景色
      ctx.closePath();
      ctx.beginPath();
      ctx.font = maskFont;
      ctx.fillStyle = maskColor;
      ctx.textAlign = 'center'; // 文字水平对齐
      ctx.textBaseLine = 'middle'; // 文字垂直方向对齐
      ctx.fillText(maskText, Number(width) / 2, (Number(height) + 10) / 2); // 设置遮罩的文字
      ctx.closePath();
      showPrize = true; // 可以清除
   }, [ctx, showPrize, width, height]);
  // 组件初始化时执行
  React.useEffect(() => { 
    initDraw();
  }, [initDraw]);
  // 清除以(x,y)为中心的四周边长20px的正方形的遮罩,c时canvas对象 
  function clearCircle(x, y) {
    ctx.globalCompositeOperation = 'destination-out'; // 关键点-混合模式
    ctx.moveTo(x, y);
    ctx.arc(x, y, 12, 0, Math.PI * 2, true);
    ctx.fill();
    if (typeof drawResult === 'function') {
      drawResult(ctx);
    }
  }
  // 全部清除
  function clearAll() { 
    const canvas = canvasRef.current;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    document.getElementsByTagName('body')[0].style.overflow = '';
    if (typeof drawResult === 'function') {
      drawResult(ctx); // 清除后重新绘制蒙层下面的内容
    }
    showPrize = false;
  }
  
  function getFilledPercentage(_e, w, h) { // 计算当前刮开的面积比例
    const { data } = ctx.getImageData(0, 0, w, h); // 获取整个canvas的元素点
    let scrapeNum = 0;
    // 整个区域的面积
    const area = w * h;
    for (let i = 3, len = data.length; i < len; i += 4) {
      if (data[i] === 0) {
        scrapeNum += 1;
      }
    }
    if (scrapeNum > area * cleanAreaRate) { // 达到一定比例后清除所有
      clearAll();
      return true;
    }
    return false;
  }
  
  function drawCircle(e) { // 清除逻辑
    const canvas = canvasRef.current;
    let x;
    let y;
    if (document && document.documentElement) {
      x = e.clientX + document.documentElement.scrollLeft;
      y = e.clientY + document.documentElement.scrollTop;
    }
    // 手指到canvas元素左边的距离
    const canvX = x - canvas.offsetLeft;
    // 手指到canvas元素上边的距离
    const canvY = y - canvas.offsetTop;
    clearCircle(canvX, canvY); // 清除
    getFilledPercentage(e, canvas.width, canvas.height); // 计算刮开的面积
  }
  
  const onStart = (e) => { // 鼠标按下
    if (!canvasRef?.current || !ctx || !showPrize) return;
    try {
      canDraw = true;
      // 刮奖时设置当前屏幕不可滚动
      document.getElementsByTagName('body')[0].style.overflow = 'hidden';
      drawCircle(e);
    } catch (error) {
      console.error(error);
      canDraw = false;
    }
  };
  
  const onMove = (e) => { // 鼠标移动
    if (!canvasRef?.current || !ctx || !showPrize) return;
    try {
      if (canDraw && showPrize) {
        drawCircle(e);
      }
    } catch (error) {
      console.error(error);
      canDraw = false;
    }
  };
  
  const onEnd = React.useCallback(() => { // 鼠标抬起
    if (!canvasRef?.current || !ctx || !showPrize) return;
    try {
      // 刮奖操作结束设置当前屏幕可滚动
      document.getElementsByTagName('body')[0].style.overflow = '';
      canDraw = false;
    } catch (error) {
      console.error(error);
      canDraw = false;
    }
  }, [ctx]);
  
  React.useEffect(() => { // 鼠标抬起时取消清除的动作
    window.addEventListener('mouseup', onEnd, false);
    return () => {
      window.removeEventListener('mouseup', onEnd);
    };
  }, [onEnd]);
  
   return (
    <canvas
      style={{ touchAction: 'none' }}
      width={`${width}px`}
      height={`${height}px`}
      ref={canvasRef}
      onMouseDown={onStart}
      onMouseMove={onMove}
      onMouseUp={onEnd}
    />
  );
}

demo

export default () => {
  const drawResult = (ctx) => {
    const img = new Image();
    img.src =
      'https://img2.baidu.com/it/u=61792293,1592862768&fm=253&fmt=auto&app=120&f=JPEG?w=1366&h=768';
    img.crossOrigin = 'true';
    img.onload = function () {
      console.log('图片加载完');
      ctx.globalCompositeOperation = 'destination-over'; // 关键点
      ctx.beginPath();
      ctx.drawImage(img, 0, 0, 100, 100);
      ctx.closePath();
    };
  };
  
  return (
      <Scratch
        width={300}
        height={200}
        maskText="请刮开"
        maskColor="#232323"
        maskFont="20px"
        maskBackground="gray"
        drawResult={drawResult}
      />
  );
};

目前功能缺陷(有兴趣的可复制代码自行实现)

  1. 移动端使用未补充,原理是一样的,只是事件不同
  2. drawResult 方法里画线条之类的会有模糊感,目前仅支持画图片
  3. 组件只梳理了核心逻辑,参数判断,取消默认事件等问题均未处理