iT邦幫忙

2021 iThome 鐵人賽

DAY 29
1
Modern Web

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

【Day29】反饋元件 - Toast

元件介紹

Toast 可以提供使用者操作的反饋訊息。包含一般資訊、操作成功、操作失敗、警告訊息等。預設為在頂部置中顯示並自動消失,是一種不打斷用戶操作的輕量級提示方式。

參考設計 & 屬性分析

我們來參考一下 Antd 的 message 元件,這個元件很有意思,我還蠻喜歡的。

他跟我們其他元件需要寫 JSX 在畫面上不一樣,他是直接執行一個 function 來顯示 toast,如下:

import { message, Button } from 'antd';

const info = () => {
  message.info('This is a normal message');
};

ReactDOM.render(
  <Button type="primary" onClick={info}>
    Display normal message
  </Button>,
  mountNode,
);

這個 message.info() 只要被執行一次,toast 就會在畫面上跳出來一次。

另外,我也參考了 React-Toastify 這個 npm 套件,這個也是我過去在專案中有接觸過的套件,我們來看他的使用方法:

import React from 'react';

import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

function App(){
  const notify = () => toast("Wow so easy!");

  return (
    <div>
      <button onClick={notify}>Notify!</button>
      <ToastContainer />
    </div>
  );
}

雖然看起來也是還蠻不錯的 toast,但是我們觀察看看,他要使用的時候會需要比 Antd message 多幾個步驟,像是他需要放一個 <ToastContainer /> 在畫面上,然後需要引入他的 CSS 樣式,另外也需要執行他特有的 toast function 才能顯示 toast 訊息。

所以今天我們要來挑戰看看是不是能夠做出 Antd 這種一拿就能夠用的 toast。

我們多按幾次來觀察一下他的行為,首先,我們發現他跟其他提示元件一樣都是把元件畫到比較外層的 DOM 結構上,大概是如下的結構:

<html>
  <header>...</header>
  <body>
    <div id="root">...</div>
    <div class="ant-message">
      <div>...</div>
    </div>
  </body>
</html>

所以這個就是我們本次的目標,我們要想辦法把 toast 畫在比較上層的節點上,並且是用呼叫一個 function 的方式來做。只要能夠做到這一件事,其他的部分感覺起來就不難了。

然後我想像我未來會這樣使用這個 toast,因為提示訊息有分種類,分別有 操作成功一般通知訊息警告訊息錯誤訊息

然後訊息也會有它的內容,甚至有時候我們想要控制這個訊息顯示的持續時間。

一個簡單的 Toast 應該具備上述幾個參數就足夠了。再更進階一點,我們有看到 React-toastify 有很多的參數控制項,我們來偷看一下:

toast('? Wow so easy!', {
  position: "bottom-center",
  autoClose: 5000,
  hideProgressBar: false,
  closeOnClick: true,
  pauseOnHover: true,
  draggable: true,
  progress: undefined,
});

以上述的參數來看,甚至他可以決定 Toast 的出現位置,從視窗的左上、右上、左下、右下...等等各種位置來出現,還有是不是能夠手動關掉 Toast,因為可能有時候 Toast 遮擋住畫面上我們正在瀏覽的訊息。

因此我們今天的目標,至少先做出一個簡單版的 Toast,其他的部分,可以依照自己的需求再修改或添加。

介面設計

屬性 說明 類型 默認值
type 提示訊息種類 success, info, warn, error
content 提示訊息內容 ReactElement, string
duration 提示訊息展示時間 3000ms

元件實作

假設我今天要跳出一個訊息,我希望我的介面是長這樣:

message.success({ type: 'success', content: '新增成功' });

我們知道,success 是一個 function,這個 function 會幫我們把畫面畫出來。

在 React 當中,有一個方法可以幫助我們做到這件事,就是 ReactDOM.render()

ReactDOM.render(element, container[, callback])

所以簡單來說,我們要用 ReactDOM.render() 這個方法,想辦法讓我們的 Toast 變成這樣:

<html>
  <header>...</header>
  <body>
    <div id="root">...</div>
    <div class="toast-root">
      <div class="toast-container">
        <Toast />
        <Toast />
        <Toast />
        <Toast />
        <Toast />
        ...
      </div>
    </div>
  </body>
</html>

以下是我的方法:

export const message = {
  success: (props) => {
    render(<Toast {...props} type="success" />, getContainer());
  },
  info: (props) => {
    render(<Toast {...props} type="info" />, getContainer());
  },
  warn: (props) => {
    render(<Toast {...props} type="warn" />, getContainer());
  },
  error: (props) => {
    render(<Toast {...props} type="error" />, getContainer());
  },
};

getContainer() 當中,我要想辦法製造出下面這樣的結構,並透過 document.body.appendChild(...); 把他塞進 body 下面,之後新增一個在 toast-container 下面的子節點當作 container 回傳回來給 ReactDOM.render() 就可以了。

<div class="toast-root">
  <div class="toast-container">
  </div>
</div>

其中,toast-container 存在的目的,是為了要幫助我們對他的 children ,也就是 Toast 做一些排版上的佈局,例如置中...等等。

const rootId = 'toast-root';

