【山竹记账前端-react】13.滑动菜单暗藏玄机


大纲链接 §

[toc]


1. 滑动菜单之样式制作 ⇧

@/components/FloatingOverlayMenu.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { MenuList } from "@/components/menu/MenuList"
import { UserHeader } from "@/components/menu/UserHeader"
import { cn } from "cn"

export const FloatingOverlayMenu: React.FC = () => {
  return (
    <div
      className={cn(
        'fixed top-0 left-0',
        'w-70vw max-w-20em h-screen',
        'flex flex-col',
      )}>
      <UserHeader className="grow-0 shrink-0" />
      <MenuList className="grow-1 shrink-1" />
    </div>
  )
}

@/components/menu/UserHeader.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { cn } from "cn"

interface Props {
  className?: string
}

export const UserHeader: React.FC<Props> = ({ className, }) => {
  return (
    <div
      className={cn(
        'bg-[var(--main)] text-white w-full',
        'pt-2em pb-44px px-1em',
        className,
      )}>
      <h2 className="text-[24px]">未登录用户</h2>
      <div className="text-[var(--user-text)]">点击这里登录</div>
    </div>
  )
}

@/components/menu/MenuList.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { MenuLinkItem } from "@/components/menu/MenuLinkItem"
import { cn } from "cn"

interface Props {
  className?: string
}
type MenuLinkItemType = {
  linkPath: string
  linkTitle: string
  iconName: string
}

const menuLinkItemList: Readonly<MenuLinkItemType[]> = Object.freeze([
  { linkPath: '/statistics', linkTitle: '统计图表', iconName: 'chart' },
  { linkPath: '/export', linkTitle: '导出数据', iconName: 'export' },
  { linkPath: '/notify', linkTitle: '记账提醒', iconName: 'notify' },
  { linkPath: '/tech_stack', linkTitle: '本App技术栈', iconName: 'tech_stack' },
])

export const MenuList: React.FC<Props> = ({ className, }) => {
  return (
    <nav
      className={cn(
        'bg-white text-20px py-1em',
        className,
      )}>
      <menu className={cn(
          'pt-1em text-20px',
          'flex flex-col justify-start',
        )}>
          {menuLinkItemList.map((item) => (
            <MenuLinkItem
              key={item.linkPath}
              linkPath={item.linkPath}
              linkTitle={item.linkTitle}
              iconName={item.iconName}
            />
          ))}
        </menu>
      </nav>
  )
}
  • 需要将图标列表名称设置到 svgsprites 的 noOptimizeList 选项中,不去除原来颜色

@/components/menu/MenuLinkItem.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { SvgIcon } from "@/components/SvgIcon"
import { cn } from "cn"

interface Props {
  className?: string
  linkPath?: string
  linkTitle?: string
  iconName: string
}

export const MenuLinkItem: React.FC<Props> = ({
  className,
  iconName,
  linkTitle,
}) => {
  return (
    <li
      className={cn(
        'flex items-center px-1em py-.5em mb-.25em',
        className,
      )}>
      <SvgIcon name={iconName} className="mr-[1em] size-[2em]" />
      <span>{linkTitle}</span>
    </li>
  )
}

2. 点击外部关闭菜单 ⇧

@/components/menu/OverlayMask.tsx

  • 直接使用状态中的方法改变显示状态

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    import { useMenuStore } from "@/stores/useMenuStore"
    import { cn } from "cn"
    
    export const OverlayMask: React.FC = () => {
    const { setVisible, } = useMenuStore()
    return (
    <div
      onClick={() => setVisible(false)}
      className={cn(
      'fixed top-0 left-0 w-screen h-screen',
      'bg-[var(--overlay-mask-bg)] z-[var(--z-index-float-overlay)]',
    )}>
    </div>
    )
    }
    

3. 制作滑动动画 ⇧

各个组件根据 visible 状态使用 useSpring 设置动画 style ⇧

总页面可以去除全局的状态 @/pages/ItemsPage.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// import ...

