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

时间:2024-03-04 10:58:26

前一节课中漏了一个知识点,当内容很长时需要滚动,这个滚动条是很影响美观的。在MacOS下的还能忍,win系统下简直不能看。如何让长内容能滚动又不显示滚动条呢,我尝试过很多办法,最终下面这个方法目前来说是最完美的。我们创建一个css文件。

_ModelContent.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

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;
}

当调整浏览器的大小时,我们要实时动态的获取视口的大小,以使我们的弹窗及时做出响应。

弹窗弹出时的主体动画

//弹窗的动画
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;
`;

设置加载后运行动画,

// 弹窗注目动画的监听
    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动画是针对原始位置的动画,当主体位移后,动画还在原来的位置动画,这就很尴尬了。所以我们要调整

...
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。

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 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

/** @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);
        };