const getContainer = () => {
  let toastRoot;
  let toastContainer;

  // 製造出 toastRoot
  if (document.getElementById(rootId)) {
    toastRoot = document.getElementById(rootId);
  } else {
    const divDOM = document.createElement('div');
    divDOM.id = rootId;
    document.body.appendChild(divDOM);
    toastRoot = divDOM;
  }

  // 製造出 toastContainer,並放在 toastRoot 底下
  if (toastRoot.firstChild) {
    toastContainer = toastRoot.firstChild;
  } else {
    const divDOM = document.createElement('div');
    toastRoot.appendChild(divDOM);
    toastContainer = divDOM;
  }
	
  // 製造出 container,並放在 toastContainer 底下
  const divDOM = document.createElement('div');
  toastContainer.appendChild(divDOM);

  // 調整 toastRoot 以及 toastContainer 的樣式
  toastRoot.style = css`
    position: absolute;
    top: 0px;
    left: 0px;
    width: 100vw;
  `;

  toastContainer.style = css`
    position: absolute;
    top: 0px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 9999;
    display: flex;
    flex-direction: column;
    align-items: center;
  `;

  // 把 container 回傳,成為 ReactDOM.render() 的第二個參數
  return divDOM;
};

以上就是我們把 Toast 畫在外面的方法了。

再來我們來看 Toast 本體:

const Toast = ({
  type,
  content,
  duration,
}) => {
  const toastRef = useRef();
  const [isVisible, setIsVisible] = useState(true);
  const color = getColor(type);

  useEffect(() => {
    setTimeout(() => {
      setIsVisible(false);
    }, duration);
    setTimeout(() => {
      const currentDOM = toastRef.current;
      const parentDOM = currentDOM.parentElement;
      parentDOM.parentElement.removeChild(parentDOM);
    }, duration + 200);
  }, [duration]);

  return (
    <ToastWrapper
      ref={toastRef}
      $isVisible={isVisible}
    >
      <Icon $color={color}>{iconMap[type]}</Icon>
      {content}
    </ToastWrapper>
  );
};

這個本體也就很簡單,就是一些樣式的排版與呈現,需要特別說明的部分是我們透過 isVisible 這個 state 來控制 Toast 的出現與消失,同時伴隨著他的動畫效果:

const topIn = keyframes`
  0% {
    transform: translateY(-50%);
    opacity: 0;
  }
  100% {
    transform: translateY(100%);
    opacity: 1;
  }
`;

const topOut = keyframes`
  0% {
    transform: translateY(100%);
    opacity: 1;
  }
  100% {
    transform: translateY(-50%);
    opacity: 0;
  }
`;

const topStyle = css`
  animation: ${(props) => (props.$isVisible ? topIn : topOut)} 200ms ease-in-out forwards;
`;

const ToastWrapper = styled.div`
  /* ...略 */
  ${topStyle}
`;

當然,如果還有餘裕的話,我們就能夠來刻各種方向出現與消失的動畫,這邊先以 top 方向為例。

那在這個 Toast 當中,依照不同類型的提示訊息,我們可以給他不同的 icon:

import SuccessIcon from '@material-ui/icons/Check';
import InfoIcon from '@material-ui/icons/InfoOutlined';
import WarnIcon from '@material-ui/icons/ReportProblemOutlined';
import ErrorIcon from '@material-ui/icons/HighlightOffOutlined';

const iconMap = {
  success: <SuccessIcon />,
  info: <InfoIcon />,
  warn: <WarnIcon />,
  error: <ErrorIcon />,
};

而不同的訊息,當然也需要對應的不同顏色:

const getColor = (type) => {
  if (type === 'success') {
    return '#52c41a';
  }
  if (type === 'info') {
    return '#1890ff';
  }
  if (type === 'warn') {
    return '#faad14';
  }
  if (type === 'error') {
    return '#d9363e';
  }
  return '#1890ff';
};

這樣我們的簡單的 Toast 就完整了:

<ToastWrapper
  ref={toastRef}
  $isVisible={isVisible}
>
  <Icon $color={color}>{iconMap[type]}</Icon>
  {content}
</ToastWrapper>

最後我們來 Demo 我們的成果:

const ToastDemo = (args) => (
  <ButtonGroup>
    <Button variant="outlined" onClick={() => message.success({ type: 'success', content: '新增成功' })}>Success</Button>
    <Button variant="outlined" onClick={() => message.info({ type: 'info', content: 'Some information' })}>Information</Button>
    <Button variant="outlined" onClick={() => message.warn({ type: 'warn', content: '伺服器出了一點問題' })}>Warning</Button>
    <Button variant="outlined" onClick={() => message.error({ type: 'error', content: '刪除失敗' })}>Error</Button>
  </ButtonGroup>
);


Toast 元件原始碼:
Source code

Storybook:
Toast

參考

https://www.npmjs.com/package/antd-like-message
https://github.com/bingqichen/antd-message/blob/master/src/message/index.js


上一篇
【Day28】反饋元件 - Modal
下一篇
【Day30】挑戰回顧 & 鐵人練成心得分享
系列文
30 天擁有一套自己手刻的 React UI 元件庫30

尚未有邦友留言

立即登入留言