iT邦幫忙

2021 iThome 鐵人賽

DAY 22
0

元件介紹

Tabs 是一個選項卡切換元件,能夠在同一層級的內容組別當中導航、切換。此元件由兩個部分構成,一個是讓使用者點擊的導覽頁籤 Tab,一個是對應的內容 TabPanel。通常使用於同一層級的內容之間互相切換、導航。

參考設計 & 屬性分析

下面來比較一下 Antd & Mui 的 Tabs 使用方式

Antd Tabs:

const AntdTabs = () => (
  <Tabs defaultActiveKey="1" onChange={callback}>
    <TabPane tab="Tab 1" key="1">
      Content of Tab Pane 1
    </TabPane>
    <TabPane tab="Tab 2" key="2">
      Content of Tab Pane 2
    </TabPane>
    <TabPane tab="Tab 3" key="3">
      Content of Tab Pane 3
    </TabPane>
  </Tabs>
);

MUI Tabs:

const MuiTabs = () => (
  <AppBar position="static">
    <Tabs value={value} onChange={handleChange} aria-label="simple tabs example">
      <Tab label="Item One" {...a11yProps(0)} />
      <Tab label="Item Two" {...a11yProps(1)} />
      <Tab label="Item Three" {...a11yProps(2)} />
    </Tabs>
  </AppBar>
  <TabPanel value={value} index={0}>
    Item One
  </TabPanel>
  <TabPanel value={value} index={1}>
    Item Two
  </TabPanel>
  <TabPanel value={value} index={2}>
    Item Three
  </TabPanel>
);

從上面的程式碼我們可以發現一些相似之處,也有相異之處。
像似之處是在於導覽列的結構很相似,都是用一個 Tabs 的 wrapper 將 Tab 包覆起來,用 Tab 來處理 label 顯示的內容,Tabs 則統一來處理 onChange 事件,onChange 之後會決定 active 的 Tab 是誰,再按照 Tab 上的 key 來顯示 active 的樣式,這樣的好處就是我們不用一一在每個 Tab 上面處理 onChange 事件,可以把共同的部分往外一層抽出。

相異之處則是在於 TabPanel 的處理,Antd 是由同一個元件來處理 Tab 以及 Panel,這個元件它命名為 TabPane。

TabPane 當中,props tab 用來傳入 Tab 要顯示的內容,型別是 ReactNode,因此要放一個 icon 進去 Tab 的內容也是做得到;再來是 key 這個 props ,用來識別 Tab 是否被選取。然後 TabPane 的內容則由 children 傳入。

MUI 則是將導覽列及內容拆成兩個元件,分別是 Tab 以及 TabPanel,Tab 的內容用 label 這個 props 來處理,一樣可以支援 ReactNode 型別,但是 icon 有獨立的 props 介面來處理;value這個參數則是跟 Antd 的 key 一樣,用來識別是否是 active 狀態。Tab 跟 TabPanel 拆開來處理有一個好處就是讓 Tab 的導覽列可以獨立出來,假設我們今天 TabPanel 呈現的方式跟預設不一樣,可能今天要有一個 Tab 導覽列搭配 Smooth Scrolling 的單頁式設計,那這樣同樣的 Tab 元件也可以拿來使用,範例如下:

Indicator

為了表示哪個 Tab 為 active,在 Tab 下方通常有一橫條稱為 indicator 來幫助識別。

簡單的做法我們可以用 Tab 的 border-bottom 來做,這樣只需要一個 boolean 來決定是否有 border-bottom 的樣式就可以了:

但是我們看到 MUI 及 Antd 的 indicator 都很華麗,會有一個底部的滑動動畫來過場,要做到這樣的效果,勢必用上述的結構必定是做不到,那該怎麼樣才能做到這樣的效果呢?我們偷偷打開檢視原始碼,來瞧瞧他的眉角

透過觀察 MUI Tab,我們簡化他的結構如下:

.scroller {
  position: relative;
}

.indicator {
  position: absolute;
  bottom: 0px;
  height: 2px;
  transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
}

<TabsScrollerWrapper className="scroller">
  <Tabs>
    <Tab />
    <Tab />
    <Tab />
  </Tabs>
  <span
    className="indicator"
    style="left: 0px; width: 160px;"
  >
  </span>
</TabsScrollerWrapper>

透過上述的結構我們可以知道,將 TabsScrollWrapper 設為 position: relative; 並且將 indicator 設為 position: absolute;,這樣 indicator 就能夠以 TabsScrollWrapper 為基準做絕對定位。

此時,我們將 Tabs 跟 indicator 一樣都放在 TabsScrollWrapper 下的同一層,當 active tab 改變時,我們即時計算出 active tab 的位置,並透過改變 indicator 的 css left 屬性來跟 active tab 對齊,搭配 css transition 就能夠做到在底部滑動的效果。

