React 模态框的设计(六)Draggable的整合

前一节课中漏了一个知识点,当内容很长时需要滚动,这个滚动条是很影响美观的。在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>
    );
}

上面我都做了说明,应该不难理解。这样我们组合后我们弹窗就可以移动了。最后的测试请关注下一篇文章。

相关推荐
霍先生的虚拟宇宙网络5 分钟前
webp 网页如何录屏?
开发语言·前端·javascript
温吞-ing7 分钟前
第十章JavaScript的应用
开发语言·javascript·ecmascript
彪8258 分钟前
第十章 JavaScript的应用 习题
javascript·css·ecmascript·html5
jessezappy26 分钟前
jQuery-Word-Export 使用记录及完整修正文件下载 jquery.wordexport.js
前端·word·jquery·filesaver·word-export
旧林8431 小时前
第八章 利用CSS制作导航菜单
前端·css
yngsqq1 小时前
c#使用高版本8.0步骤
java·前端·c#
Myli_ing2 小时前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风2 小时前
前端 vue 如何区分开发环境
前端·javascript·vue.js
PandaCave2 小时前
vue工程运行、构建、引用环境参数学习记录
javascript·vue.js·学习
软件小伟2 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js