상세 컨텐츠

본문 제목

Tag DesignSystem 리팩토링 과정

iOS

by 쑤야. 2023. 3. 19. 00:26

본문

재치 프로젝트에서는 많은 태그 디자인들이 존재한다.

 

현재 Tag 디자인 시스템은 Tag라는 부모 클래스가 존재하며, 자식 클래스를 색상으로 분리해 구현했다.

extension ZatchComponent{
    
    enum TagColor{
        case purple
        case yellow
    }

    enum TagConfiguration{
        case height20
        case height25
        case height29
        case height31
    }
}
class Tag: PaddingLabel{
    
    var isDisabled = false{
        didSet{
            isDisabled ? setDisabledState() : setNormalState()
        }
    }
    var isSelected = false{
        didSet{
            isSelected ? setSelectState() : setNormalState()
        }
    }
    
    private let configuration: TagType
    private let colorType: TagColor
    
    init(color: TagColor, configuration: TagType){
        self.colorType = color
        self.configuration = configuration
        super.init(frame: .zero)
				...
    }
    
    private func style(){
        self.layer.cornerRadius = configuration.height / 2
        self.clipsToBounds = true
        self.setTypoStyleWithSingleLine(typoStyle: configuration.font)
    }
    
    private func setNormalState(){
        self.tag = ViewTag.normal
        self.textColor = colorType.textColor
        self.backgroundColor = colorType.backgroundColor
    }
    
    private func setDisabledState(){
        self.tag = ViewTag.deselect
        self.textColor = colorType.disabledTextColor
        self.backgroundColor = colorType.disabledBackgroundColor
    }
    
    private func setSelectState(){
        self.tag = ViewTag.select
        self.textColor = colorType.selectedTextColor
        self.backgroundColor = colorType.selectedBackgroundColor
    }
}
class PurlpleTag: Tag{
    