介面設計

屬性 說明 類型 默認值
className 客製化樣式 string
themeColor 主題配色 primary, secondary, 色票 primary
options Tabs 選項內容 { label, value }[]
value 用來指定當前被選中的 Tab 項目 string
onChange 當 Tab 選項被選中時會被調用 (value) => void

元件實作

今天我們要來做一個如下使用方式的 Tabs

const StyledTabs = styled(Tabs)`
  border-bottom: 1px solid #EEE;
`;

const tabOptions = [
  {
    value: 'item-one',
    label: 'ITEM ONE',
  },
  {
    value: 'item-two',
    label: 'ITEM TWO',
  },
  {
    value: 'item-three',
    label: 'ITEM THREE',
  },
  {
    value: 'item-four',
    label: 'ITEM FOUR',
  },
];


const TabsDemo = () => {
  const [selectedValue, setSelectedValue] = useState(tabOptions[0].value);
  return (
    <>
      <StyledTabs
        value={selectedValue}
        options={tabOptions}
        onChange={(value) => setSelectedValue(value)}
      />
      <TabPanel>
        {`TabPanel of #${selectedValue}`}
      </TabPanel>
    </>
  );
};

其實整體使用方式跟前幾篇的 Select 有 87 分像,我們只需要給定三個 props,包含 選中的項目選項內容被選中時調用的 onChange

因為我們已經可以透過 onChange 來拿到被選中的項目,因此我們 TabPanel 就能夠很自由的來切換。

我們來看一下 Tabs 的內部,結構上我也是以 TabGroup 來包住每一個 Tab 項目,其實跟我們之前在處理 RadioGroup 是很類似的:

const Tabs = ({
  className,
  themeColor,
  value, options, onChange,
}) => {
  const { makeColor } = useColor();
  const color = makeColor({ themeColor });

  return (
    <TabGroup
      className={className}
      onChange={onChange}
      value={value}
      color={color}
    >
      {
        options.map((option) => (
          <Tab
            key={option.value}
            label={option.label}
            value={option.value}
          />
        ))
      }
    </TabGroup>
  );
};

Tab 點擊及選取

TabGroup 裡面的 children,我們就是用 options 把 Tab 的選項都迭代出來,至於其他的樣式以及選取控制等等,都在 TabGroup 裡面處理。

TabGroup 我們來看一下處理選項被選中的地方:

<StyledTabGroup ref={tabGroupRef} className="tab__tab-group">
  {React.Children.map(children, (child, tabIndex) => (
    React.cloneElement(child, {
      onClick: () => handleClickTab({
        tabValue: child.props.value,
        tabIndex,
      }),
      isActive: child.props.value === value,
      color,
    })
  ))}
</StyledTabGroup>

看起來有點複雜,但希望藉由我的說明可以讓他簡單一點。

最主要的目的我們是希望能夠在每一個 Tab 上面做兩件事,一個是綁定點擊事件,一個是標示哪個選項目前被選中。

因為我們知道在 TabGroup 下面的 Tab 若被展開來是長這樣:

<TabGroup
  className={className}
  onChange={onChange}
  value={value}
  color={color}
>
  <Tab key={option[0].value} label={option[0].label} value={option[0].value} />
  <Tab key={option[1].value} label={option[1].label} value={option[1].value} />
  <Tab key={option[2].value} label={option[2].label} value={option[2].value} />
  <Tab key={option[3].value} label={option[3].label} value={option[3].value} />
</TabGroup>

所以在上面程式碼中,我們知道 children 就是一個 array of <Tab />,因此我們透過 React.Children.map 的幫助,把裡面每一個 Tab 迭代出來,也就是我們的 child

在 child 上面,我們要綁定點擊事件以及標示是否選取,所以我們就透過 React.cloneElement 來幫我們做到這件事。

因為我們在 Tab 上面綁定了 onClick 事件,我們就能夠透過 onChange 來拿到被選取的 Tab 的 value 及 tabIndex,也能夠藉此來標示哪個 Tab 是被選取的了。

const handleClickTab = ({ tabValue, tabIndex }) => {
  onChange(tabValue);
  setActiveIndex(tabIndex);
};

Indicator

再來我們要來講可滑動的 Indicator,如果只是顯示一個沒有滑動動畫的 Indicator,其實也很簡單,就是透過被選取的 tabIndex 來標示哪個 Tab 下面有 border-bottom 就可以了,一個 boolean 就搞定。

所以我們要來做一些比較有難度的事,先來展示一下成果,表示我們接下來真的做得到:

我們的架構跟先前分析講的是一樣的,如下:

