上次(React 主题切换(方案分享🤩))我们已经介绍了3个主题切换的方案,接下来介绍第四种方案,首先看看效果:
本篇Demo源码:GitHubLink
项目内实现:Ys-OoO/I_TabUI
如何实现? 首先我们需要引入dom-to-image
这个依赖,这个依赖的功能如名字一样,就是可以将Dom转化为图片,我们为什么需要转化为图片呢?这就是这个方案的重点了,接下来将步骤分解:
- 点击主题切换时,对我们要换肤的dom进行截图并使用canvas绘制
- 将图片以定位的方式放在目标Dom的上层
- 接下来利用Canvas绘制半径逐渐扩大的圆
- 真正的切换主题(设置样式),删除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');
});
};
梳理一下该函数的流程:
- 首先执行
toCanvas
将dom转为canvas - 然后执行
then
,将canvas挂载到页面上 - 绘制圆,设置主题
- 删除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 切换主题的动效👍