不少管理后台支持拖放来调节左侧菜单的宽度,以此来提升用户体验。

example.gif

本文会详细介绍下实现。

实现算法

实现的算法是:开始拖动分割线,监听光标移动,记录光标的水平位置,设置菜单宽度为当前宽度加上当前光标水平位置与之前水平位置的差值,结束拖动。流程图如下:
logic.png

下面,我们用 React 来一步步来实现下。

具体实现

1 开始拖动

在拖动线上,按下鼠标,表示开始拖动。此时,记录光标的水平位置。

  1. // 光标的水平的位置
  2. const [clientX, setClientX] = useState(0)
  3. // 是否在拖动
  4. const [isResizing, setIsResizing] = useState(false)
  5. const handleStartResize = useCallback((e: React.MouseEvent<HTMLElement>) => {
  6. setClientX(e.clientX)
  7. setIsResizing(true)
  8. }, [])
  9. return (
  10. <div
  11. className={s.resizeHandle}
  12. onMouseDown={handleStartResize}
  13. ></div>
  14. )

注意:为了方便用户开始拖动,可以将拖动线设置的宽一点。如:

  1. .resizeHandle {
  2. width: 10px;
  3. cursor: col-resize;
  4. /* ... */
  5. }

2 设置菜单的宽度

监听光标移动:

  1. useEffect(() => {
  2. document.addEventListener('mousemove', handleResize)
  3. return () => {
  4. document.removeEventListener('mousemove', handleResize)
  5. }
  6. }, [handleResize])

记录光标的水平位置,设置菜单的宽度:

  1. // 左侧宽度
  2. const [menuWidth, setMenuWidth] = useState(calculatedMenuWidth);
  3. const handleStartResize = useCallback((e) => {
  4. if(!isResizing) {
  5. return
  6. }
  7. const offset = e.clientX - clientX
  8. const width = menuWidth + offset
  9. setMenuWidth(width)
  10. }, [menuWidth, clientX])
  11. return (
  12. <div className={s.app}>
  13. <div
  14. className={s.menu}
  15. style={{ width: `${menuWidth}px` }}
  16. >
  17. 菜单
  18. <div className={s.resizeHandle} />
  19. </div>
  20. <div>右侧</div>
  21. </div>
  22. )