<TabsScrollerWrapper className={className} {...props}>
  <StyledTabGroup ref={tabGroupRef} className="tab__tab-group">
    {React.Children.map(children, (child, tabIndex) => (
      React.cloneElement(child, {
        onClick: () => handleClickTab({
          tabValue: child.props.value,
          tabIndex,
        }),
        isActive: child.props.value === value,
        color,
      })
    ))}
  </StyledTabGroup>
  <Indicator
    $left={tabAttrList[activeIndex]?.left || 0}
    $width={tabAttrList[activeIndex]?.width || 0}
    $color={color}
  />
</TabsScrollerWrapper>

<Indicator /><TabGroup /> 是在同一層,因為這樣才有辦法在 TabGroup 下面滑來滑去。

再來我們看到 Indicator 有傳入兩個 props,一個是 left,一個是 width。
left 是表示 Indicator 的位置,是相對於 <TabsScrollerWrapper /> 的距離,並且用 left 是希望我們能夠對他做 transition 過場動畫。

然後 width 就是我們 Tab 的寬度,因為難保我們 Tab 總是會一樣寬。

取得 left 以及 width 之後,Indicator 裡面的 style 就簡單了:

const Indicator = styled.div`
  position: absolute;
  bottom: 0px;
  left: ${(props) => props.$left}px;
  height: 2px;
  width: ${(props) => props.$width}px;
  background: ${(props) => props.$color};
  transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
`;

到目前為止我們已經知道 Indicator 的概念,那接下來的問題就是,我們該怎麼取得 left 以及 width 呢?

left 和 width 其實就是要拿到 active Tab 的 left 以及 width。
因為我們知道 TabGroup 的 children 就是 array of Tab,所以我採用的方法是對 TabGroup 使用 useRef 這個 hook,藉由他來取得 Tab。

const [tabAttrList, setTabAttrList] = useState([]);

const handleUpdateTabAttr = useCallback(() => {
  const tabGroupCurrent = tabGroupRef.current;
  const tabNumber = React.Children.count(children);

  setTabAttrList(
    [...Array(tabNumber).keys()]
      .map((tabIndex) => ({
        width: tabGroupCurrent.children[tabIndex].offsetWidth,
        left: tabGroupCurrent.children[tabIndex].offsetLeft,
      })),
  );
}, [children]);


useEffect(() => {
  handleUpdateTabAttr();
  window.addEventListener('resize', handleUpdateTabAttr);
  return () => {
    window.removeEventListener('resize', handleUpdateTabAttr);
  };
}, [handleUpdateTabAttr]);


<StyledTabGroup ref={tabGroupRef} className="tab__tab-group">
  {...array of Tab...}
</StyledTabGroup>

透過上面程式碼我們知道,在 did mount 的時候,還有 window resize 的時候我們會執行 handleUpdateTabAttr 來更新。

我們來看 handleUpdateTabAttr 在做什麼。在裡面我們把 TabGroup 的 children 一一拿出來,並且提取他的 width 以及 left,我們一次就把他全部記錄下來存成一個 array,這樣之後 active Tab 改變的時候,只要去這個 array 查找就可以了。

到目前為止我們可滑動 Indicator 的 Tabs 就完成了!

Tab 置中

要讓 Tabs 可以置左、置中、置右,主要控制 style 的地方是在 TabGroup 上面,因為我們前面也有說,TabGroup 的 children 是 array of Tab,所以我是使用 TabGroup 來佈局,所以在上面我會留下一個 className,方便我在往後客製化他的樣式

<StyledTabGroup className="tab__tab-group">
  {...}
</StyledTabGroup>

這樣我就能夠使用 flex 佈局來讓他置中:

const StyledCentered = styled(Tabs)`
  border-bottom: 1px solid #EEE;
  .tab__tab-group {
    justify-content: center;
  }
`;


<>
  <StyledCentered
    options={tabOptions}
    {...args}
    value={selectedValue}
    onChange={(value) => setSelectedValue(value)}
  />
  <TabPanel>
    {`TabPanel of #${selectedValue}`}
  </TabPanel>
</>

效果如下:

Icon Tab

要讓 Tab 上面除了呈現文字以外,也能夠呈現 Icon,甚至能夠呈現我們客製化樣式,這樣我們就需要讓我們一開始定義的 label 可以接受這些不同的型別的資料。

const iconTabOptions = [
  {
    value: 'phone',
    label: <PhoneIcon />,
  },
  {
    value: 'favorite',
    label: <FavoriteIcon />,
  },
  {
    value: 'person',
    label: <PersonPinIcon />,
  },
];

這樣我們在不用改任何架構的狀況下,就能夠把文字替換成 Icon 啦!

Colored Tab

當然我們的 Tab 也能夠隨意調整顏色,這部分跟先前的篇章,例如 Button, Radio, Switch ...等等篇章是一樣的方法,就不再重複說明:


Tabs 元件原始碼:
Source code

Storybook:
Tabs


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

尚未有邦友留言

立即登入留言