일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 코딩테스트
- 국비지원
- git
- 컴퓨터공학
- html/css/js
- BFS
- CSS
- 자바스크립트
- Javascript
- DFS
- KAKAO
- 호이스팅
- 패스트캠퍼스
- CS
- 프론트엔드개발자
- LinkSnap
- 야놀자
- computerscience
- nodejs
- 알고리즘
- 코테
- 국비지원취업
- js
- 컴퓨터과학
- 그리디
- 너비우선탐색
- 백준
- 부트캠프
- github
- cpu
- Today
- Total
My Boundary As Much As I Experienced
variants가 엄청난 디자인시스템의 버튼 컴포넌트를 설계해보자 본문
설계 배경
UI 개발에서 버튼은 필수적인 컴포넌트 중 하나이다.
하지만 단순한 기능처럼 보여도 버튼 컴포넌트를 잘 설계하는 것은 생각보다 까다로운 작업이다.
다양한 상태, 크기, 색상, 아이콘 배치 등 요구사항을 충족하면서도 유지보수성과 재사용성을 고려해야 하기 때문이다.
이번에 디자이너 분이 디자인 시스템을 본격적으로 만드시기 시작했는데 (내가 디자이너 시절에 만들었던 시안을 보고 작업하셨다ㅋㅋ..)
다른 컴포넌트에 비해 버튼이 특히 매우 다양한 variants가 존재하는 걸 알 수 있다.
이렇게 많은 variants들은 단순 prop들로만 관리하기 힘들었고, 나는 대안으로 컴파운드 컴포넌트 패턴을 활용하기로 했다.
이번 글에서는 컴파운드 컴포넌트 패턴과 디자인 시스템을 활용한 Button 컴포넌트를 어떻게 설계했는지 설명하겠다.
컴포넌트의 핵심 의도
이 버튼을 만들기 위해 다음과 같은 세 가지 주요 목표가 있었다:
- 디자인 시스템 기반의 일관성 유지:
버튼의 색상, 크기, 모양 등을 디자인 시스템의 규칙에 맞게 동적으로 렌더링한다. 이를 통해 프로젝트 전반에서 버튼 스타일의 일관성을 유지한다. - 컴포넌트의 재사용성과 확장성:
다양한 상황에서 사용 가능한 컴포넌트를 설계하여 중복 코드를 줄이고, 유연하게 재사용할 수 있도록 설계하였다. - 유연한 상태 관리:
버튼의 active, loading, disabled, pressed 등 상태를 컨텍스트와 훅을 통해 동적으로 관리하여, 상태 변화에 따라 UI와 동작을 쉽게 제어할 수 있도록 한다.
Button 컴포넌트의 설계 구조
이 컴포넌트는 크게 3개의 주요 설계 요소로 구성된다.
네임스페이스를 활용한 컴파운드 컴포넌트 구조
컴포넌트가 여러 하위 컴포넌트로 나뉘어 각 역할을 담당하도록 설계되었다.
예를 들어, 버튼의 텍스트, 아이콘 등을 독립적으로 조합할 수 있다.
<Button
buttonContent="fill"
buttonState={currentState}
buttonShape="square"
buttonSize="large"
buttonColor="primary"
onPress={handleClick}
>
<Button.Text text="좋아요" />
<Button.Icon>
<PencilIcon />
</Button.Icon>
</Button>
컨텍스트를 통한 상태 관리
버튼 상태(buttonState)와 스타일 관련 정보는 React.Context를 통해 공유된다.
이를 통해 버튼 내부의 텍스트와 아이콘이 부모 컴포넌트의 상태를 기반으로 자동으로 스타일을 적용받는다.
const ButtonContext = createContext<{
buttonState: ButtonStateType;
buttonSize: ButtonSizeType;
buttonColor: ButtonColorType;
textColor: string;
}>({
buttonState: "active",
buttonSize: "large",
buttonColor: "brand",
textColor: theme.colors.text.brand,
});
const TypedButton = ({
...
}: ButtonCompositionType) => {
const { buttonSize, textColor } = useContext(ButtonContext);
return (
<ButtonContext.Provider
value={{ // children들에게 context 전파
buttonColor,
buttonState,
buttonSize,
textColor,
}}
>
<ButtonBase
...
>
{children}
</ButtonBase>
</ButtonContext.Provider>
);
};
아래는 ButtonIcon이나 ButtonText에서 부모가 내려주는 context를 받아 활용하는 모습이다.
export const ButtonIcon = ({
children,
}: {
children: React.ReactElement;
}) => {
const { buttonSize, buttonColor, buttonState } = useContext(ButtonContext);
const iconSize = buttonSize === "large" ? 24 : 16;
const iconColor =
buttonColor && buttonState
? buttonBrandColors.text[buttonState] // buttonState(active | loading | disabled | ...)에 따라 아이콘 컬러값이 다르게 입력되도록 설정
: "#000";
return (
<View>
{React.Children.map(children, (child: React.ReactElement) =>
React.isValidElement<ChildComponentProps>(child)
? React.cloneElement(child, { // colenElement로 children에 props 덮어쓰기!
size: iconSize,
color: iconColor,
})
: child
)}
</View>
);
};
export const ButtonText = ({ text }: { text: string }) => {
const { buttonSize, textColor } = useContext(ButtonContext);
let textSizeWidthStyle: TextSizeWidthType | null = null;
switch (buttonSize) {
case "large":
textSizeWidthStyle = "head2Regular";
break;
case "small":
textSizeWidthStyle = "body2Regular";
break;
}
return (
<Text sizeWidth={textSizeWidthStyle} style={{ color: textColor }}>
{text}
</Text>
);
};
디자인 시스템 기반 스타일을 생성하는 유틸함수
버튼 스타일은 buttonSize, buttonColor, buttonState 등 여러 속성에 따라 동적으로 결정된다.
이를 위해 getColorByType과 generateStyleByType 같은 유틸리티 함수가 사용된다.
getColorByType만 예시로 설명하겠다.
getColorByType
버튼 색상은 buttonColor와 buttonState 조합에 따라 자동으로 계산된다.
세 가지 컬러 세트가 있고, 그 세가지 모두 Active | Pressed | Disabled | Loading 시의 컬러들이 다르다.
theme에 각각의 컬러세트의 Primitive값과 Semantic값들을 입력해두고
버튼과 버튼 텍스트 컬러에 semantic한 값들을 상태에 따라 반환해주는 유틸함수를 만들었다.
import {
buttonBrandColors, // 브랜드 컬러의 Active | Pressed | Disabled | Loading 등의 컬러 상태
buttonPrimaryColors, // Primary 컬러의 Active | Pressed | Disabled | Loading 등의 컬러 상태
buttonSecondaryColors, // ...
...
} from "./type";
export const getColorByType = (buttonColor, buttonState) => {
const colorMapping = {
brand: buttonBrandColors.surface,
primary: buttonPrimaryColors.surface,
secondary: buttonSecondaryColors.surface,
};
const textMapping = {
brand: buttonBrandColors.text,
primary: buttonPrimaryColors.text,
secondary: buttonSecondaryColors.text,
};
return {
backgroundColor: colorMapping[buttonColor][buttonState],
textColor: textMapping[buttonColor][buttonState],
};
};
UI 로직만 분리해서 재사용할 수 있도록 useButton Hook 생성
그리고 이를 활용하기 위한 useButton이라는 훅을 만들었다. 일종의 Controller로써 UI로직만을 처리해주는 훅이라고 보면 된다.
- 현재 버튼의 상태(loading, disabled, active, ...)를 동적으로 반환한다. 이를 View역할을 하는 버튼컴포넌트에 넣어주면 된다.
- handleClick은 핸들러를 주입하면 UI로직을 합성하여 반환하는 함수이다.
(try-catch문으로 에러핸들링을 해주거나, loading 상태 등으로 만들어주는 등의 로직을 둘러준다.)
이를 활용하여 맨날 핸들러에 보일러플레이팅 하지 않고 실제 비즈니스 로직만 핸들러로 만들어서 주입하면 된다. - 이외에도 isLoading, isDisabled 등의 버튼의 상태들을 원한다면 꺼낼 수 있다.
const useButton = ({
isLoading = false,
isDisabled = false,
onClick,
}: UseButtonOptions) => {
const [loading, setLoading] = useState(isLoading);
const [disabled, setDisabled] = useState(isDisabled);
const [currentState, setCurrentState] = useState<ButtonStateType>(
isDisabled ? "disabled" : isLoading ? "loading" : "active"
);
const handleClick = useCallback(
async (...args: any[]) => { // 모든 패러미터를 대응
if (disabled || loading) return;
setLoading(true);
setCurrentState("loading");
try {
await onClick(...args); // 전달받은 매개변수를 그대로 전달
} catch (error) {
console.error("Error during button click:", error); // 에러 로깅
} finally {
setLoading(false); // 로딩 상태 해제
setCurrentState("active");
}
},
[loading, disabled, onClick]
);
return {
loading,
disabled,
setLoading,
setDisabled,
currentState,
handleClick,
};
};
export default useButton;
// 실제 사용
const { currentState, handleClick } = useButton({
onClick: handleChangeTypeface, // 글꼴 바꾸는 핸들러
});
return (
<ScrollView>
<View style={styles.container}>
<InitBanner />
<ButtonView>
<Button
buttonContent="fill"
buttonState={currentState}
buttonShape="square"
buttonSize="large"
buttonColor="primary"
onPress={handleClick}
>
// ...
Type도 아토믹한 단위에서 점점 합성되어 가는 큰 단위로 가도록 설계
유니온 타입으로 콘텐츠타입, 컬러타입, 쉐입, 상태, 사이드 등을 정의하고,
ButtonCompositionType에 주입하였다. ButtonCompositionType은 이름대로 버튼 합성 타입이다.
이 디자인 시스템에 편입되지 않은 형태의 버튼들이 종종 발생한다.
디자인시스템에 100% 종속적인 타입을 설계하면 이를 대응하기 힘들기 때문에..
ButtonBaseType이란 코어적인 타입을 만들어서 headless한 기능적인 설계만 넣었고,
ButtonCompositionType을 ButtonBaseType을 확장하도록 만들었다.
// 수많은 Variant 경우의 수의 타입
export type ButtonContentType =
| "fill"
| "Icon"
| "IconLeft"
| "IconRight"
| "Text"
| "ghost"; // 형태 구분
export type ButtonColorType = "brand" | "primary" | "secondary"; // 색상 구분
export type ButtonShapeType = "round" | "square"; // Radius 구분
export type ButtonStateType = "active" | "disabled" | "pressed" | "loading"; // 내용 상태 구분
export type ButtonSizeType = Extract<ComponentSizeType, "small" | "large">; // 크기 구분
// 가장 기본이 되는 버튼의 베이스 타입
// 이 디자인 시스템을 안 따르는 버튼도 분명 존재하기에,
// 버튼의 코어부분을 분리하여 범용성 높게 활용하기 위한 타입이다.
export interface ButtonBaseType {
children: React.ReactNode;
onPress: (...args: any[]) => any;
buttonState: ButtonStateType; // 버튼의 상태
onLongPress?: () => void;
style?: StyleProp<ViewStyle | Partial<ButtonStyle>>;
shirinkAtPressed?: number;
touchOpacity?: boolean;
isDisabled?: boolean;
isLoading?: boolean;
formByType?: ViewStyle;
backgroundColorByType?: Record<ButtonStateType, string>;
}
// ViewStyle에 있는 값들이지만, Button의 스타일로 꼭 쓸만한 것들을 분리해놨다.
// ButtonBaseType의 style속성에 Partial<ButtonStyle> 로 optional하게 만들어서 사용했다.
export interface ButtonStyle extends ViewStyle {
borderRadius: string;
backgroundColor: string;
paddingLeft: DimensionValue;
paddingRight: DimensionValue;
paddingTop: DimensionValue;
paddingBottom: DimensionValue;
paddingHorizontal: DimensionValue;
paddingVertical: DimensionValue;
}
// 위 디자인 시스템에 있는 state, content, size, shape, color 등의 variant를
// ButtonBase 타입에 추가 확장한 타입이다.
export interface ButtonCompositionType extends ButtonBaseType {
buttonState: ButtonStateType;
buttonContent: ButtonContentType;
buttonSize: ButtonSizeType;
buttonShape: ButtonShapeType;
buttonColor: ButtonColorType;
}
위 설계에서 개선했으면 좋겠는 부분이나 설계 방향성에 대한 의문이 있다면 가감없이 댓글로 남겨주면 감사하겠다.
'FrontEnd > React' 카테고리의 다른 글
React-Hook-Form과 zod를 이용해 유효성 검사하기 (0) | 2024.12.11 |
---|---|
리액트는 MVC, MVVM, Flux 패턴중에 어디에 더 가까울까? (0) | 2024.11.21 |
Hook들을 Controller와 Business Logic으로 분류해서 사용하자 (0) | 2024.11.15 |
고차 컴포넌트 vs 컴파운드 컴포넌트 (0) | 2024.11.14 |
React Class Component의 기본 사용법 (0) | 2024.10.02 |