React 主题切换(canvas截屏换肤)

上次(React 主题切换(方案分享🤩))我们已经介绍了3个主题切换的方案,接下来介绍第四种方案,首先看看效果:

本篇Demo源码:GitHubLink

项目内实现:Ys-OoO/I_TabUI

如何实现? 首先我们需要引入dom-to-image这个依赖,这个依赖的功能如名字一样,就是可以将Dom转化为图片,我们为什么需要转化为图片呢?这就是这个方案的重点了,接下来将步骤分解:

  1. 点击主题切换时,对我们要换肤的dom进行截图并使用canvas绘制
  2. 将图片以定位的方式放在目标Dom的上层
  3. 接下来利用Canvas绘制半径逐渐扩大的圆
  4. 真正的切换主题(设置样式),删除Canvas

是不是豁然开朗,实际上这里的canvas截屏换肤只是一个动效,真正的样式切换还是需要使用我上一篇文章中所讲的其中一种方案,这里就不多赘述了,感兴趣的同学可以移步--> React 主题切换(方案分享🤩)

接下来我们分步讲述。

点击主题切换时,对我们要换肤的dom进行截图并使用canvas绘制

使用dom-to-image将目标dom转换为图片(Base64),并使用canvas绘制(复制)图片:

js 复制代码
export function toCanvas(el) {
  return new Promise(resolve => {
  //转换为图片
    domtoimage.toPng(el)
      .then(pngDataURI => {
        const canvas = document.createElement("canvas");
        const rect = el.getBoundingClientRect();
        //canvas样式设置(位置大小)
        canvas.style.position = "fixed"
        canvas.style.left = rect.left + "px"
        canvas.style.top = rect.top + "px"
        canvas.style.zIndex = "999"
        canvas.width = rect.width
        canvas.height = rect.height
        canvas.style.width = rect.width + 'px'
        canvas.style.height = rect.height + 'px'
        const context = canvas.getContext('2d');
        //创建一个Image元素,并将其源设置为转换后的URI
        const img = new Image();
        img.src = pngDataURI;
        //当图像加载完成时,将图像绘制到 canvas 上
        img.onload = () => {
          context.drawImage(img, 0, 0);
          setTimeout(() => resolve(canvas))
        }
      })
  })
}

将图片以定位的方式放在目标Dom的上层

实际上在绘制canvas时就已经为canvas设置好了位置信息,接下来我们只需要使用该函数并添加到目标位置即可 以React为例:

js 复制代码
function App() {
  const themeContext = useContext(ThemeContext);
  const [theme, setTheme] = useState(themeContext);
  const parentRef = useRef();
  const targetRef = useRef();

  const toggleTheme = (e) => {
    toCanvas(targetRef.current).then((canvas) => {
      parentRef.current.appendChild(canvas);
      ...
    });
  };

  return (
    <ThemeContext.Provider value={theme}>
      <div ref={parentRef}>
        <div ref={targetRef}>
          <button onClick={toggleTheme}>👻点赞+收藏👻</button>
        </div>
      </div>
    </ThemeContext.Provider>
  );
}

至此,我们已经将与切换前Dom一样的canvas添加到了指定位置,接下来要做的就是在canvas上绘制出一个半径扩大的圆形即可。

接下来利用Canvas绘制半径逐渐扩大的圆 + 切换主题(设置样式),删除canvas

接下来我们编写最终的toggleTheme函数:

js 复制代码
  const toggleTheme = (e) => {
    toCanvas(targetRef.current).then((canvas) => {
      parentRef.current.appendChild(canvas);
      //绘制半径逐渐扩大的圆
      crop(canvas, e, { reverse: theme === 'dark' }).then((canvas) => {
        //绘制结束后删除canvas
        parentRef.current.removeChild(canvas);
      });
      //真正的修改样式,设置主题
      setTheme(theme === 'light' ? 'dark' : 'light');
    });
  };

梳理一下该函数的流程:

  1. 首先执行toCanvas将dom转为canvas
  2. 然后执行then,将canvas挂载到页面上
  3. 绘制圆,设置主题
  4. 删除canvas

🆗 现在我们只需要完成crop函数即可

crop函数接收3个参数:画布canvas,点击事件源,是否反转

看看代码实现:

js 复制代码
export const crop = (canvas, initialPosition, { reverse = false }) => {
  const ctx = canvas.getContext('2d');
  //获取点击时鼠标坐标,将以其作为圆心位置
  const { x, y } = getMousePos(canvas, initialPosition);
  //计算半径
  const maxRadius = getMaxRadius(canvas);
  return new Promise(resolve => {
    let progress = 0;
    const duration = 60;
    //填充色
    ctx.fillStyle = 'rgba(255, 255, 255, 1)';
    //重点!设置了 Canvas 2D 上下文的全局合成操作(`globalCompositeOperation`),它决定了新绘制的内容如何与已有的内容合成
    ctx.globalCompositeOperation = reverse ? 'destination-in' : 'destination-out';

    function draw() {
      let radius;
      if (reverse) {
        //缓动函数决定新的半径
        radius = easeInOutQuint(progress, maxRadius, -maxRadius, duration);
      } else {
        radius = easeInOutQuart(progress, 0, maxRadius, duration);
      }
      //开始绘制
      ctx.beginPath();
      ctx.arc(x, y, radius, 0, Math.PI * 2, false);
      ctx.fill();

      progress++;

      if (progress < duration) {
        //递归请求浏览器调用draw来绘制下一帧
        requestAnimationFrame(draw);
      } else {
        resolve(canvas);
      }
    }

    draw();
  })
}

