【Day21】导航元件 - Drawer

元件介绍

Drawer 抽屉元件,由萤幕边缘滑出的浮动面版,常见的应用是作为导航用途,例如 Navigation drawers。

参考设计 & 属性分析

我们一样偷偷打开「检视网页原始码」工具来偷看一下 Antd Drawer 及 MUI Drawer 的 DOM 结构,是不是再次感受到熟悉的感觉?想想我们之前提过的 Tooltip 以及 Dropdown ,不出所料的,这次的 Drawer 一样采用 Portal 的做法,把 Drawer 选染到外面跟 <div id="root" /> 在同一层。

Antd Drawer

MUI Drawer

而且有一件事让我觉得特别厉害,就是当 Drawer 消失的时候,他会同时把 DOM 里面刚刚被 Portal 出来的东西清掉。

如果第一次看到这个,没有动手实作过的朋友,可能很难感受到他的厉害,我们仔细想想看他的行为,这里其实并不是直接找到那个节点的位置单纯的把它拿掉这麽简单而已,你有没有注意到 Drawer 关闭的时候,他是先有一个「抽屉滑动收回」的动画,以及「灰色全屏淡出」的动画之後,DOM 的节点才被拿掉呢?

如果我们只是用一个 boolean 来控制,那当 open 这个 props 瞬间被转换成 false 的时候,我们还没见到「抽屉滑动收回」的动画以及「灰色全屏淡出」的动画,这个抽屉就会「啪」一下消失了,画面会看起来就不那麽滑顺,程序码示意如下:

const Drawer = ({ open }) => {
  return open && (
    <DrawerContainer>
      {content}
    </DrawerContainer>
  );
};

所以,有时候我们乍看之下很自然、很简单的东西,仔细观察之後会发现其实有很多巧思在其中,了解他的巧思之後,不禁会对这个元件设计的用心敬畏三分。

自定义位置

自定义位置可以决定抽屉要由画面的上、右、下、左滑出,要留意抽屉从不同方位滑出,除了要处理动画的过场行为不同,排版也会有所影响,例如从上、下滑出的抽屉是宽大於高,从左、右滑出的抽屉是宽小於高,但考虑到手机窄萤幕的状况,由上、下滑出的抽屉,也是有可能宽小於高,因此在处理不同尺寸的切版时这部分可能会需要特别留意。

在这边决定滑出方向的属性,Antd 中是使用一贯的命名参数 placement,而 MUI 则是使用 anchor ,虽然 props 的名称不同,但是传入的参数很类似,都是 top, right, bottom, left

抽屉内容

抽屉的内容按照不同的需求,能够呈现的形式也是五花八门,因此跟 Dropdown 元件一样,我个人不太建议把内容写死,例如只能用固定格式的 props 来产生固定样式的内容。而是希望 Drawer 的滑出滑入行为跟内容独立开来,Drawer 就是单纯一个容器,而内容若需要固定格式的 props 来产生固定样式,就建议另外做个元件,独立处理内容的部分,之後再塞入 Drawer 这个容器中。

属性 说明 类型 默认值
isOpen 抽屉是否显示 boolean false
placement 抽屉的方向 top, right, bottom, left left
animationDuration 定义动画完成一次周期的时间(ms) number 200
children 抽屉的内容 ReactNode
onClose 触发抽屉关闭 function

元件实作

假设今天我们已经把元件做好,我们可以用下面的范例来使用这个抽屉元件,需要有一个按钮来触发抽屉的开启,然後抽屉元件上的 props 也很单纯,就是一个开关的 isOpen,然後控制关闭的 onClose function,最後 children 放置抽屉的内容:

const DrawerDemo = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button variant="outlined" onClick={() => setIsOpen(true)}>Open Drawer</Button>
      <Drawer
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
      >
        <div style={{ width: 300 }}>Drawer content</div>
      </Drawer>
    </>
  );
};

这样的话我们就会有如下的一个简单抽屉:

我们来看一下程序码的结构:

<Portal>
  <Mask
    $isOpen={isOpen}
    $animationDuration={animationDuration}
    onClick={onClose}
  />
  <DrawerWrapper
    $isOpen={isOpen}
    $placement={placement}
    $animationDuration={animationDuration}
  >
    {children}
  </DrawerWrapper>
</Portal>

我们一样用 Portal 把内容渲染到父层结构上面去,用来简化我们图层的 stacking context。

再来我们可以看到里面有主要两个元件:

  1. 遮罩 mask
  2. 抽屉本身

抽屉遮罩

在遮罩上面我们要做几件事:

  1. 遮罩的垂直图层位置位於原本画面与抽屉内容中间,这样他可以遮住原本的画面,让抽屉内容在视觉上显得显眼。
  2. 点击遮罩的时候需要触发抽屉的 onClose 事件,要关闭抽屉。
  3. 开启抽屉时,遮罩要有淡入动画;关闭抽屉时,遮罩要有淡出动画

首先第一件事,遮罩的垂直图层位置,
遮罩的 position 我是设为 fixed,因为只是设为 position: absoltue; 的话,如果被遮住的内容是可以 scroll 的,这样遮罩就不会跟着移动,会像是这样:

因为已经被设为 position: fixed; ,所以图层位置用 z-index 来调整就可以了。

第二,点击遮罩要能关闭抽屉
我们只需要在遮罩上面绑定一个 onClick 事件,让他触发 onClose 就可以了,这题很简单。

