2. Switch
基本樣式
Swtich 的交互行為比較像 button,因此 styled 元件可以用 button 代替,
在 Button 元件已經設計好 useColor 這個 hook 可以用了,
所以設計時就可以先傳入 $themeColor。
import styled from 'styled-components';
interface Props {
$themeColor?: string;
}
export const StyledSwitch = styled.button<Props>`
position: relative;
display: flex;
align-items: center;
padding: 2px;
width: 40px;
height: 24px;
border: none;
border-radius: 12px;
outline: none;
background-color: ${({ $themeColor }) => $themeColor};
cursor: pointer;
`;
export const Thumb = styled.div<Props>`
position: absolute;
left: ${({ $isChecked }) => ($isChecked ? '18px' : '2px')};
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #fff;
transition: transform 0.2s ease-in-out;
`;
外層元件:
interface SwitchProps {
isChecked: boolean;
themeColor?: string;
onClick: () => void;
}
/**
* 這就是一個 Switch 開關,不然你要怎樣
*/
export function Switch({ isChecked, themeColor = 'primary', onClick }: SwitchProps) {
const { getColor } = useColor();
const switchColor = getColor(themeColor, !isChecked);
return (
<StyledSwitch $isChecked={isChecked} $themeColor={switchColor} onClick={onClick}>
<Thumb $isChecked={isChecked} />
</StyledSwitch>
);
}
不管文字的話 Switch 交互流程大致結束了!
寬度變化
考慮文字之後就表示寬度是會隨著文字內容改變而變化的,
所以需要用 useRef 綁定 Label,:
useLayoutEffect(() => {
// 這個寬度比較不一定,如果需要調這個值,要注意後續計算位移的量
const minLabelWidth = thumbSize * 1.2;
const currentLabelWidth = labelRef.current?.clientWidth ?? minLabelWidth;
setLabelWidth(currentLabelWidth > minLabelWidth ? currentLabelWidth : minLabelWidth);
}, [labelRef?.current?.clientWidth, isChecked]);
只加入書中給的依賴項 labelRef?.current?.clientWidth 的話,
我嘗試過後在搭配文字時會有問題,因此我加入 isChecked,我認為語意上也是通順的,
因為文字內容的變化必定發生在按下這個開關後。
現在可以取到動態變化的 Label 寬度後,原本在 styled 裡面寫死的寬高和位移量,
都可以重新修改了。
加入間隔
目前是把 padding 清空,Label 和 Thumb 間也只計算了位移,
整體的樣式是沒有間距的,所以這邊先補上 SwitchButton 的 padding:
export const SwitchButton = styled.button<StyledProps>`
padding: 0 4px;
width: ${({ $labelWidth, $thumbSize }) => $labelWidth! + $thumbSize}px;
height: ${({ $thumbSize }) => $thumbSize * 1.5}px;
box-sizing: content-box;
// 其他略
`;
export const Thumb = styled.div<StyledProps>`
flex-shrink: 0;
width: ${({ $thumbSize }) => $thumbSize}px;
height: ${({ $thumbSize }) => $thumbSize}px;
transform: translateX(${({ $isChecked, $labelWidth, $thumbSize }) => ($isChecked ? $labelWidth : 0)}px);
// 其他略
`;
export const Label = styled.label<StyledProps>`
padding: 0 4px;
transform: translateX(${({ $isChecked, $thumbSize }) => ($isChecked ? -$thumbSize : 0)}px);
// 其他略
`;
上面的程式碼可以發現,我在使用 translateX 做位移時,基本上沒有額外做計算,
這是因為:
SwitchButton改為content-boxLabel用padding做間隔(用margin需要額外計算)
一開始我使用 border-box 和 margin 來做間隔,
只能說那程式碼不是普通醜,而且如果想配合 size 做整體大小的變化,
那可說是慘烈,至少以我目前的能力沒辦法讓那程式碼變好看。
打包 props
如果發現給 styled 的 props 很多,我會嘗試用打包的方式,
讓 JSX 看起來乾淨一點,這樣做也可以讓 styled 裡面的型別不用再考慮到底要不要必傳,
下了問號又要考慮驚嘆號,我覺得 styled 裡面還是盡量減少這些型別判斷與額外計算,
專注在 props 與樣式變化的關係就好:
const styledProps = {
$isChecked: isChecked,
$themeColor: switchColor,
$thumbSize: thumbSize,
$labelWidth: labelWidth,
};
return (
<SwitchButton {...styledProps} disabled={isDisabled} onClick={isDisabled ? () => {} : onClick}>
<Thumb {...styledProps} />
<Label ref={labelRef} {...styledProps}>
{isChecked ? checkedText : uncheckedText}
</Label>
</SwitchButton>
);
展示設定
Switch 需要外部傳入 isChecked 與 onClick,本身並沒有 state(保證單向資料流),
所以直接輸出 Story元件的話在 storybook 的展示裡面是沒有點擊效果的,
要稍微改寫一下 Story:
export const Normal: Story = {
render: (args) => {
const [isChecked, setIsChecked] = useState(false);
const handleClick = () => {
setIsChecked(!isChecked);
};
return <Switch {...args} isChecked={isChecked} onClick={handleClick} />;
},
};
改用 render 的方式設計一個外層元件後,這個 Story 的 Switch 就可以被點擊了!
屬性設計
interface ButtonProps {
/**
* 關閉狀態的文字
*/
uncheckedText?: React.ReactNode;
/**
* 開啟狀態的文字
*/
checkedText?: React.ReactNode;
/**
* 是否開啟
*/
isChecked: boolean;
/**
* 是否禁用
*/
isDisabled?: boolean;
/**
* 顏色
*/
themeColor?: string;
/**
* 尺寸
*/
size?: 'sm' | 'md';
/**
* 切換開關
*/
onClick: () => void;
}