上述代码中我们在原有canvas上使用fill来填充一个纯白色不透明的圆形,如果仅仅是这样的话,就会绘制出一个没有内容纯白的圆,显然这不是我们想要的,我们还需要显示原本canvas的内容以达到一个和谐的过渡效果。这就引出了一个新配置:globalCompositeOperation,他会决定新绘制的内容如何与已有的内容合成。可以看看这篇文章--->动画效果之 Canvas学习-globalCompositeOperation详解

  • 'destination-in':只保留新、旧图片重叠的新圆形区域,其余透明
  • 'destination-out':只保留新、旧图像的非重叠的旧圆形区域,其余为透明

此外,由于合成方式不同,我们每一帧的绘制圆的半径变化趋势也不同。

上面代码有些函数未给出,这里补全:

js 复制代码
import domtoimage from "dom-to-image";

export function toCanvas(el) {
  return new Promise(resolve => {
    domtoimage.toPng(el)
      .then(pngDataURI => {
        const canvas = document.createElement("canvas");
        const rect = el.getBoundingClientRect();
        canvas.style.position = "fixed"
        canvas.style.left = rect.left + "px"
        canvas.style.top = rect.top + "px"
        canvas.style.zIndex = "999"
        canvas.width = rect.width
        canvas.height = rect.height
        canvas.style.width = rect.width + 'px'
        canvas.style.height = rect.height + 'px'
        const context = canvas.getContext('2d');
        const img = new Image();
        img.src = pngDataURI;
        img.onload = () => {
          context.drawImage(img, 0, 0);
          setTimeout(() => resolve(canvas))
        }
      })
  })
}

export function easeInOutQuint(elapsed, initialValue, amountOfChange, duration) {
  if ((elapsed /= duration / 2) < 1) {
    return amountOfChange / 2 * elapsed * elapsed * elapsed * elapsed * elapsed + initialValue;
  }
  return amountOfChange / 2 * ((elapsed -= 2) * elapsed * elapsed * elapsed * elapsed + 2) + initialValue;
}

export function easeInOutQuart(elapsed, initialValue, amountOfChange, duration) {
  if ((elapsed /= duration / 2) < 1) {
    return amountOfChange / 2 * elapsed * elapsed * elapsed * elapsed + initialValue;
  }
  return -amountOfChange / 2 * ((elapsed -= 2) * elapsed * elapsed * elapsed - 2) + initialValue;
}

function getMousePos(canvas, evt) {
  const rect = canvas.getBoundingClientRect();
  return {
    x: ((evt.clientX - rect.left) / (rect.right - rect.left) * canvas.width),
    y: ((evt.clientY - rect.top) / (rect.bottom - rect.top) * canvas.height)
  };
}

function getMaxRadius(canvas) {
  return Math.sqrt(Math.pow(canvas.width, 2) + Math.pow(canvas.height, 2));
}

export const crop = (canvas, initialPosition, { reverse = false }) => {
  const ctx = canvas.getContext('2d');

  const { x, y } = getMousePos(canvas, initialPosition);

  const maxRadius = getMaxRadius(canvas);
  return new Promise(resolve => {
    let progress = 0;
    const duration = 60;
    ctx.fillStyle = 'rgba(255, 255, 255, 1)';
    ctx.globalCompositeOperation = reverse ? 'destination-in' : 'destination-out';

    function draw() {
      let radius;
      if (reverse) {
        radius = easeInOutQuint(progress, maxRadius, -maxRadius, duration);
        console.log('destination-in', radius);
      } else {
        radius = easeInOutQuart(progress, 0, maxRadius, duration);
        console.log(radius);

      }

      ctx.beginPath();
      ctx.arc(x, y, radius, 0, Math.PI * 2, false);
      ctx.fill();

      progress++;

      if (progress < duration) {
        requestAnimationFrame(draw);
      } else {
        resolve(canvas);
      }
    }

    draw();
  })
}

感谢大家观看☺️! 此外,该方案起初在这里看到的-->实现 Telegram 切换主题的动效👍

相关推荐
熊的猫29 分钟前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
瑶琴AI前端1 小时前
uniapp组件实现省市区三级联动选择
java·前端·uni-app
会发光的猪。1 小时前
如何在vscode中安装git详细新手教程
前端·ide·git·vscode
我要洋人死2 小时前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人2 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人2 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR2 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香2 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel
q2498596933 小时前
前端预览word、excel、ppt
前端·word·excel
小华同学ai3 小时前
wflow-web:开源啦 ,高仿钉钉、飞书、企业微信的审批流程设计器,轻松打造属于你的工作流设计器
前端·钉钉·飞书