前一节课中漏了一个知识点,当内容很长时需要滚动,这个滚动条是很影响美观的。在MacOS下的还能忍,win系统下简直不能看。如何让长内容能滚动又不显示滚动条呢,我尝试过很多办法,最终下面这个方法目前来说是最完美的。我们创建一个css文件。
_ModelContent.css
css
/*
* 本样式表用于隐藏滚动条但保留滚动功能
*/
/* 隐藏 Chrome、Safari 和 Opera 的滚动条 */
.noscrollbar::-webkit-scrollbar {
display: none;
}
/* 为 IE、Edge 和 Firefox 隐藏滚动条 */
.noscrollbar {
-ms-overflow-style: none;
/* IE 和 Edge */
scrollbar-width: none;
/* Firefox */
}
把它引入 到 ModelContent组件中就好了。目前我测试了Edge、Safari、Chrome三款浏览器,效果不错。其它的没有测试,不知道什么效果,欢迎大家告诉我。
再次升级Draggable组件
关于前面我已经讲过Draggable组件,想让一个组件移动起来不难,想要在弹窗中多状态下的移动有点难度。
动态获取视口的大小参数
_useWindowSize.jsx
javascript
import { useState, useEffect } from 'react';
/**
* 动态获取窗口的宽高
* @returns
*/
export const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const updateSize = () => setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}, []);
return windowSize;
}
当调整浏览器的大小时,我们要实时动态的获取视口的大小,以使我们的弹窗及时做出响应。
弹窗弹出时的主体动画
javascript
//弹窗的动画
const attentionKeyframes = keyframes`
from,to {
transform: scale(1);
}
50% {
transform: scale(1.03);
}
`;
//弹窗的开始时动画
const anim = css`
animation: ${attentionKeyframes} 400ms ease;
`;
//弹窗的结束时动画
const stopAnim = css`
animation: null;
`;
设置加载后运行动画,
javascript
// 弹窗注目动画的监听
useEffect(function () {
// 弹窗动画监听事件
const listener = (e) => {
if (e.type === "animationend") {
setAttentionStyle(stopAnim);
}
};
if (wrapperRef.current !== null) {
wrapperRef.current.addEventListener("animationend", listener, true);
}
return () => {
if (wrapperRef.current !== null) {
wrapperRef.current.removeEventListener("animationend", listener);
}
};
}, []);
只运行一次,所以useEffect中没有依赖。
如果transform动画有多个属性动画,而主体的位置又是发生变化的,那么这个属性一定要分割开分别进行动画,原为transform动画是针对原始位置的动画,当主体位移后,动画还在原来的位置动画,这就很尴尬了。所以我们要调整
javascript
...
return (
<Box
ref={wrapperRef}
sx={{
transform: `translate(${position.x}px, ${position.y}px)`,
cursor: canDrag ? isDragging ? "grabbing" : "grab" : "default",
transition: isDragging ? null : `transform 200ms ease-in-out`,
}}
onMouseDown={handleMouseDown}
onMouseMove={onMouseMove}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
<Box
sx={{
transform: `${isDragging ? "scale(1.03)" : "scale(1)"}`,
transition: `transform 200ms ease-in-out`,
}}
css={attentionStyle}
>
{
children
}
</Box>
</Box>
);
上面我们做了两层嵌套,外面一层执行位置动画,里面一层执行缩放动画。因为这一层相对于外层的位置始终不变。外面带着内层移动了,但它相对于外层而言位置没有发生变化。
移动
移动的原理很简单,移动的偏移量 = 鼠标当前的位置 - 上次的偏移量后的位置(初始为0);最小化、最大化、正常模式三个状态下的移动量都是分别保存的,当弹窗处于某一种状态下时就把它的位置信息更新到 position中以实现更新UI。
javascript
const normalPos = useRef({ x: 0, y: 0 }); // 正常模式下弹窗的位置(translate的值)
const minPos = useRef({ x: 0, y: 0 }); // 最小化时的位置
const maxPos = { x: 0, y: 0 }; // 最大化时的位置,因为最大化时弹窗的位置是固定的,所以不需要ref
// 当所有模式下的位置变化都是通过position来反映到UI上的,所以position是唯一的位置状态
const [position, setPosition] = useState({x: 0, y: 0}); // 弹窗的位置(translate的值)
// 当鼠标按下时,记录鼠标的位置并以当前位置为基准进行拖动(相对位置),与position的差值为偏移量,position为上一次的偏移量。
// 因为采用的是translate的方式进行拖动,这种方式下,是以组件第一次渲染的位置为基准参考点(也就是相对0,0的位置)进行拖动的.
// 正常模式下的偏移量
const normalOffsetX = useRef(0); // x轴偏移量
const normalOffsetY = useRef(0); // y轴偏移量
// 最小化时的偏移量
const minOffsetX = useRef(0); // x轴偏移量
const minOffsetY = useRef(0); // y轴偏移量
const initedRect = useRef(0); // 初始化后的弹窗大小
偏移量的计算如下:
javascript
// 鼠标移动事件
const handleMouseMove = (e) => {
if (isDragging) {
switch (stateMode) {
case 0:
const xt = e.clientX - minOffsetX.current;
const yt = e.clientY - minOffsetY.current;
const xtMinTop = -((windowSize.height - minHeight) / 2 - 10);
const xtMaxTop = (windowSize.height - minHeight) / 2 - 10;
const xtMinLeft = -((windowSize.width - minWidth) / 2 - 10);
const xtMaxLeft = (windowSize.width - minWidth) / 2 - 10;
const xm = xt < xtMinLeft ? xtMinLeft : xt > xtMaxLeft ? xtMaxLeft : xt;
const ym = yt < xtMinTop ? xtMinTop : yt > xtMaxTop ? xtMaxTop : yt;
minPos.current = { x: xm, y: ym};
setPosition({ ...minPos.current });
break;
case 2:
break;
default:
const xTmp = e.clientX - normalOffsetX.current;
const yTmp = e.clientY - normalOffsetY.current;
const minLetf = -(windowSize.width - initedRect.current.width) / 2;
const minTop = -(windowSize.height - initedRect.current.height) / 2;
const maxLeft = (windowSize.width - initedRect.current.width) / 2;
const maxTop = (windowSize.height - initedRect.current.height) / 2;
const x = xTmp < minLetf ? minLetf : xTmp > maxLeft ? maxLeft : xTmp;
const y = yTmp < minTop ? minTop : yTmp > maxTop ? maxTop : yTmp;
normalPos.current = { x, y };
setPosition({ ...normalPos.current });
break;
}
}
};
状态0 为最小化,1 为正常模式、2为最大化模式,由于最大化下是固定的,所以不用复杂计算。
完整的代码如下:
_Draggable.jsx
javascript
/** @jsxImportSource @emotion/react */
import { css, keyframes } from '@emotion/react'
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import { useOutsideClick } from './_useOutsideClick';
import { useWindowSize } from './_useWindowSize';
import { minHeight, minWidth } from './_ModelConfigure';
//弹窗的动画
const attentionKeyframes = keyframes`
from,to {
transform: scale(1);
}
50% {
transform: scale(1.03);
}
`;
//弹窗的开始时动画
const anim = css`
animation: ${attentionKeyframes} 400ms ease;
`;
//弹窗的结束时动画
const stopAnim = css`
animation: null;
`;
const draggableHandler = ".model-handler"; // 拖动句柄的类名
/**
* 拖动组件,使被包裹的组件可以拖动,支持拖动句柄
* @param {是否启用拖动句柄 } enableHandler
* @param {拖动句柄的类名} draggableHandler
* @param {外部点击事件} onOutsideClick
*/
export default function Draggable({
children, // 子组件
enableDragging = true,
enableHandler = false, // 是否启用拖动句柄
stateMode
}) {
const [attentionStyle, setAttentionStyle] = useState(anim); // 弹窗动画,当点击外部时,弹窗会有一个动画效果
const [isDragging, setIsDragging] = useState(false); // 是否正在拖动
const [canDrag, setCanDrag] = useState(true); // 是否可以触发拖动操作,改变鼠标样式
const normalPos = useRef({ x: 0, y: 0 }); // 正常模式下弹窗的位置(translate的值)
const minPos = useRef({ x: 0, y: 0 }); // 最小化时的位置
const maxPos = { x: 0, y: 0 }; // 最大化时的位置,因为最大化时弹窗的位置是固定的,所以不需要ref
// 当所有模式下的位置变化都是通过position来反映到UI上的,所以position是唯一的位置状态
const [position, setPosition] = useState({x: 0, y: 0}); // 弹窗的位置(translate的值)
// 当鼠标按下时,记录鼠标的位置并以当前位置为基准进行拖动(相对位置),与position的差值为偏移量,position为上一次的偏移量。
// 因为采用的是translate的方式进行拖动,这种方式下,是以组件第一次渲染的位置为基准参考点(也就是相对0,0的位置)进行拖动的.
// 正常模式下的偏移量
const normalOffsetX = useRef(0); // x轴偏移量
const normalOffsetY = useRef(0); // y轴偏移量
// 最小化时的偏移量
const minOffsetX = useRef(0); // x轴偏移量
const minOffsetY = useRef(0); // y轴偏移量
const initedRect = useRef(0); // 初始化后的弹窗大小
const wrapperRef = useRef(null);
const windowSize = useWindowSize();
// 当点击外部时,弹窗会有一个注目动画效果
useOutsideClick(wrapperRef, () => {
setAttentionStyle(anim);
});
// 弹窗注目动画的监听
useEffect(function () {
// 弹窗动画监听事件
const listener = (e) => {
if (e.type === "animationend") {
setAttentionStyle(stopAnim);
}
};
if (wrapperRef.current !== null) {
wrapperRef.current.addEventListener("animationend", listener, true);
}
return () => {
if (wrapperRef.current !== null) {
wrapperRef.current.removeEventListener("animationend", listener);
}
};
}, []);
// document的鼠标移动事件和鼠标抬起事件监听
useEffect(() => {
// 鼠标移动事件
const handleMouseMove = (e) => {
if (isDragging) {
switch (stateMode) {
case 0:
const xt = e.clientX - minOffsetX.current;
const yt = e.clientY - minOffsetY.current;
const xtMinTop = -((windowSize.height - minHeight) / 2 - 10);
const xtMaxTop = (windowSize.height - minHeight) / 2 - 10;
const xtMinLeft = -((windowSize.width - minWidth) / 2 - 10);
const xtMaxLeft = (windowSize.width - minWidth) / 2 - 10;
const xm = xt < xtMinLeft ? xtMinLeft : xt > xtMaxLeft ? xtMaxLeft : xt;
const ym = yt < xtMinTop ? xtMinTop : yt > xtMaxTop ? xtMaxTop : yt;
minPos.current = { x: xm, y: ym};
setPosition({ ...minPos.current });
break;
case 2:
break;
default:
const xTmp = e.clientX - normalOffsetX.current;
const yTmp = e.clientY - normalOffsetY.current;
const minLetf = -(windowSize.width - initedRect.current.width) / 2;
const minTop = -(windowSize.height - initedRect.current.height) / 2;
const maxLeft = (windowSize.width - initedRect.current.width) / 2;
const maxTop = (windowSize.height - initedRect.current.height) / 2;
const x = xTmp < minLetf ? minLetf : xTmp > maxLeft ? maxLeft : xTmp;
const y = yTmp < minTop ? minTop : yTmp > maxTop ? maxTop : yTmp;
normalPos.current = { x, y };
setPosition({ ...normalPos.current });
break;
}
}
};
// 鼠标抬起事件
const handleMouseUp = (e) => {
if (e.button !== 0) return;
setIsDragging(false);
};
// 在相关的事件委托到document上
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
} else {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
// 组件卸载时移除事件
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
// 弹窗位置的监听, 每当弹窗状态改变时,都会重新设置弹窗的位置, 将相应状态下的最后位置设置为当前位置
// 但最小化状态下的位置有所不同,因为最小化状态下的初始位置为左下角,每次从其它状态切换到最小化状态时都要进行相同的设置。
useEffect(() => {
switch (stateMode) {
case 0:
const initX = -((windowSize.width - minWidth - 20) / 2);
const initY = windowSize.height / 2 - minHeight + 10;
setPosition({ x: initX, y: initY });
minPos.current = { x: initX, y: initY };
break;
case 2:
setPosition({...maxPos.current});
break;
default:
setPosition({ ...normalPos.current });
break;
}
}, [stateMode]);
// ref对象的鼠标移动事件,用于判断是否在拖动句柄上
const onMouseMove = (e) => {
if (!enableDragging) {
setCanDrag(false);
return;
}
if (enableHandler) {
const clickedElement = e.target;
// 检查鼠标点击的 DOM 元素是否包含特定类名
if (clickedElement.classList.contains(draggableHandler)) {
setCanDrag(true);
} else {
setCanDrag(false);
}
}
}
// ref对象的鼠标按下事件,用于触发拖动操作,
// 如果启用了拖动句柄,那么只有在拖动句柄上按下鼠标才会触发拖动操作,
// 否则直接按下鼠标就会触发拖动操作
const handleMouseDown = (e) => {
if (!enableDragging) return;
switch (stateMode) {
case 0:
if (enableHandler) {
// 判断是否在拖动句柄上
const curElement = e.target;
// 检查鼠标点击的 DOM 元素是否包含特定类名
if (curElement.classList.contains(draggableHandler)) {
if (e.button !== 0) return;
setIsDragging(true);
minOffsetX.current = e.clientX - minPos.current.x;
minOffsetY.current = e.clientY - minPos.current.y;
} else {
setCanDrag(false);
}
} else {
if (e.button !== 0) return;
setIsDragging(true);
minOffsetX.current = e.clientX - minPos.current.x;
minOffsetY.current = e.clientY - minPos.current.y;
}
return;
case 2:
return;
default:
if (enableHandler) {
// 判断是否在拖动句柄上
const curElement = e.target;
// 检查鼠标点击的 DOM 元素是否包含特定类名
if (curElement.classList.contains(draggableHandler)) {
if (e.button !== 0) return;
setIsDragging(true);
normalOffsetX.current = e.clientX - normalPos.current.x;
normalOffsetY.current = e.clientY - normalPos.current.y;
} else {
setCanDrag(false);
}
} else {
if (e.button !== 0) return;
setIsDragging(true);
normalOffsetX.current = e.clientX - normalPos.current.x;
normalOffsetY.current = e.clientY - normalPos.current.y;
}
return;
}
};
return (
<Box
ref={wrapperRef}
sx={{
transform: `translate(${position.x}px, ${position.y}px)`,
cursor: canDrag ? isDragging ? "grabbing" : "grab" : "default",
transition: isDragging ? null : `transform 200ms ease-in-out`,
}}
onMouseDown={handleMouseDown}
onMouseMove={onMouseMove}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
onPointerEnter={() => {
if (initedRect.current === 0 && wrapperRef.current !== null) {
const rect = wrapperRef.current.getBoundingClientRect();
initedRect.current = {
width: rect.width,
height: rect.height,
};
}
}}
>
<Box
sx={{
transform: `${isDragging ? "scale(1.03)" : "scale(1)"}`,
transition: `transform 200ms ease-in-out`,
}}
css={attentionStyle}
>
{
children
}
</Box>
</Box>
);
}
上面我都做了说明,应该不难理解。这样我们组合后我们弹窗就可以移动了。最后的测试请关注下一篇文章。