【山竹记账前端-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)
- 透明度渐变
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

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

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

插曲:一个莫名其妙的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
- 文章链接:
- 版权声明
- 非自由转载-非商用-非衍生-保持署名