跳至主要内容

2-Switch

7/28/2024 發布

屬性設計

interface ButtonProps {
/**
* 關閉狀態的文字
*/
uncheckedText?: React.ReactNode;

/**
* 開啟狀態的文字
*/
checkedText?: React.ReactNode;

/**
* 是否開啟
*/
isChecked: boolean;

/**
* 是否禁用
*/
isDisabled?: boolean;

/**
* 顏色
*/
themeColor?: string;

/**
* 尺寸
*/
size?: "sm" | "md";

/**
* 切換開關
*/
onClick: () => void;
}

基本樣式

使用 styled 做出基本的樣式,因為在 Button 元件已經設計好 useColor 這個 hook 可以用了,
所以這邊在做基本樣式時已經先把 $themeColor 傳入:

export const SwitchButton = styled.button<StyledProps>`
display: flex;
align-items: center;

padding: 0;
width: 40px;
height: 20px;
border: none;
border-radius: 20px;

background-color: ${({ $isChecked, $themeColor, theme }) =>
$isChecked ? $themeColor : theme.colors.disable};

cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")};
`;

export const Thumb = styled.div<StyledProps>`
width: 16px;
height: 16px;
border-radius: 50%;
background-color: white;

transform: translateX(${({ $isChecked }) => ($isChecked ? "20px" : "0px")});
transition: 0.3s;
`;

export const Label = styled.label<StyledProps>`
color: white;
font-size: 14px;
line-height: 1;
white-space: nowrap;

cursor: pointer;
`;

interface StyledProps {
$isChecked: boolean;
$themeColor?: string;
}

外層元件:

function Switch({
checkedText,
uncheckedText,
isChecked,
themeColor = "primary",
isDisabled = false,
size = "md",
onClick,
}: SwitchProps) {
const { getColor } = useColor();
const switchColor = getColor(themeColor, isDisabled);

return (
<SwitchButton
$isChecked={isChecked}
$themeColor={switchColor}
disabled={isDisabled}
onClick={onClick}
>
<Thumb $isChecked={isChecked} />
<Label $isChecked={isChecked}>
{/* {isChecked ? checkedText : uncheckedText} */}
</Label>
</SwitchButton>
);
}

不管文字的話目前暫時已經完成 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 清空,LabelThumb 間也只計算了位移,
整體的樣式是沒有間距的,所以這邊先補上 SwitchButtonpadding

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-box
  • Labelpadding 做間隔(用 margin 會需要額外多些計算)

一開始我使用 border-boxmargin 來做間隔,
只能說那程式碼不是普通醜,而且如果想配合 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 需要外部傳入 isCheckedonClick,本身並沒有 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 的方式設計一個外層元件後,這個 StorySwitch 就可以被點擊了!