首页
/ MUI Material Drawer 组件深度解析:三种变体、Swipeable 手势与源码级实现原理

MUI Material Drawer 组件深度解析:三种变体、Swipeable 手势与源码级实现原理

2026-09-04 20:54:47作者:薛曦旖Francesca

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)→ SlidePaper 由用户状态控制,会占据布局空间并挤压其他内容 桌面端可折叠侧栏
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'anchorPaper 的布局影响在 Drawer.js#L85-L126 中以 styled 变体实现:

  • leftleft: 0,靠右撑开;
  • rightright: 0,靠左撑开;
  • top:横向拉满(left: 0; right: 0),height: 'auto'; maxHeight: '100%',纵向从上往下滑;
  • bottom:同样横向拉满,但 top: 'auto'; bottom: 0,从下往上滑。

值得注意的是 RTL(从右到左)支持:源码导出的 getAnchor 函数会把水平锚点在 RTL 布局下做镜像(leftrighttopdown),映射表 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#L150disableSwipeToOpen 的默认值就是 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.transitionslotProps.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.tsxPersistentDrawerRight.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':拦截自定义 open prop,防止其泄漏为 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.tsxPermanentDrawerRight.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)reasonescapeKeyDown / 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.jsSwipeableDrawer.jsdrawerClasses.ts,行为与文档描述逐条吻合,可作为二次封装时的参照基准。
登录后查看全文
热门项目推荐
相关项目推荐