본문 바로가기

iOS/니카내카

[니카내카] 디자인시스템 (UIComponent 편)

728x90

Toss Design System을 이을 라이징스타 ... Nicarnaecar Design System을 소개합니다 !!!!! (휘휘~

 

 

프로젝트를 본격적으로 시작하기 전에 .. 늘 하는 작업 중 하나인 디자인시스템 만들기 ..

 

왜 만드느냐?!

앱 서비스의 경우 디자인 측에서 일괄적으로 사용하는 UIComponent 들이 있다.

(-> 이러한 요소들을 통해서 사용자는 앱을 사용하면서 일괄적인 무드를 느낄 수 있다.)

 

예를 들면 CTA 버튼, Navigation Bar, TextField 등이 대표적이다.

이를 뷰마다 만들어주는 것은 비효율적인 일이기 때문에 하나로 관리하는 것이 좋다.  (class를 만들어두고 필요한 화면에서 인스턴스를 생성해서 만들어주면 된다.)

 

 

어떻게 만드는데?!

개발자마다 다를 수 있지만 나는 Global 폴더 안에 DesignSystem 폴더를 만들어서 관리한다.

 

 

UIComponent

앱 규모가 그렇게 크지 않아서 많은 Component들이 많지 않지만 NavigationBar와 CTA Button, TextField는 2개 이상의 화면에서 사용하기 때문에 디자인시스템으로 만들었다.

 

 

Custom Navigation Bar 

애플에서 제공하는 기본 네비게이션 바를 사용하지 않고 버튼의 이미지와 타이틀의 색상 및 폰트가 커스텀이 되어 있기 때문에 네비게이션 바에서 사용하는 버튼을 먼저 만들고 > 네비게이션 바를 만들었다.

 

import UIKit

import NiCarNaeCar_Resource

import SnapKit

final class BackButton: UIButton {
                
    // MARK: - Initializer
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        configureUI()
        setLayout()
    }
    
    convenience init(root: UIViewController) {
        self.init()
        setAction(vc: root)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - UI Method
    
    private func configureUI() {
        setImage(R.Image.btnBack, for: .normal)
    }
    
    private func setLayout() {
        self.snp.makeConstraints { make in
            make.width.height.equalTo(Metric.buttonSize)
        }
    }
    
    // MARK: - Custom Method
    
    private func setAction(vc: UIViewController) {
        let backAction = UIAction { action in
            vc.navigationController?.popViewController(animated: true)
        }
        self.addAction(backAction, for: .touchUpInside)
    }
}

 

이렇게 BackButton을 만들고 같은 방식으로 Close 버튼도 만들어준다. (이미지 및 버튼 눌렀을 때 action만 다르게 구현)

 

import UIKit

import NiCarNaeCar_Resource

import SnapKit
import Then

// MARK: - Metric Enum

public enum Metric {
    static let navigationHeight: CGFloat = UIScreen.main.hasNotch ? 44 : 50
    static let titleTop: CGFloat = 11
    static let buttonLeading: CGFloat = 4
    static let buttonTrailing: CGFloat = 9
    static let buttonSize: CGFloat = 44
}

final class NDSNavigationBar: UIView {
    
    // MARK: - PageView Enum
    
    public enum PageView {
        case main
        case detail
        case setting
        
        fileprivate var title: String? {
            switch self {
            case .main:
                return ""
            case .detail:
                return "차량 정보"
            case .setting:
                return "설정"
            }
        }
    }
    
    // MARK: - Properties
    
    private var viewController = UIViewController()
    public var backButton = BackButton()
    private var closeButton = CloseButton()
    
    private var titleLabel = UILabel().then {
        $0.textColor = R.Color.black200
        $0.textAlignment = .center
        $0.font = NiCarNaeCarFont.body2.font
    }
    
    var viewType: PageView = .main {
        didSet {
            titleLabel.text = viewType.title
        }
    }
    
    var backButtonIsHidden: Bool = false {
        didSet {
            backButton.isHidden = backButtonIsHidden
            closeButton.isHidden = !backButtonIsHidden
        }
    }
    
    // MARK: - Initializer
    
    public init(_ viewController: UIViewController) {
        super.init(frame: .zero)
        self.backButton = BackButton(root: viewController)
        self.closeButton = CloseButton(root: viewController)
        configureUI()
        setLayout()
    }
    
    convenience init(_ viewController: UIViewController,
                     view: PageView,
                     isHidden: Bool) {
        self.init(viewController, view: view, isHidden: isHidden)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - UI Method
    
    private func configureUI() {
        backgroundColor = R.Color.white
    }
    
    private func setLayout() {
        addSubviews(backButton, titleLabel, closeButton)
                
        snp.makeConstraints { make in
            make.height.equalTo(Metric.navigationHeight)
        }
        
        backButton.snp.makeConstraints { make in
            make.top.equalToSuperview()
            make.leading.equalToSuperview().inset(Metric.buttonLeading)
            make.width.height.equalTo(Metric.buttonSize)
        }
        
        titleLabel.snp.makeConstraints { make in
            make.bottom.equalToSuperview().inset(9)
            make.centerX.equalToSuperview()
        }
        
        closeButton.snp.makeConstraints { make in
            make.top.equalToSuperview()
            make.trailing.equalToSuperview().inset(Metric.buttonTrailing)
            make.width.height.equalTo(Metric.buttonSize)
        }
    }
    
    // MARK: - Custom Method
    
    private func setBackButton(isHidden: Bool) {
        backButton.isHidden = isHidden
        closeButton.isHidden = !backButton.isHidden
    }
}

네비게이션 바의 전체 코드는 위와 같다.

 

전역적으로 사용할 상수값들에 대해서는 Metric 열거형을 만들어서 관리하고, (혹시 이후에 레이아웃이 수정될 경우 한번에 바꿔주기 용이하다.) 화면 타입에 따라서 네비게이션의 타이틀이 다르므로 PageView 열거형으로 관리한다.

 

그리고 공통적으로 적용될 UI와 레이아웃을 작성한 다음,

해당 앱에서는 back button이 있다면, close button이 없고 back button이 없다면 close button이 없기 때문에 (다른 앱에서는 안그럴 수도 있기 때문에 주의해서 복붙할 것 !!) setBackButton으로 관리한다. 

 

CTA Button

CTA 버튼의 경우 디자인시스템으로 만들어 놓으면 중복되는 코드를 정말 많이 줄일 수 있다.

기본 상태일 때, 눌렀을 때, 비활성화 상태일 때 등을 화면마다 작성할 필요 없이 하나의 파일에서 관리할 수 있기 때문이다.

 

import UIKit

import NiCarNaeCar_Util
import NiCarNaeCar_Resource

final class NDSButton: UIButton {
    
    // MARK: - Property
    
    var text: String? = nil {
        didSet {
            setTitle(text, for: .normal)
        }
    }
    
    var isDisabled: Bool = false {
        didSet {
            self.isEnabled = !isDisabled
            setBackgroundColor()
        }
    }
    
    // MARK: - Initializer
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setTitle()
        setBackgroundColor()
        makeRound()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - InitUI
    
    private func setTitle() {
        titleLabel?.font = NiCarNaeCarFont.body2.font
        setTitleColor(R.Color.white, for: .normal)
        setTitleColor(R.Color.gray100, for: .highlighted)
    }
    
    private func setBackgroundColor() {
        backgroundColor = isDisabled ? R.Color.gray300 : R.Color.black200
    }
}

 

버튼의 상태에 따른 색상을 분기처리해서 관리할 수 있다.

 

 

TextField

사용자에게 입력받는 화면이 많은 앱 서비스의 경우 TextField가 여러번 사용될 것이고 그 때마다 커스텀 코드를 작성할 필요가 없기 때문에 이 역시 디자인시스템으로 만들어놓으면 편리하다.

 

import UIKit

import NiCarNaeCar_Util
import NiCarNaeCar_Resource

import SnapKit
import Then

extension Metric {
    static let textFieldHeight = 36
}

final class NDSTextField: UITextField {
    
    // MARK: - UI Property
    
    private var lineView = UIView().then {
        $0.backgroundColor = R.Color.black200
    }
    
    // MARK: - Property
    
    public var isFocusing: Bool = false {
        didSet { setState() }
    }
    
    public override var placeholder: String? {
        didSet { setPlaceholder() }
    }
    
    // MARK: - Initialize
    
    public init() {
        super.init(frame: .zero)
        setUI()
        setLayout()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - Set UI
    
    private func setUI() {
        tintColor = R.Color.black200
        backgroundColor = R.Color.white
        setupPadding()
        setState()
    }
    
    private func setLayout() {
        addSubviews(lineView)
        
        snp.makeConstraints { make in
            make.height.equalTo(Metric.textFieldHeight)
        }
        
        lineView.snp.makeConstraints { make in
            make.height.equalTo(1)
            make.leading.trailing.bottom.equalToSuperview()
        }
    }
    
    private func setState() {
        borderStyle = .none
        
        if isFocusing {
            layer.borderColor = R.Color.black200.cgColor
        }
    }
    
    private func setPlaceholder() {
        guard let placeholder = placeholder else {
            return
        }

        attributedPlaceholder = NSAttributedString(
            string: placeholder,
            attributes: [.foregroundColor: R.Color.gray200]
        )
    }
    
}

 

앞의 방식과 비슷하게 상태에 따라서 UI를 분기처리 해놓으면 된다.

 

 

어떻게 사용하는데?!

그래서 !! 이걸 어떻게 사용하냐 ?!

 

 

Navigation Bar

private lazy var navigationBar = NDSNavigationBar(self).then {
    $0.viewType = .detail
    $0.backButtonIsHidden = true
}

네비게이션 바가 들어갈 화면에 위와 같이 NDSNavigationBar를 만든다.

 

이 때 주의할 점은 네비게이션 바가 선언될 때 루트 뷰컨을 알고 있어야 화면전환 등을 할 수 있어 이를 파라미터로 받게 되는데, 

그렇기 때문에 lazy로 선언해야 한다. (= 초기화를 늦게 해야한다.)

 

override func setLayout() {
    view.addSubview(navigationBar)
    
    navigationBar.snp.makeConstraints { make in
        make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
    }
}

그리고 top, leading, trailing 레이아웃을 잡아주면 끝 !!

 

 

🤔 높이는 왜 안잡아주는가?

앞에서 디자인시스템을 만들 때 네비게이션 높이를 지정했었다.

        snp.makeConstraints { make in
            make.height.equalTo(Metric.navigationHeight)
        }

그래서 잡아주지 않아도 된다 !!

 

 

Button (CTA Button)

    lazy var startButton = NDSButton().then {
        $0.text = "시작하기"
        $0.isDisabled = false
        $0.addTarget(self, action: #selector(touchUpStartButton), for: .touchUpInside)
    }

같은 방식으로 이렇게 NDSButton 인스턴스를 만들어서 생성하면 되고,

초기 상태를 설정하면 된다.

 

*여기서 lazy로 선언되는 이유는 초기화구문에서 addTarget까지 설정하기 때문이다. 

 

    override func setLayout() {
        addSubviews(startButton)
        
        startButton.snp.makeConstraints { make in
            make.leading.trailing.equalToSuperview().inset(Metric.ctaButtonLeading)
            make.bottom.equalTo(self.safeAreaLayoutGuide).inset(Metric.ctaButtonBottom)
            make.height.equalTo(Metric.ctaButtonHeight)
        }
    }

마찬가지로 레이아웃까지 잡아주면 끝 !!!

 

 

TextField

이것 역시 사용할 곳에서 인스턴스를 만들고 원하는 상태를 지정한 다음 레이아웃을 잡아주면 된다.

 

    var nameTextField = NDSTextField().then {
        $0.placeholder = "니캉내캉"
        $0.isFocusing = true
    }
    
    override func setLayout() {
        addSubviews(nameTextField, nameCountLabel, startButton)
        
        nameTextField.snp.makeConstraints { make in
            make.top.equalTo(self.safeAreaLayoutGuide).inset(152)
            make.leading.trailing.equalTo(self.safeAreaLayoutGuide).inset(Metric.ctaButtonLeading)
        }
    }