export const ItemsPage: React.FC = () => {
  const [selectedDate, setSelectedDate] = useState<DateTabRangeStr>('thisMonth')
  return (
    <div>
      <header
        className={cn(
          'bg-linear-to-b from-[var(--welcome-card-start)] to-[var(--welcome-card-end)]',
        )}>
        <TopNav />
        <DateRangePicker selectedId={selectedDate} onSelected={setSelectedDate} />
      </header>

      <ItemsSummary startDate={selectedDate} endDate={selectedDate} />

      <AddItemFloatButton />

      <FloatingOverlayMenu />
    </div>
  )
}

给菜单添加移动动画 @/components/FloatingOverlayMenu.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { MenuList } from "@/components/menu/MenuList"
import { OverlayMask } from "@/components/menu/OverlayMask"
import { UserHeader } from "@/components/menu/UserHeader"
import { useMenuStore } from "@/stores/useMenuStore"
import { animated, useSpring } from "@react-spring/web"
import { cn } from "cn"

interface Props {}

export const FloatingOverlayMenu: React.FC<Props> = () => {
  const { visible, } = useMenuStore()
  const menuStyle = useSpring({
    transform: visible ? 'translateX(0%)' : 'translateX(-100%)',
    opacity: visible ? 1 : 0,
  })
  return (
    <>
      <animated.div
        style={menuStyle}
        className={cn(
          'floating-menu',
          'fixed top-0 left-0',
          'w-70vw max-w-20em h-screen',
          'flex flex-col',
          'z-[var(--z-menu)]',
        )}>
        <UserHeader className="grow-0 shrink-0" />
        <MenuList className="grow-1 shrink-1" />
      </animated.div>

      <OverlayMask />
    </>
  )
}
  • useSpring(Object) 中接收除了接口默认的配置参数外,
    • 还可以接收动画相关的属性,例如 opacity、transform、visibility 等
    • 返回值即可以响应式的 style 对象值 SpringValues,可在动画配置事件中打印查看
  • 注意移动过渡动画 transform: visible ? 'translateX(0%)' : 'translateX(-100%)', 中不可写成 translateX(0)
    • translateX(0) 没有补间动画
  • 透明度渐变 opacity: visible ? 1 : 0,

给遮罩层添加可见度动画 @/components/menu/OverlayMask.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { useMenuStore } from "@/stores/useMenuStore"
import { animated, useSpring } from "@react-spring/web"
import { cn } from "cn"

interface Props { }

export const OverlayMask: React.FC<Props> = () => {
  const { visible, setVisible, } = useMenuStore()
  const [maskVisible, setMaskVisible] = useState(false)
  const maskAnimeStyle = useSpring({
    opacity: visible ? 1 : 0,
    visibility: maskVisible ? 'visible' : 'hidden',
    config: { duration: 1000,},
    onStart(result) {
      console.log('maskAnimeStyle_______________________')
      console.log('%c%s', 'color: #aa00ff', visible);
      console.log('%c⧭', 'color: #00a3cc', result);
      console.log('%c⧭', 'color: #00a3cc', result.value);
      console.log('_______________________maskAnimeStyle')
      if (result.value.opacity < 0.1) {
        setMaskVisible(true)
      }
    },
    onRest({ value }) {
      if (value.opacity < 0.1) {
        setMaskVisible(false)
      }
    },
  })
  return (
    <animated.div
      style={maskAnimeStyle}
      onClick={() => setVisible(false)}
      className={cn(
        'overlay-mask',
        'fixed top-0 left-0 w-screen h-screen',
        'bg-[var(--overlay-mask-bg)] z-[var(--z-index-float-overlay)]',
      )}>
    </animated.div>
  )
}
  • 为了不遮挡可点击页面元素,需要将遮罩层初始设为不可见
    • visibility: visible ? 'visible' : 'hidden'
  • visible 变化重新计算节点,对应的 maskAnimeStyle 也会变化
  • 在 onStart 钩子中
    • 打开动画 opacity: 从 0 -> 1
    • 关闭动画 opacity: 从 1 -> 0
    • 但是 result.value.opacity 中的数值打印出来并不是完全等于 0
    • 可以放宽松地和 0.1 比较,符合条件就 setMaskVisible(true)
  • 在 onRest 钩子中
    • 打开动画 opacity: 从 0 -> 1
    • 关闭动画 opacity: 从 1 -> 0
    • 同样地也还是判断 value.opacity < 0.1

