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 切换主题的动效👍

相关推荐
loey_ln2 分钟前
webpack配置和打包性能优化
前端·webpack·性能优化
建群新人小猿3 分钟前
会员等级经验问题
android·开发语言·前端·javascript·php
爱上语文4 分钟前
HTML和CSS 表单、表格练习
前端·css·html
djk888814 分钟前
Layui Table 行号
前端·javascript·layui
loey_ln40 分钟前
FIber + webWorker
javascript·react.js
NightCyberpunk1 小时前
HTML、CSS
前端·css·html
xcLeigh1 小时前
HTML5超酷响应式视频背景动画特效(六种风格,附源码)
前端·音视频·html5
zhenryx1 小时前
前端-react(class组件和Hooks)
前端·react.js·前端框架
ZwaterZ1 小时前
el-table-column自动生成序号&&在序号前插入图标
前端·javascript·c#·vue
zhangjr05753 小时前
【HarmonyOS Next】鸿蒙实用装饰器一览(一)
前端·harmonyos·arkts