    init(configuration: ZatchComponent.Tag.TagType){
        super.init(color: .purple, configuration: configuration)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
class YellowTag: Tag{
    
    init(configuration: ZatchComponent.Tag.TagType){
        super.init(color: .yellow, configuration: configuration)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

 

위의 설계를 통해 Tag 디자인 시스템을 사용할 때는 아래와 같이 선언해 줬었다.

let tagLabel = ZatchComponent.YellowTag(configuration: .height31)

 

이렇게 설계해 놓은 Tag에 대해서 다시 설계해야겠다고 생각한 이유는 다음과 같다

 

  1. Tag 하나의 역할이 많아지다 보니 클래스의 응집도가 낮아지는 문제점 발생
  2. stroke 스타일을 Tag로 구현하기에는 프로퍼티 네이밍 등 적합하지 않음

 

새롭게 설계할 때는 어떻게 클래스를 나눌지, 프로토콜을 사용할지 아니면 클래스 상속을 사용할지 고민을 시작했다.

 

먼저 프로젝트에서 사용되는 모든 태그들을 모아서 케이스를 나누어 보았다.

 

 

 

    1. stroke
    2. filledstroke 스타일과 filled 스타일 존재
    1. 40 / 100 케이스 > isSelected로 구분
    2. 회색 / 컬러 케이스 > isDisabled로 구분선택 여부에 따라 색상이 다름

일단 stroke와 filled 스타일은 무조건 클래스를 구분해야겠다고 생각했다.

stroke는 색상 지정 프로퍼티가 strokeColor가 되어야 하고, filled는 backgroundColor가 되어야 한다고 생각했기 때문이다.

 



이제 새롭게 설계한 Tag와 하위 클래스에 대해서 설명해 보도록 하겠다.

먼저 구조를 살펴보면

  1. Tag라는 부모 클래스
  2. Tageable이라는 select 관련 프로퍼티/메서드를 가지고 있는 프로토콜
  3. Tag에게 공통 기능을 물려받고 개별적인 기능을 구현할 하위 클래스인 FilledTag와 StrokeTag

Tag
┣ FilledTag + Tagable
┣ StrokeTag + Tagable


Tag에서 하위 클래스의 인스턴스를 생성해 주는 정적 메서드도 추가하였다.

static func filled(color: ZatchComponent.TagColor, configuration: ZatchComponent.TagConfiguration) -> FilledTag{
    return FilledTag(color: color, configuration: configuration)
}

static func stroke(color: ZatchComponent.TagColor, configuration: ZatchComponent.TagConfiguration) -> StrokeTag{
    return StrokeTag(color: color, configuration: configuration)
}


여기서 그냥 FilledTag(color: , configuration)을 사용하지, 왜 정적 메서드를 선언해 인스턴스를 생성하고 반환하는 거지라고 의문을 가질 수도 있을 것이다.

프로젝트를 한 2주(1주일 수도)만 안 해도 Tag 하위로 어떤 클래스가 있는지 기억을 못 할 것이다. (실제 경험담…ㅋㅋ)


Tag라는 디자인 시스템이 있다는 것은 알더라도 borderLine 속성을 가진 Tag 하위 클래스 이름이 StrokeFilled인 것을 모를 가능성이 높다.


이를 위해서 정적 메서드를 활용하는 것이다.

일단 Tag에 접근하면 정적 메서드 리스트를 생성해 줄 것이니 그 안에서 원하는 타입을 고르면 되는 것이다.


사실 아래 코드와 같은 케이스는 정적 메서드 구현하는 것을 망설이지 않을 텐데, 위의 경우는 조금 고민을 하긴 했었다.

static func generateMakeSufficientSpaceMessage() -> ToastMessageView{
    return ToastMessageView(image: Image.toastSufficientSpace, message: "스크롤을 올리면 이모지를 남길 수 있어요!")
}

정적 메서드의 매개변수와 생성자의 매개변수가 완전히 일치하기 때문..

하지만 난 나의 기억력을 믿지 않기 때문에.. 메서드를 추가하기로 결정했다

Tag 클래스는 Stroke, Filled 케이스 모두가 공통으로 사용하는 프로퍼티/메서드만 넣었다.

(응집도를 높여야..)

공통으로 사용되는 부분들이 꽤 있기 때문에 프로토콜을 통해 역할 확장만 시키는 것보다는 직접 상속시켜 주는 것이 낫다고 판단했다.

extension ZatchComponent{
    
    class Tag: PaddingLabel{
        
        private let configuration: ZatchComponent.TagConfiguration
        let color: ZatchComponent.TagColor
        
        init(color: ZatchComponent.TagColor, configuration: ZatchComponent.TagConfiguration){
            self.color = color
            self.configuration = configuration
            super.init(padding: configuration.padding)
            initialize()
        }
        
        static func filled(color: ZatchComponent.TagColor, configuration: ZatchComponent.TagConfiguration) -> FilledTag{
            return FilledTag(color: color, configuration: configuration)
        }
        
        static func stroke(color: ZatchComponent.TagColor, configuration: ZatchComponent.TagConfiguration) -> StrokeTag{
            return StrokeTag(color: color, configuration: configuration)
        }
        
        func initialize(){
            style()
            layout()
        }
        
        private func style(){
            layer.cornerRadius = configuration.height / 2
            clipsToBounds = true
            setTypoStyleWithSingleLine(typoStyle: configuration.font)
        }
        
        private func layout(){
            self.snp.makeConstraints{
                $0.height.equalTo(configuration.height)
            }
        }
        
        func setTitle(_ title: String){
            text = title
        }
        
        func setCategoryTitle(categoryId: Int){
            let category = ServiceType.Zatch.getCategoryFromCategories(at: categoryId)
            text = category.title
        }
    }
}


다음은 Tagable 프로토콜이다.

사실 네이밍이 마음에 들지 않는다. 하지만 아직 프로토콜 네이밍을 어떻게 지어야 할지 감도 안 오고 도저히 모르겠어서.. 일단은 이렇게

isSelected 프로퍼티를 통해 select 상태를 표현할 것이다.

여담이지만 프로토콜은.. private이 불가능한 게 너무 아쉽다..

protocol Tagable{
    var isSelected: Bool { get set }
    func setSelectState()
    func setUnselectState()
}


FilledTag의 경우

  1. 회색 / 색상
  2. 연한 색상 / 진한 색상

2가지를 모두 표현해야 하므로 Tagable을 통해 isSelected 프로퍼티 설정하는 것과 더불어 isDisabled 프로퍼티와 setDisabledState() 메서드도 추가하였다.

//  FilledTag.swift
extension ZatchComponent{
    
    class FilledTag: ZatchComponent.Tag, Tagable{
        
        var isDisabled = false{
            didSet{
                isDisabled ? setDisabledState() : setUnselectState()
            }
        }
        var isSelected = false{
            didSet{
                isSelected ? setSelectState() : setUnselectState()
            }
        }
        
        override func initialize() {
            super.initialize()
            setUnselectState()
        }
        
        func setSelectState(){
            tag = ViewTag.select
            textColor = color.selectedTextColor
            backgroundColor = color.selectedBackgroundColor
        }
        
        func setUnselectState(){
            tag = ViewTag.normal
            textColor = color.textColor
            backgroundColor = color.backgroundColor
        }
        
        private func setDisabledState(){
            tag = ViewTag.deselect
            textColor = color.disabledTextColor
            backgroundColor = color.disabledBackgroundColor
        }
    }
}
//  StrokeTag.swift
extension ZatchComponent{
    
    class StrokeTag: ZatchComponent.Tag, Tagable{
        
        var isSelected = true{
            didSet{
                isSelected ? setSelectState() : setUnselectState()
            }
        }
        
        override func initialize() {
            super.initialize()
            setSelectState()
        }
        
        func setSelectState() {
            layer.borderWidth = 1.5
            layer.borderColor = color.selectedColor.cgColor
            textColor = color.selectedColor
        }
        
        func setUnselectState() {
            layer.borderWidth = 1
            layer.borderColor = color.unselectedColor.cgColor
            textColor = color.unselectedColor
        }
    }
}

 


 

여기서 이번 리팩토링을 하면서 가장 큰 수확이라고 생각한 fileprivate 접근제어 활용 방안을 소개하겠다.

아까 언급했다시피 TagColor는 2가지 케이스가 존재한다.

enum TagColor{
    case purple
    case yellow
}

이 열거형은 Filled 타입, Stroke 타입 구분 없이 동일하다.

하지만 TagColor extension으로 TagColorStyle 구조체를 선언하고, 이 안에 선언하는 프로퍼티 종류는 달라야 했다.

앞에서 언급했다 싶이 stroke 타입은 색상을 지정할 때 filled 케이스와 다른 네이밍의 프로퍼티를 사용해야 한다고 판단했기 때문이다.

처음에는 where 절을 통해 사용되는 클래스에 따라 TagColorStyle를 다르게 구현할 생각이었다.

하지만 아쉽게도 TagColor는 protocol이 아닌 enum형이기 때문에 이 방법은 불가능했다.

그래서 다른 방법은 뭐가 있지 고민해 보았는데fileprivate라는 접근제어가 생각이 났다.

해당 파일 내에서만 사용이 가능한 것이니까 같은 파일 안에 넣어준다면 가능하지 않을까? 싶었던 것이다.

 

결과는 성공이었다.

//  FilledTag.swift
fileprivate extension ZatchComponent.TagColor{
    
    struct TagColorStyle{
        let textColor: UIColor
        let backgroundColor: UIColor
        let selectedTextColor: UIColor = .white
        let selectedBackgroundColor: UIColor
        let disabledTextColor: UIColor
        let disabledBackgroundColor: UIColor
    }
    
    private var colorInfo: TagColorStyle{
        switch self{
        case .purple:
            return TagColorStyle(textColor: .zatchPurple,
                                 backgroundColor: .purple40,
                                 selectedBackgroundColor: .zatchPurple,
                                 disabledTextColor: .black20,
                                 disabledBackgroundColor: .black10)
        case .yellow:
            return TagColorStyle(textColor: .zatchDeepYellow,
                                 backgroundColor: .yellow40,
                                 selectedBackgroundColor: .zatchDeepYellow,
                                 disabledTextColor: .black20,
                                 disabledBackgroundColor: .black10)
        }
    }
    
    var textColor: UIColor{
        colorInfo.textColor
    }
    
    var backgroundColor: UIColor{
        colorInfo.backgroundColor
    }
    
    var selectedTextColor: UIColor{
        colorInfo.selectedTextColor
    }
    
    var selectedBackgroundColor: UIColor{
        colorInfo.selectedBackgroundColor
    }
    
    var disabledTextColor: UIColor{
        colorInfo.disabledTextColor
    }
    
    var disabledBackgroundColor: UIColor{
        colorInfo.disabledBackgroundColor
    }
}
//  StrokeTag.swift
fileprivate extension ZatchComponent.TagColor{
    
    struct TagColorStyle {
        let backgroundColor: UIColor = .white
        let unselectStateColor: UIColor = .black45
        let selectedStateColor: UIColor
    }
    
    private var colorInfo: TagColorStyle{
        switch self{
        case .purple:
            return TagColorStyle(selectedStateColor: .zatchPurple)
        case .yellow:
            return TagColorStyle(selectedStateColor: .zatchYellow)
        }
    }
    
    var backgroundColor: UIColor{
        colorInfo.backgroundColor
    }
    
    var unselectedColor: UIColor{
        colorInfo.unselectStateColor
    }
    
    var selectedColor: UIColor{
        colorInfo.selectedStateColor
    }
}

 


새롭게 설계한 Tag는 아래와 같이 선언해 사용하고 있다.

Tag를 접근하기만 하면 filled와 stroke라는 정적 메서드가 등장하기 때문에 오랜만에 프로젝트를 진행하더라도 어떤 하위 클래스가 있는지 찾아볼 필요 없이 접근할 수 있을 것이다!

let filledTag = ZatchComponent.Tag.filled(color: .yellow, configuration: .height31)
let strokeTag = ZatchComponent.Tag.stroke(color: .yellow, configuration: .height31)


이번 리팩토링 진행하면서 얻은 것은

  1. 정적 팩토리 메서드는 클래스에 대한 서술이 가능하다
  2. fileprivate 접근 제어를 통해 각각 다른 extension 적용이 가능하다.

관련글 더보기