useSpring_opacity_1

  • 之后发现和 visible 状态的逻辑行为一致,可直接使用 visible 代替 maskVisible 即可
  • 直接都放到 useSpring({}) 的配置样式中即可,不用单独再设置 setMaskVisible

useSpring_opacity_2

@/components/menu/OverlayMask.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useMenuStore } from "@/stores/useMenuStore"
import { animated, useSpring } from "@react-spring/web"
import { cn } from "cn"

interface Props { }

export const OverlayMask: React.FC<Props> = () => {
  const { visible, setVisible, } = useMenuStore()
  const maskAnimeStyle = useSpring({
    opacity: visible ? 1 : 0,
    visibility: visible ? 'visible' : 'hidden',
  })
  return (
    <animated.div
      style={maskAnimeStyle}
      onClick={() => setVisible(false)}
      className={cn(
        'overlay-mask',
        'fixed top-0 left-0 w-screen h-screen',
        'bg-[var(--overlay-mask-bg)] z-[var(--z-index-float-overlay)]',
      )}>
    </animated.div>
  )
}

useSpring_opacity_3

插曲:一个莫名其妙的bug及其workaround

  • 直接在元素中写 style={{ ...markStyle, {visible: ...} }} 可能会出现bug
  • 直接绕过,单独声明变量即可

4. 添加路由跳转 ⇧

@/route/routes.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Root } from '@/components/Root'
import { Home } from '@/pages/Home'
import { ItemsPage } from '@/pages/ItemsPage'
import { welcomeRouter } from '@/route/welcomeRouter'
import type { RouteObject } from 'react-router-dom'

export const routes: RouteObject[] = [
  {
    path: '/',
    element: <Root />,
  },
  {
    path: '/home',
    element: <Home title="首页" />,
  },
  welcomeRouter,
  {
    path: '/items',
    element: <ItemsPage />,
  },
  { path: '/sign_in', element: <div>sign in</div>},
  { path: '/statistics', element: <div>统计图表</div>},
  { path: '/export', element: <div>敬请期待</div>},
  { path: '/notify', element: <div>敬请期待</div>},
  { path: '/tags', element: <div>标签</div>},
  { path: '/tech_stack', element: <div>敬请期待</div>},
]

@/components/menu/UserHeader.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { cn } from "cn"
import { Link } from "react-router-dom"

interface Props {
  className?: string
}

export const UserHeader: React.FC<Props> = ({ className, }) => {
  return (
    <Link
      to="/sign_in"
      className={cn(
        'block',
        'bg-[var(--main)] text-white w-full',
        'pt-2em pb-44px px-1em',
        className,
      )}>
      <h2 className="text-[24px]">未登录用户</h2>
      <div className="text-[var(--user-text)]">点击这里登录</div>
    </Link>
  )
}

@/components/menu/MenuLinkItem.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { SvgIcon } from "@/components/SvgIcon"
import { cn } from "cn"
import { NavLink } from "react-router-dom"

interface Props {
  className?: string
  linkPath?: string
  linkTitle?: string
  iconName: string
}

export const MenuLinkItem: React.FC<Props> = ({
  className,
  iconName,
  linkTitle,
  linkPath,
}) => {
  return (
    <li
      className={cn(
        'px-1em py-.5em mb-.25em',
        className,
      )}>
      <NavLink
        to={linkPath}
        className={cn(
          'flex items-center',
          className,
        )}>
        <SvgIcon name={iconName} className="mr-[1em] size-[2em]" />
        <span>{linkTitle}</span>
      </NavLink>
    </li>
  )
}

5. 插曲:一个你可能理解不了的bug ⇧

当跳转到其他路由后,useMenuStore 状态中 的 visible 是否还在内存中

  • 仍然存在
  • 使用 <Link> 或者 <NavLink>;并且使用 createBrowserRouter 的 history API,并不会刷新页面
  • 不刷新页面意味着内存不会被刷新,跳转另外路由时,visible 状态会保持不变
  • 此时再点击回退,由于组件内的状态并不在状态管理,会随组件的销毁而删除
  • 为了避免不更新状态的bug,可以将状态都放到状态管理文件中
  • 或者使得组件内的状态初始值依赖于状态值即可

·未完待续·

参考文章

相关文章

  • 无

  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名