<Mask
  ...(略)
  onClick={onClose}
/>

第三,开关抽屉时,Mask 要有淡入淡出动画

这边我是使用 styled-components 的 keyframes 来定义我的动画效果:

import styled, { keyframes } from 'styled-components';

const hideMask = keyframes`
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
`;

const showMask = keyframes`
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
`;

定义完淡入淡出动画的关键影格动画之後,就依照是否打开抽屉 isOpen 这个 props 来决定要播放哪一个动画:

const Mask = styled.div`
  {...略}
  animation: ${(props) => (props.$isOpen ? showMask : hideMask)} 200ms ease-in-out forwards;
`;

特别提一下我在 animation 尾部放入 forwards,这是一个名为 animation-fill-mode 的 css 属性,指的是动画执行的前或後应该如何呈现样式。

我给他 forwards 表示我希望动画执行结束之後,样式会停留在结束时的状态,而不是动画开始时的状态。

抽屉滑出效果

抽屉滑出效果,我们预设打开是从左边滑出来,关闭时从左边收回去,因此我们以这个范例来说明。

跟我这系列其他篇章一样,我用一样的手法,给定一个 placement 的时候,会显示对应的样式:

const placementMap = {
  top: topStyle,
  right: rightStyle,
  bottom: bottomStyle,
  left: leftStyle,
};

const DrawerWrapper = styled.div`
  {...略}
  ${(props) => placementMap[props.$placement] || placementMap.left}
`;

跟前面提到的 mask 很雷同,在 left 样式当中,会根据 isOpen 这个 boolean props 来显示要播放滑出还是收回的动画,动画我们也是用 styled-components 的关键影格 keyframes 来定义,我们让动画滑出滑入的参数很简单,只有用 left 这个属性而已:

const leftShowDrawer = keyframes`
  0% {
    left: -100%;
  }
  100% {
    left: 0%;
  }
`;

const leftHideDrawer = keyframes`
  0% {
    left: 0%;
  }
  100% {
    left: -100%;
  }
`;

const leftStyle = css`
  top: 0px;
  left: 0px;
  height: 100vh;
  animation: ${(props) => (props.$isOpen ? leftShowDrawer : leftHideDrawer)} 200ms ease-in-out forwards;
`;

其他 placement 可以依此类推,详细的内容我有附在程序码当中供大家参考,到目前为止我们就已经能够做出一个有模有样的滑出滑入抽屉啦!简单展示一下成果:

滑出之後让元件消失

最後我们稍微优化一下,这个没有做其实不太会影响到功能,但有做的话应该会好棒棒!

我的核心想法是说,因为我需要在播放完动画完才把抽屉元件的 DOM 移除掉,所以我必须要先参数化我们的动画播放时间,我把它叫做 animationDuration,预设值为 200ms

所以不管我们是的 Mask 淡入淡出动画,或是抽屉滑出滑入的动画,我们的动画持续时间都是用 animationDuration 来带入,然後等动画播放完毕之後,我把 DOM 移除掉,那这个时间点我就把动画持续时间加上 100ms,也就是说,在播放完收合动画之後的 100ms 我要移除这个元件。

我的程序码简化示意如下:

const Drawer = ({
  children, isOpen, placement, onClose,
  animationDuration,
}) => {
  const [removeDOM, setRemoveDOM] = useState(!isOpen);

  useEffect(() => {
    if (isOpen) {
      setRemoveDOM(false);
    } else {
      setTimeout(() => {
        setRemoveDOM(true);
      }, (animationDuration + 100));
    }
  }, [animationDuration, isOpen]);

  return !removeDOM && (
    <Portal>
      <Mask ... />
      <DrawerWrapper ... />
    </Portal>
  );
};

我另外用一个 removeDOM 的 boolean 来决定是否在 DOM 里面塞入抽屉元件,当抽屉被打开的时候,因为是节点被塞入 DOM 才开始播放动画,所以这里 setRemoveDOM(false); 可以让他立即执行。

然後当抽屉关闭的时候,需要等待动画播放完之後再移除,所以透过 setTimeout 来实现。

附注说明一下,我们的 <Portal /> 元件里面,我有做一些小修改,让这个元件在被移除之前(unmount),要先移除 Portal 的根节点,这是跟之前篇章有点差异的地方:

// src/components/Portal/index.jsx

useEffect(() => () => {
  portalRoot.parentElement.removeChild(portalRoot);
}, [portalRoot]);

下面就是我们的成果展示啦!


Drawer 元件原始码:
Source code

Storybook:
Drawer


<<:  [Android Studio 30天自我挑战] 透过Banner来轮播广告资讯

>>:  图的储存结构 - 相邻矩阵 - DAY 20

[DAY 10] AWS RDS

再来写一点关於在 AWS 上的 reational database 有关的资讯, 也就是在 AW...

Day 30: 更多的 Vue SSR

这篇程序码在 https://github.com/DanSnow/ironman-2020/tr...

D12 使用者个人文件页

首页完成後 让使用者可以进入使用者个人文件页 列出属於此使用者的文件 我已经先用测试网页塞了测试资料...

【履历要点 ii】不要放这些在履历上

上一篇讲了要放什麽,今天这篇讲一下不要放的东西。 Summary 这应该是有点争议的,至少我会有点想...

AI ninja project [day 10] 基因演算法

这一篇介绍,将使用DEAP这个套件, 其实,现在比较红及使用上比较简便的套件应该是PyGAD, 但是...