【Day22】导航元件 - Tabs

元件介绍

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


<<:  单一页面应用模式的页面导航

>>:  DAY20 - line message API 初体验

连续 30 天 玩玩看 ProtoPie - Day 12

第二种启动相机的方法 昨天勾选 Camera 的 Auto Start 来启动相机。 今天使用第二种...

Day 17 : 模型前的资料处理 (1)

虽然好的模型和参数可以提高成效,但通常最关键还是资料本身。基本上资料的品质决定了八成以上模型的成效,...

Day14 开发套件 - 范例程序码介绍03 iOS 端

最後来看Native 端(iOS): 补充:iOS 中的 .h 和.m 档 .h 为标头档,做为宣告...

IPv6

IPv6节点使用本地链接地址(前缀为FE80 :: / 10)引导,并使用多播与DHCP服务器联系。...

Day51. 职责链模式

本文同步更新於blog Chain Of Responsibility Pattern 使多个物件...