iT邦幫忙

2021 iThome 鐵人賽

DAY 21
0
Modern Web

30 天擁有一套自己手刻的 React UI 元件庫系列 第 21

【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


上一篇
【Day20】導航元件 - Select
下一篇
【Day22】導航元件 - Tabs
系列文
30 天擁有一套自己手刻的 React UI 元件庫30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言