본문 바로가기

개발경험

디자인 시스템 개발 이야기(3) - 함수형 체이닝을 통한 디자인 시스템

나는 디자인 시스템을 설계하면서 init 파라미터 확장, SwiftUI의 modifier 등을 통해 확장등의 방법을 통해 먼저 디자인시스템을 만들어봤을 때의 특징은 다음과 같다.

1. init

다음과 같이 init 생성자에 파라미터가 길게 늘어져 있는 경우는 사실 디자인시스템의 명확한 기준이 없을 확률이 크다고 생각한다. 

이렇게 되어있는 경우 새로운 케이스가 나타날 때마다 자연스럽게 init 생성자에 새로운 파라미터를 추가해 점점 보기 안좋은 코드가 만들어질 것이다. 

init(
        title: String? = nil,
        subtitle: String? = nil,
        placeholder: String,
        text: Binding<String>,
        backgroundColor: Color = Color.gray.opacity(0.1),
        borderColor: Color = .gray,
        cornerRadius: CGFloat = 8,
        padding: CGFloat = 12
    ) {
        self.title = title
        self.subtitle = subtitle
        self.placeholder = placeholder
        self._text = text
        self.backgroundColor = backgroundColor
        self.borderColor = borderColor
        self.cornerRadius = cornerRadius
        self.padding = padding
    }

 

만약 현재 이러한 상황이라면 다음과 같이 개선해볼 수 있을 것이다. 

 

init 생성자 내부에 있는 값들을 protocol로 따로 정의.

textField를 꾸며줄 수 있는 값들을 미리 정의해 놓는 방법이다. 그리고 protocol을 채택하는 구조체를 미리 만들어 놓는다면 해당 값들이 필요한 케이스를 바로 가져다 쓸 수 있을 것이다.

 

// 스타일 프로토콜 정의
protocol TextFieldStyle {
    var title: String { get }
    var placeholder: String { get }
    var textColor: Color { get }
    var borderColor: Color { get }
    var cornerRadius: CGFloat { get }
    var padding: CGFloat { get }
}

// 기본 구현체
struct DefaultTextFieldStyle: TextFieldStyle {
    var cornerRadius: CGFloat = 8
    var padding: CGFloat = 12
    var title: String = "기본 입력"
    var placeholder: String = "텍스트를 입력하세요"
    var textColor: Color = .primary
    var borderColor: Color = .gray
}

struct WarningTextFieldStyle: TextFieldStyle {
    var cornerRadius: CGFloat = 8
    var padding: CGFloat = 12
    var title: String = "경고"
    var placeholder: String = "주의해서 입력하세요"
    var textColor: Color = .red
    var borderColor: Color = .red
}
struct ContentView: View {
    @State private var input = ""

    var body: some View {
        VStack(spacing: 20) {
            TextFieldView(text: $input, style: DefaultTextFieldStyle())
            TextFieldView(text: $input, style: WarningTextFieldStyle())
        }
    }
}

 

다음과 같이 만들면 디자인 시스템의 디자인이 추가되거나 변경될때 확장에는 열려있게 되고 디자인 시스템의 구조를 구조체화 할 수 있게 된다. 

이 방식도 충분히 좋은 방법이지만 현재 만들고 있는 서비스를 볼 때 같은 컴포넌트지만 케이스가 많기 떄문에 구조화 보다는 쉽게 케이스를 만들 수 있는 구조가 낫다고 판단했다.

2. modifier, ButtonStyle, TextFieldStyle 등 활용

다음과 같은 메서드들은 뷰 컴포넌트를 꾸며주는 역할을 한다.

struct DisabledTextFieldStyle: TextFieldStyle {
    func _body(configuration: TextField<_Label>) -> some View {
        configuration
            .font(DesignSystem.Font.body)
            .foregroundColor(.gray)
            .padding(DesignSystem.Spacing.padding)
            .background(DesignSystem.Color.gray100)
            .overlay(
                RoundedRectangle(cornerRadius: DesignSystem.Spacing.cornerRadius)
                    .stroke(DesignSystem.Color.gray400, lineWidth: 0.8)
            )
            .disabled(true)
    }
}

