MUI Material Drawer 组件深度解析:三种变体、Swipeable 手势与源码级实现原理
Material UI(MUI Material)的 Drawer(抽屉)组件用于构建 Google Material Design 规范中的导航抽屉(侧边栏),提供在应用内切换目的地、切换账户等功能的人体工学入口。导航抽屉既可以是常驻屏幕的,也可以由导航菜单图标控制开合。本文基于仓库中 Drawer 组件官方文档 及 Drawer 源码、SwipeableDrawer 源码 展开,覆盖 temporary / persistent / permanent 三种变体的完整用法、SwipeableDrawer 手势细节、响应式与 Mini 变体实战方案,并给出源码层面的实现证据,帮助你真正理解每个 prop 背后的行为。
Drawer 的三种变体(variant)
从 Drawer.js 的源码结构看,Drawer 内部按 variant 走三条完全不同的渲染分支:
| variant | 渲染结构 | 是否可关闭 | 典型场景 |
|---|---|---|---|
temporary(默认) |
Modal(root)→ Slide 过渡 → Paper |
可点击遮罩或按 Esc 关闭 | 移动端侧滑菜单、模态式抽屉 |
persistent |
div(docked, flex: 0 0 auto)→ Slide → Paper |
由用户状态控制,会占据布局空间并挤压其他内容 | 桌面端可折叠侧栏 |
permanent |
div(docked)→ Paper,无过渡 |
始终可见,无法关闭 | 桌面端固定导航(官方推荐的桌面默认方案) |
对应源码中的关键分支逻辑:
// packages/mui-material/src/Drawer/Drawer.js(节选)
if (variant === 'permanent') {
return <DockedSlot {...dockedSlotProps}>{drawer}</DockedSlot>;
}
const slidingDrawer = <TransitionSlot {...transitionSlotProps}>{drawer}</TransitionSlot>;
if (variant === 'persistent') {
return <DockedSlot {...dockedSlotProps}>{slidingDrawer}</DockedSlot>;
}
// variant === temporary
return <RootSlot {...rootSlotProps}>{slidingDrawer}</RootSlot>;
可以确认:只有 permanent 是纯静态 DOM(连过渡都没有);persistent 有滑动过渡但停留在文档流内(docked 槽位);temporary 则挂载在 Modal 中,浮于所有内容之上。variant 的完整 prop 声明为:
/**
* The variant to use.
* @default 'temporary'
*/
variant: PropTypes.oneOf(['permanent', 'persistent', 'temporary']),
temporary 变体的 Paper 还会额外加上 role="dialog"、aria-modal="true" 和 tabIndex={-1}(见 Drawer.js#L276-L281),使其具备正确的无障碍语义;且只有 temporary 变体才会应用 elevation(默认 16),docked 变体的 elevation 强制为 0,与内容表面同层。
Temporary drawer:完整的受控实现
临时导航抽屉默认关闭,打开后临时覆盖在全部内容之上,直到用户选中某个条目。可以通过点击遮罩(backdrop)或按 Esc 键取消关闭——这两个交互由内部 Modal 提供的 reason 参数区分(onClose 的第二个参数可取 "escapeKeyDown" 或 "backdropClick"),选中条目关闭则由控制 open prop 的状态更新负责。
完整可运行的示例(对应仓库中的 TemporaryDrawer.tsx):
import * as React from 'react';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import Button from '@mui/material/Button';
import List from '@mui/material/List';
import Divider from '@mui/material/Divider';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import InboxIcon from '@mui/icons-material/MoveToInbox';
import MailIcon from '@mui/icons-material/Mail';
export default function TemporaryDrawer() {
const [open, setOpen] = React.useState(false);
const toggleDrawer = (newOpen: boolean) => () => {
setOpen(newOpen);
};
const DrawerList = (
<Box sx={{ width: 250 }} role="presentation" onClick={toggleDrawer(false)}>
<List>
{['Inbox', 'Starred', 'Send email', 'Drafts'].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
<List>
{['All mail', 'Trash', 'Spam'].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
);
return (
<div>
<Button onClick={toggleDrawer(true)}>Open drawer</Button>
<Drawer open={open} onClose={toggleDrawer(false)}>
{DrawerList}
</Drawer>
</div>
);
}
要点解析:
- 抽屉内容是纯数据驱动的
List/ListItemButton结构,宽度由内容Box sx={{ width: 250 }}决定(left/right 锚点时); role="presentation"加在内容容器上,配合点击容器任意位置关闭抽屉;- 关闭逻辑收敛在
toggleDrawer工厂函数里,onClose与点击事件复用同一函数。
Anchor:从哪一侧滑出
使用 anchor prop 指定抽屉从屏幕哪一侧出现,默认值为 'left',可选 'top' | 'left' | 'bottom' | 'right'。anchor 对 Paper 的布局影响在 Drawer.js#L85-L126 中以 styled 变体实现:
left:left: 0,靠右撑开;right:right: 0,靠左撑开;top:横向拉满(left: 0; right: 0),height: 'auto'; maxHeight: '100%',纵向从上往下滑;bottom:同样横向拉满,但top: 'auto'; bottom: 0,从下往上滑。
值得注意的是 RTL(从右到左)支持:源码导出的 getAnchor 函数会把水平锚点在 RTL 布局下做镜像(left ↔ right,top ↔ down),映射表 oppositeDirection 同时决定了 Slide 过渡的初始方向。四向滑动完整示例见 SwipeableTemporaryDrawer.tsx,其中对四边各维护一份 open 状态,并且在键盘事件处理里对 Tab/Shift 做了豁免:
const toggleDrawer =
(anchor: Anchor, open: boolean) =>
(event: React.KeyboardEvent | React.MouseEvent) => {
if (
event &&
event.type === 'keydown' &&
((event as React.KeyboardEvent).key === 'Tab' ||
(event as React.KeyboardEvent).key === 'Shift')
) {
return;
}
setState({ ...state, [anchor]: open });
};
Swipeable:触摸滑动开合
SwipeableDrawer 组件让抽屉支持手指滑开/滑关。文档明确提示:该组件带来约 2 kB gzipped 的额外包体积;部分低端移动设备无法在 60 FPS 下跟手,可以借助 disableBackdropTransition prop 改善。
官方文档站点自身的取舍逻辑值得参考——iOS 设备普遍为高端硬件,开启背景过渡不会掉帧;但 iOS 有"从屏幕边缘左滑返回"的系统手势,会与"滑开抽屉"的发现性手势冲突,因此必须禁用 disableDiscovery:
const iOS =
typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent);
<SwipeableDrawer disableBackdropTransition={!iOS} disableDiscovery={iOS} />;
源码侧印证了这一策略:SwipeableDrawer.js#L150 中 disableSwipeToOpen 的默认值就是 iOS,与文档建议一致。
Swipeable edge:关闭态保留可见边缘
SwipeableDrawer 可以配置为关闭状态下仍露出一条"可见边缘"(bleeding edge),让用户能发现抽屉的存在并上滑打开。该示例(SwipeableEdgeDrawer.tsx)采用 anchor="bottom" 底部抽屉形态,关键 props:
const drawerBleeding = 56; // 关闭时露出的高度(px)
// 通过全局样式让 paper 高度 = 50% - bleeding,并允许内容上移溢出
<Global
styles={{
'.MuiDrawer-root > .MuiPaper-root': {
height: `calc(50% - ${drawerBleeding}px)`,
overflow: 'visible',
},
}}
/>
<SwipeableDrawer
container={container}
anchor="bottom"
open={open}
onClose={toggleDrawer(false)}
onOpen={toggleDrawer(true)}
swipeAreaWidth={drawerBleeding}
disableSwipeToOpen={false}
keepMounted
>
<StyledBox
sx={{
position: 'absolute',
top: -drawerBleeding, // 内容向上溢出,形成可见的"抓手"区域
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
visibility: 'visible',
right: 0,
left: 0,
}}
>
{/* 圆形 Puller 指示条 + 摘要信息 */}
</StyledBox>
{/* 主体内容 */}
</SwipeableDrawer>
其中 swipeAreaWidth={56} 使触摸监听区域覆盖露出的 56px 边缘,disableSwipeToOpen={false} 显式允许从边缘滑开(该 prop 默认值在 iOS 上为 true)。在桌面端可通过 "Open" 按钮切换,在移动端可在 CodeSandbox 中实际滑动体验(官方 demo 以 iframe 隔离运行)。
Keep mounted:内容常驻 DOM
SwipeableDrawer 内部使用的 Modal 默认设置了 keepMounted,意味着抽屉内容始终存在于 DOM 中(关闭时仅做视觉平移)。这个默认行为可以用 ModalProps 覆盖,但文档特别提醒:React 18 下 keepMounted: false 可能引发问题:
<Drawer
variant="temporary"
ModalProps={{
keepMounted: false,
}}
/>
Transition:替换默认过渡
临时抽屉默认使用 Slide 过渡。使用 slots.transition 与 slotProps.transition 可以替换为其他过渡组件或向其传递过渡参数(如 timeout)。transitionDuration 默认取主题值:
transitionDuration: {
enter: theme.transitions.duration.enteringScreen,
exit: theme.transitions.duration.leavingScreen,
}
Responsive drawer:断点切换两种变体
官方推荐的响应式方案是渲染两个 Drawer:小屏幕用 temporary,宽屏幕用 permanent,通过 display 断点样式互斥显示。完整实现见 ResponsiveDrawer.tsx,核心骨架:
const drawerWidth = 240;
// 1. 过渡结束/关闭中状态:避免关闭动画期间按钮再次触发打开
const [mobileOpen, setMobileOpen] = React.useState(false);
const [isClosing, setIsClosing] = React.useState(false);
const handleDrawerClose = () => { setIsClosing(true); setMobileOpen(false); };
const handleDrawerTransitionEnd = () => setIsClosing(false);
const handleDrawerToggle = () => { if (!isClosing) setMobileOpen(!mobileOpen); };
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton color="inherit" aria-label="open drawer" edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">Responsive drawer</Typography>
</Toolbar>
</AppBar>
<Box component="nav" sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
aria-label="mailbox folders">
{/* The implementation can be swapped with js to avoid SEO duplication of links. */}
<Drawer
variant="temporary"
open={mobileOpen}
onTransitionEnd={handleDrawerTransitionEnd}
onClose={handleDrawerClose}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
slotProps={{
root: {
keepMounted: true, // Better open performance on mobile.
},
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box component="main"
sx={{ flexGrow: 1, p: 3, width: { sm: `calc(100% - ${drawerWidth}px)` } }}>
<Toolbar /> {/* 顶栏占位 */}
{/* 页面内容 */}
</Box>
</Box>
几个实战细节:
slotProps.root.keepMounted: true:文档注释说明这是为了移动端打开性能——避免首次打开时同步挂载整棵导航树;isClosing状态机:抽屉关闭动画期间阻止handleDrawerToggle再次打开,防止动画被打断;- 两个 Drawer 共享同一个
drawer变量(同一份List),注释明确指出"可以用 JS 条件渲染替代,以避免 SEO 意义上的链接重复"——即 permanent 分支对爬虫可见,temporary 分支可改为mobileOpen && <Drawer …>; - 抽屉内容顶部放置一个空
<Toolbar />作为占位,使导航项不被固定 AppBar 遮挡(与下文 Clipped 方案同理)。
Persistent drawer:挤压布局的常驻抽屉
持久导航抽屉同样可开可关,但它位于与内容相同表面层级的布局空间内:默认关闭,选中菜单图标后打开,并一直保持打开直到用户手动关闭。打开时抽屉强制其他内容改变尺寸、适应更小的视口(这是与 temporary"浮在内容之上"的本质区别)。源码上这对应 docked 槽位的 flex: '0 0 auto' 样式(Drawer.js#L55-L63)。
官方建议:persistent 抽屉适用于移动端以上所有尺寸;对于存在多级层级、需要"上箭头"导航的应用则不推荐。左右两种锚点的完整示例分别在 PersistentDrawerLeft.tsx 与 PersistentDrawerRight.tsx。
Mini variant drawer:可缩放的持久抽屉
Mini 变体中,持久抽屉的宽度会变化:静止状态是与应用栏同层的迷你抽屉(被 AppBar 裁剪),展开后是标准持久抽屉。该方案推荐给需要在内容旁快速选择的应用分区。
MiniDrawer.tsx 的精髓在于用 styled 组件为 Drawer 和 AppBar 分别注入"开/合"两套 mixin,并对宽度做 width 过渡动画:
const drawerWidth = 240;
const openedMixin = (theme: Theme): CSSObject => ({
width: drawerWidth,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
overflowX: 'hidden',
});
const closedMixin = (theme: Theme): CSSObject => ({
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: 'hidden',
width: `calc(${theme.spacing(7)} + 1px)`,
[theme.breakpoints.up('sm')]: {
width: `calc(${theme.spacing(8)} + 1px)`,
},
});
const AppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== 'open',
})<AppBarProps>(({ theme }) => ({
zIndex: theme.zIndex.drawer + 1, // AppBar 盖在抽屉之上,形成"裁剪"效果
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
variants: [
{
props: ({ open }) => open,
style: {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
},
],
}));
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })(
({ theme }) => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
variants: [
{ props: ({ open }) => open, style: { ...openedMixin(theme), '& .MuiDrawer-paper': openedMixin(theme) } },
{ props: ({ open }) => !open, style: { ...closedMixin(theme), '& .MuiDrawer-paper': closedMixin(theme) } },
],
}),
);
实现要点:
variant="permanent"+open状态:Mini 抽屉本质是 permanent 变体(永不消失),"折叠"只是宽度动画,而非卸载组件;zIndex: theme.zIndex.drawer + 1:AppBar 压在 Drawer 之上(Drawer 的 z 值正是theme.zIndex.drawer,见 Drawer.js#L45-L53),从而产生"抽屉被应用栏裁剪"的视觉;shouldForwardProp: (prop) => prop !== 'open':拦截自定义openprop,防止其泄漏为 DOM 属性;- 折叠态下文字
opacity: 0、图标居中,只留图标一列,宽度收敛到theme.spacing(7)(sm 以上为spacing(8)); - 关闭按钮的图标随
theme.direction切换为ChevronLeftIcon/ChevronRightIcon,是 RTL 友好的写法。
Permanent drawer 的两种形态
常驻导航抽屉始终可见、固定贴边、与内容或背景同层,无法被关闭,官方将其标记为桌面端的推荐默认。源码行为与之完全对应:permanent 分支不渲染 Modal、不渲染过渡,且 docked 变体 Paper 的 elevation 强制为 0(见上节 elevation: variant === 'temporary' ? elevation : 0)。
全高导航(Full-height navigation)
面向信息消费类、采用从左到右层级结构的应用。左右锚点示例:PermanentDrawerLeft.tsx、PermanentDrawerRight.tsx。
裁剪于应用栏之下(Clipped under the app bar)
面向生产力类、要求屏幕空间平衡的应用。ClippedDrawer.tsx 的裁剪技巧与 Responsive/Mini 方案一致:在 Drawer 内容顶部渲染一个空的 Toolbar 占位,其高度与固定 AppBar 相同,使导航项不被 AppBar 覆盖。
SwipeableDrawer 源码级补充
结合 SwipeableDrawer.js 的 props 默认值(L146-L161)与文档建议,可以给出更完整的手势参数说明:
| prop | 默认值 | 说明 |
|---|---|---|
anchor |
'left' |
同 Drawer,水平锚点在 RTL 下自动镜像 |
disableBackdropTransition |
false |
低端机掉帧时置 true 关闭背景动画 |
disableDiscovery |
false |
开启关闭态的"发现性"边缘拖拽 |
disableSwipeToOpen |
iOS(布尔) |
iOS 默认禁用边缘滑开,规避系统返回手势冲突 |
hysteresis |
0.52 |
开/关的判定阈值比例:拖过 52% 宽度(或速度足够)判定为打开 |
minFlingVelocity |
450 |
快速甩动的判定速度阈值(px/s 量级) |
swipeAreaWidth |
20 |
边缘可触发滑开的区域宽度 |
transitionDuration |
主题 entering/leavingScreen | 与 Drawer 一致 |
源码中还暴露了两个内部常量,解释了两段式手势判定:UNCERTAINTY_THRESHOLD = 3 px(低于该位移视为误触,接近浏览器触发原生滚动的内部阈值)和 DRAG_STARTED_SIGNAL = 20 px(触摸起点后需要移动 20px 才算"拖拽已开始")。此外 claimedSwipeInstance 单例保证同一时刻只有一个 Drawer 实例拥有滑动控制权,避免多个 SwipeableDrawer 同时响应造成 UX 混乱。触摸监听本身由同目录的 SwipeArea.js 完成。
工具类 CSS 类名(覆盖样式用)
d drawerClasses.ts 定义了 Drawer 的全部工具类,可用于 classes prop 或 sx 中的主题级样式覆盖:
| 类名 | 应用条件 |
|---|---|
MuiDrawer-root |
根元素 |
MuiDrawer-docked |
variant="permanent" 或 "persistent" 时的根元素 |
MuiDrawer-modal |
variant="temporary" 时的 Modal 根 |
MuiDrawer-paper |
Paper 元素 |
MuiDrawer-anchorLeft / anchorRight / anchorTop / anchorBottom |
按 anchor 附加在根元素上 |
例如响应式示例中 '& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth } 就是直接针对 paper 槽位的宽度控制。
小结
- Drawer 是受控组件:
open是唯一事实来源,temporary变体额外由 Modal 提供 Esc/遮罩关闭(onClose(event, reason)中reason∈escapeKeyDown/backdropClick); - 三种变体的本质差异是布局参与方式:temporary 浮层(Modal + 阴影 elevation 16)、persistent/permanent 为 docked 流内元素(elevation 0、同表面层级);
- 移动端手势优先选
SwipeableDrawer,并记住三个默认值陷阱:keepMounted默认开启、disableSwipeToOpen在 iOS 默认true、2 kB 体积开销; - 桌面布局组合拳:Responsive(双 Drawer + 断点 display)、Mini(permanent + width 过渡 +
zIndex.drawer + 1的 AppBar 裁剪)、Clipped(空Toolbar占位); - 全部源码证据集中于 Drawer.js、SwipeableDrawer.js、drawerClasses.ts,行为与文档描述逐条吻合,可作为二次封装时的参照基准。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00