React模态框设计(六)拖动组件的整合

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

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

相关推荐
摸鱼的春哥7 分钟前
春哥的Agent通关秘籍07:5分钟实现文件归类助手【实战】
前端·javascript·后端
念念不忘 必有回响10 分钟前
viepress:vue组件展示和源码功能
前端·javascript·vue.js
C澒16 分钟前
多场景多角色前端架构方案:基于页面协议化与模块标准化的通用能力沉淀
前端·架构·系统架构·前端框架
崔庆才丨静觅17 分钟前
稳定好用的 ADSL 拨号代理,就这家了!
前端
江湖有缘19 分钟前
Docker部署music-tag-web音乐标签编辑器
前端·docker·编辑器
恋猫de小郭1 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60619 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端