拖动时,mousemove 事件会被频繁的触发,为了提升性能,我们来做个防抖处理:

  1. import { useDebounceFn } from 'ahooks'
  2. const { run: didHandleResize } = useDebounceFn((e) => {
  3. /* resize 的业务逻辑 */
  4. }
  5. const handleResize = useCallback(didHandleResize, [...])

3 结束拖放

抬起鼠标,则结束拖放。

  1. const handleStopResizing = useCallback(() => {
  2. setIsResizing(false)
  3. }, [prevUserSelectStyle])
  4. useEffect(() => {
  5. document.addEventListener('mouseup', handleStopResize)
  6. return () => {
  7. document.removeEventListener('mouseup', handleStopResize)
  8. }
  9. }, [handleStopResize])

以上实现了核心功能,但在生产环境中使用,需要更好的的用户体验。下面我们来做些优化。

优化

限制菜单的宽度

菜单太宽或太窄,都会导致显示的效果不好。因此,要设置限制菜单的最大,最小宽度。

  1. const MENU_MIN = 200;
  2. const MENU_MAX = 600;
  3. const handleResize = useCallback(offset => {
  4. let res = menuWidth + offset;
  5. if (res < MENU_MIN) {
  6. res = MENU_MIN;
  7. }
  8. if (res > MENU_MAX) {
  9. res = MENU_MAX;
  10. }
  11. setMenuWidth(res);
  12. }, [menuWidth]);

存储设置的菜单宽度

用户设置了菜单宽度后,页面刷新,需要仍然是之前的菜单宽度。

  1. const MENU_DEFAULT = 300
  2. const [menuWidth, setMenuWidth] = useState(localStorage.getItem('app-left-menu-width') || MENU_DEFAULT)
  3. const handleResize = useCallback(offset => {
  4. const res = ...
  5. setMenuWidth(res)
  6. localStorage.setItem('app-left-menu-width', `${res}`);
  7. }, [])

优化结束拖动的判断

如果用户在抬起鼠标时,在 iframe 中或该元素禁止了 mouseup 的冒泡,导致该事件不会冒泡到 document 上,导致判断结束拖动的失效。

解决方案:拖动时,在页面上盖一个遮罩,盖住下面的元素。用户 mouseup 时,其实是在遮罩元素上,总会冒泡到 document 上。JS:

  1. {isResizing && <div className={s.mask} />}

CSS:

  1. .mask {
  2. position: fixed;
  3. z-index: 98;
  4. left: 0;
  5. top: 0;
  6. height: 100%;
  7. width: 100%;
  8. }

去除拖动时对文字的选中

拖动时,经过文字时,会选中文字。下面的代码来去除选中:

  1. const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(getComputedStyle(document.body).userSelect)
  2. const handleStartResize = useCallback((e: React.MouseEvent<HTMLElement>) => {
  3. setPrevUserSelectStyle(getComputedStyle(document.body).userSelect)
  4. document.body.style.userSelect = 'none'
  5. //...
  6. }, [...])
  7. const handleStopResize = useCallback(() => {
  8. document.body.style.userSelect = prevUserSelectStyle
  9. // ...
  10. }, [...])

完整代码

我将调整元素宽度的功能抽象成了一个组件。组件的调用代码如下:

  1. import {useState, useCallback} from 'react'
  2. import ResizeWidth from './components/resize-width/index.tsx'
  3. import s from './App.module.scss';
  4. const MENU_MIN = 200
  5. const MENU_MAX = 600
  6. const MENU_DEFAULT = 300
  7. function App() {
  8. const storedMenuWidth = localStorage.getItem('app-left-menu-width')
  9. const calculatedMenuWidth = storedMenuWidth ? parseInt(storedMenuWidth, 10) : MENU_DEFAULT
  10. const [menuWidth, setMenuWidth] = useState(calculatedMenuWidth)
  11. const [expandLeftWidth, setExpandLeftWidth] = useState(calculatedMenuWidth)
  12. const handleResize = useCallback(offset => {
  13. let res = menuWidth + offset
  14. if (res < MENU_MIN) {
  15. res = MENU_MIN
  16. }
  17. if (res > MENU_MAX) {
  18. res = MENU_MAX
  19. }
  20. setMenuWidth(res)
  21. setExpandLeftWidth(res)
  22. localStorage.setItem('app-left-menu-width', `${res}`)
  23. }, [menuWidth])
  24. const handleToggleExpand = useCallback((isExpend) => {
  25. setMenuWidth(isExpend ? expandLeftWidth : 0)
  26. }, [expandLeftWidth])
  27. return (
  28. <div
  29. className={s.app}
  30. >
  31. <div
  32. className={s.menu}
  33. style={{ width: `${menuWidth}px` }}
  34. >
  35. <ResizeWidth
  36. onResize={handleResize}
  37. onToggleExpand={handleToggleExpand}
  38. />
  39. </div>
  40. <div className={s.main}>
  41. </div>
  42. </div>
  43. )
  44. }
  45. export default App

组件代码如下:

  1. import React, { FC, useCallback, useEffect, useState } from 'react'
  2. import { useDebounceFn } from 'ahooks'
  3. import cn from 'classnames'
  4. import s from './style.module.scss'
  5. export interface IResizeWidthProps {
  6. onResize: (offset: number) => void
  7. onToggleExpand: (isExpand: boolean) => void
  8. }
  9. const ResizeWidth: FC<IResizeWidthProps> = ({
  10. onToggleExpand,
  11. onResize
  12. }) => {
  13. const [clientX, setClientX] = useState(0)
  14. const [isResizing, setIsResizing] = useState(false)
  15. const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(getComputedStyle(document.body).userSelect)
  16. const handleStartResize = useCallback((e: React.MouseEvent<HTMLElement>) => {
  17. setClientX(e.clientX)
  18. setIsResizing(true)
  19. setPrevUserSelectStyle(getComputedStyle(document.body).userSelect)
  20. document.body.style.userSelect = 'none'
  21. }, [])
  22. const handleStopResize = useCallback(() => {
  23. setIsResizing(false)
  24. document.body.style.userSelect = prevUserSelectStyle
  25. }, [prevUserSelectStyle])
  26. const { run: didHandleResize } = useDebounceFn((e) => {
  27. if(!isResizing) {
  28. return
  29. }
  30. const offset = e.clientX - clientX
  31. setClientX(e.clientX)
  32. onResize(offset)
  33. }, {
  34. wait: 0,
  35. })
  36. const handleResize = useCallback(didHandleResize, [isResizing, clientX, didHandleResize])
  37. useEffect(() => {
  38. document.addEventListener('mouseup', handleStopResize)
  39. document.addEventListener('mousemove', handleResize)
  40. return () => {
  41. document.removeEventListener('mouseup', handleStopResize)
  42. document.removeEventListener('mousemove', handleResize)
  43. }
  44. }, [handleStopResize, handleResize])
  45. const [isExpand, setIsExpand] = useState(true)
  46. const handleToggleExpand = () => {
  47. const next = !isExpand
  48. onToggleExpand(next)
  49. setIsExpand(next)
  50. }
  51. return (
  52. <div
  53. className={s.resizeHandle}
  54. onMouseDown={handleStartResize}
  55. >
  56. <div className={cn(s.toggleBtn, !isExpand && s.fold)} onClick={handleToggleExpand} />
  57. {isResizing && <div className={s.mask} />}
  58. </div>
  59. )
  60. }
  61. export default React.memo(ResizeWidth)

关注公众号: 前端GoGoGo,助你升职加薪~