struct ContentView: View {
    @State private var name = ""
    @State private var password = ""
    @State private var disabled = "읽기 전용 필드"

    var body: some View {
        VStack(spacing: 20) {
            TextField("이름을 입력하세요", text: $name)
                .textFieldStyle(CustomTextFieldStyle())

            TextField("비밀번호를 입력하세요", text: $password)
                .textFieldStyle(WarningTextFieldStyle())

            TextField("", text: $disabled)
                .textFieldStyle(DisabledTextFieldStyle())
        }
        .padding()

 

textfieldStyle과 같은 메서드를 사용하면 장점은 SwiftUI 자체의 메서드 이기 때문에 다른 기존 내장 함수들을 확장하는데 자유롭게 사용할 수 있다. 하지만 아쉬운 점은 TextFieldStyle과 같은 프로토콜은 현재의 TextField 상태를 직접적으로 알 수 없다. 때문에 현재 텍스트에 따라서 UI 를 다르게 보여줘야 할 경우 Style 내부로 바인딩된 text 값을 넣어 줘야 한다. 

 

            TextField("", text: $disabled)
                .textFieldStyle(text: $disabled, DisabledTextFieldStyle())

그러면 다음과 같은 형태가 될 것이다. 

 

 

디자인 시스템을 만들면서 가장 고민했던 부분은 디자인 케이스를 하나로 만들어서 Style() 구조체 호출을 통해 컴포넌트가 완성되게 할 것인지 아니면 조합을 통해 컴포넌트를 완성시켜나갈지에 대한 고민이었다. 

 

디자인 시스템을 구축할 당시 디자인 시스템이 만들어지고 있는 단계였고 당시에는 어떤 케이스가 확정적인지 장담을 할 수 없었기 때문에 

SwiftUI와 같이 함수형 체이닝으로 디자인시스템을 만든 후, 디자인시스템이 안정적이 될 때 그 때 함수형 체이닝을 묶어서 구조화 하는 방법이 좋겠다고 판단했다. 

 

@MainActor
public protocol TextFieldViewConfigurable {
    func title(_ title: String) -> Self
    
    func sideButton<V: View>(@ViewBuilder content: @escaping () -> V, action: @escaping () -> Void) -> Self
    
    func sideView<V: View>(@ViewBuilder content: @escaping () -> V) -> Self
    
    func error(_ message: String?, isError: Bool) -> Self
    
    func isDisabled(_ value: Bool) -> Self
    
    func onSubmit(perform action: @escaping () -> Void) -> Self
    
    func returnKeyType(_ value: UIReturnKeyType) -> Self
    
    func keyboardType(_ value: UIKeyboardType) -> Self
}

 

다음과 같은 프로토콜로 메서드를 만든 다음 반환 타임을 Self로 만들어 스스로를 반환할 수 있는 구조를 만들어 연쇄적으로 메서드를 호출 할 수 있도록 했다. 

 

TextFieldView(text: $phoneNumber)
                .title(isPhoneNumberFocused ? phoneNumberTitle : phoneNumberPlaceholder)
                .error(phoneNumberValidateFailedMessage, isError: !isPhoneNumberValidated)
                .sideButton(content: {
                    XButtonView()
                }, action: {
                    phoneNumber = ""
                })

 

다음은 컴포넌트를 완성시키는 작업이다. title, error, sideButton이 필요한 케이스라면 다음과 같이 메서드를 호출해 컴포넌트를 완성시킬 수 있다.

 

느낀점

 

컴포넌트를 조합해서 디자인 시스템을 만든 이유는 현재 디자인 시스템의 특성 상 하나의 컴포넌트에 많은 케이스가 나타날 수 있다고 생각했기 때문이다. 물론 구조화를 해서 한 번에 컴포넌트를 만드는 것도 좋은 방법이라고 생각하나 약간의 변형만으로도 디자인 시스템이 만들어 질 수 있는 현 상황에서는 다음과 같이 함수형 체이닝 방법이 낫다고 판단을 했다. 만약 디자인 시스템이 안정화가 되고 케이스가 더욱 명확해 진다면 구조화를 통해 컴포넌트를 불러오는 것도 좋은 방법이라고 생각이 들었다.