상세 컨텐츠

본문 제목

Protocol과 상속 관계를 활용해 HeaderView 구성하기

iOS

by 쑤야. 2023. 2. 5. 12:30

본문

이번 포스팅에서는 HeaderView 관리를 위한 리팩토링을 어떻게 했는지 작성해보도록 하겠다

 

1. 리팩토링의 이유

 

Zatch 프로젝트에서는 아래와 같이 굉장히 많은 HeaderView 디자인이 존재한다.

 

 

navigation controller에 속하는 view controller의 경우

 

  1. back button
  2. 왼쪽에 navigation title이 위치한 경우
  3. 가운데에 navigation title이 위치한 경우
  4. 오른쪽 기타 버튼 1개인 경우
  5. 오른쪽 기타 버튼 2개인 경우

 

위의 조합에 따라 header view가 구성되게 된다.


 

처음 프로젝트를 진행했을 때는 ViewController와 View를 분리하지 않았었다.

프로젝트를 진행하다 보니 ViewController가 너무 많은 역할을 수행하고, UI가 복잡하다보니 UI 코드 자체만으로도 너무 길어져서 분리해야 겠다는 생각을 하게 되었다.

 

ViewController와 View를 한꺼번에 사용했을 때 오른쪽 기타 버튼의 경우 아래 메서드를 통해 ViewController에서 설정했다.

func setEtcButton(title: String){
    
    self.etcButton.setTitle(title, for: .normal)
    
    self.navigationView.addSubview(etcButton)
    
    etcButton.snp.makeConstraints{
        $0.top.equalToSuperview().offset(14)
        $0.trailing.equalToSuperview().offset(-20)
        $0.centerY.equalToSuperview()
    }
}
self.setEtcButton(title: "닫기")

 

처음엔 이 방법밖에 떠오르지 않아서 이렇게 구성을 하고 사용을 했는데, 사용을 하면서 계속 이 방법이 마음에 들지 않았고, 더 좋은 답이 있을 것 같은데.. 라는 생각을 계속 하게 되었다.

 

가장 큰 문제라고 생각 했던 것은

 

  1. HeaderView에서 navigation title이나 기타 버튼을 사용하지 않더라도 위의 프로퍼티를 ViewController가 상속 받아 가지고 있게 된다는 점
  2. HeaderView 커스텀 불가능

 

2가지 였다.

 

1번의 경우에 대해 먼저 설명하도록 하겠다.

 

 

위의 헤더는 back button만 존재하는 HeaderView이다.

 

이를 구성하기 위해서도 아래 코드를 보면 바로 알 수 있듯이 ViewController에서는 불필요한 titleLabel과 etcButton을 상속받게 되는 것이다.

class BaseViewController: UIViewController {
    
    let navigationView = UIView()

    lazy var backBtn = UIButton().then{
        $0.setImage(Image.backArrow, for: .normal)
        $0.addTarget(self, action: #selector(backBtnDidClicked), for: .touchUpInside)
    }

    lazy var titleLabel = UILabel().then{
        $0.font = UIFont.autoPretendard(type: .m_14)
        $0.textColor = Color.title
    }

    lazy var etcButton = UIButton().then{
        $0.titleLabel?.font = UIFont.autoPretendard(type: .m_14)
        $0.setTitleColor(Color.title, for: .normal)
    }

		...
    
    func setNavigationTitleLabel(title: String){

        self.titleLabel.text = title
        
        self.navigationView.addSubview(titleLabel)
        
        titleLabel.snp.makeConstraints{
            $0.top.equalToSuperview().offset(14)
            $0.centerY.centerX.equalToSuperview()
        }
    }
    
    func setEtcButton(title: String){
        
        self.etcButton.setTitle(title, for: .normal)
        
        self.navigationView.addSubview(etcButton)
        
        etcButton.snp.makeConstraints{
            $0.top.equalToSuperview().offset(14)
            $0.trailing.equalToSuperview().offset(-20)
            $0.centerY.equalToSuperview()
        }
    }
}

 

2번의 경우에 대해서 설명해보도록 하겠다.

 

ViewController와 View 분리 작업을 하다보니

 

 

위의 케이스 같이 Header에 버튼 하나만 들어가면 위의 방법 그대로 사용해도 문제가 없었지만,

 

 

진짜 문제는 위의 경우였다.

위의 경우는 채팅방의 Header이다.

 

앞에서 말했다 싶이 현재 코드로는 Header의 기본 구성을 ViewController에서 하고 있다.

하지만 나는 지금 리팩토링을 통해 ViewController와 View를 분리하고 있다.

버튼 하나만 들어간 다른 경우와 달리 채팅방과 같은 Header의 경우 버튼 하나로 해결되는 것이 아니고 추가로 커스텀이 필요한 것이다.

하지만 내가 분리하고 있는 View 부분은 Header 아래 부분의 UI만 구현한 것으로, View로는 Header를 커스텀할 수 없다는 것이었다.

 

어떻게 Header 부분을 관리하지..? 고민하다가 들었던 생각은

View와 동일하게 HeaderView를 따로 만들어 ViewController 마다 선언 및 관리를 해주자는 생각을 하게 되었다.


2. Refactoring

 

1. BaseHeaderView

 

먼저 기본으로 사용할 BaseHeaderView라는 클래스를 생성했다.

BaseHeaderView에서는 위의 사진과 같이 view controller를 pop 시킬 back button만 설정해놓았다.

class BaseHeaderView: BaseView{
    
    lazy var backButton = UIButton().then{
        $0.setImage(Image.arrowLeft, for: .normal)
    }
    
    override func hierarchy() {
        self.addSubview(backButton)
    }
    
    override func layout() {
        
        self.snp.makeConstraints{
            $0.height.equalTo(60)
        }
        
        backButton.snp.makeConstraints{
            $0.leading.equalToSuperview().offset(20)
            $0.top.equalToSuperview().offset(14)
            $0.centerY.equalToSuperview()
            $0.width.equalTo(backButton.snp.height)
        }
    }
}

2. HeaderNavigationTitle 프로토콜 통한 CenterNavigationHeaderView, LeftNavigationHeaderView 생성

 

 

HeaderNavigationTitle은 네이밍에서 알 수 있듯이 타이틀이 들어간 Header를 구성하기 위한 프로토콜이다.

 

프로토콜 프로퍼티로

 

  1. title 설정을 위해 생성자 인자로 받아올 title
  2. titleLabel 역할을 할 UILabel

 

2가지를 선언했다.

 

그리고 extension을 통해 초기 구현으로 setNavigationTitleLabelAttribute이란 메서드를 지정했다.

setNavigationTitleLabelAttribute은 navigation의 타이틀을 설정해주며, 종류에 따라 폰트가 다를 수 있기 때문에, typoStyle을 매개변수로 받을 수 있도록 구현했다.

protocol HeaderNavigationTitle{
    var title: String { get }
    var navigationTitleLabel: UILabel { get }
    init(title: String)
}

extension HeaderNavigationTitle{
    func setNavigationTitleLabelAttribute(typo: TypoStyle){
        navigationTitleLabel.text = title
        navigationTitleLabel.setTypoStyleWithSingleLine(typoStyle: typo)
    }
}

 

이렇게 만든 프로토콜을 통해 타이틀이 있는 HeaderView를 재사용할 수 있도록 CenterNavigationHeaderView, LeftNavigationHeaderView 클래스를 만들었다.

class CenterNavigationHeaderView: BaseHeaderView, HeaderNavigationTitle{
    
    let title: String
    let navigationTitleLabel = UILabel()
    
    required init(title: String){
        self.title = title
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func style() {
        setNavigationTitleLabelAttribute(typo: .bold20)
    }
    
    override func hierarchy() {
        super.hierarchy()
        self.addSubview(navigationTitleLabel)
    }
    
    override func layout() {
        super.layout()
        navigationTitleLabel.snp.makeConstraints{
            $0.centerY.centerX.equalToSuperview()
        }
    }
}
class LeftNavigationHeaderView: BaseHeaderView, HeaderNavigationTitle{
    
    let title: String
    let navigationTitleLabel = UILabel()

    required init(title: String){
        self.title = title
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func style() {
        setNavigationTitleLabelAttribute(typo: .bold20)
    }
    
    override func hierarchy() {
        super.hierarchy()
        self.addSubview(navigationTitleLabel)
    }
    
    override func layout() {
        super.layout()
        navigationTitleLabel.snp.makeConstraints{
            $0.centerY.equalToSuperview()
            $0.leading.equalTo(self.backButton.snp.trailing).offset(8)
        }
    }
}

3. HeaderFirstEtcButton, HeaderSecondEtcButton 프로토콜 생성

 

 

위의 HeaderNavigationTitle 프로토콜과 유사하다.

 

다른 점이 있다면, 기타 버튼의 위치가 항상 동일하기 때문에 초기 구현을 통해 미리 레이아웃 등을 설정해놓았다.

이때 where 절을 통해 BaseView를 상속받은 클래스에서만 setEtcButtonLayout 메서드를 사용할 수 있게 하여 view 계층 추가 코드도 추가할 수 있었다.

protocol HeaderFirstEtcButton{
    var etcButton: EtcButton { get }
}

extension HeaderFirstEtcButton where Self: BaseView{
    func setEtcButtonLayout(){
        self.addSubview(etcButton)
        etcButton.snp.makeConstraints{
            $0.trailing.equalToSuperview().offset(-20)
            $0.top.equalToSuperview().offset(14)
            $0.centerY.equalToSuperview()
            $0.width.equalTo(etcButton.snp.height)
        }
    }
}
protocol HeaderSecondEtcButton{
    var secondEtcButton: EtcButton { get }
}

extension HeaderSecondEtcButton where Self: HeaderFirstEtcButton, Self: BaseView{
    func setSecondEtcButtonLayout(){
        self.addSubview(secondEtcButton)
        secondEtcButton.snp.makeConstraints{
            $0.trailing.equalTo(etcButton.snp.leading).offset(-8)
            $0.top.width.height.equalTo(etcButton)
        }
    }
}

 

이렇게 설정해놓은 프로토콜을 가지고 아래와 같이

원하는 프로토콜 조합으로 코드의 중복을 줄이면서 재사용할 수 있는 HeaderView 클래스를 생성하였다.

class EtcButtonHeaderView: BaseHeaderView, HeaderFirstEtcButton{
    
    var etcButton: EtcButton
    
    init(image: UIImage){
        self.etcButton = EtcButton(image: image)
        super.init(frame: .zero)
    }
    
    
    init(title: String){
        self.etcButton = EtcButton(title: title)
        super.init(frame: .zero)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func layout() {
        super.layout()
        setEtcButtonLayout()
    }
}
class LeftNavigationEtcButtonHeaderView: LeftNavigationHeaderView, HeaderFirstEtcButton{
    
    var etcButton: EtcButton
    
    init(title: String, etcButton text: String){
        self.etcButton = EtcButton(title: text)
        super.init(title: title)
    }
    
    init(title: String, etcButton image: UIImage){
        self.etcButton = EtcButton(image: image)
        super.init(title: title)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    required init(title: String) {
        fatalError("init(title:) has not been implemented")
    }
    
    override func layout() {
        super.layout()
        setEtcButtonLayout()
    }
    
}

3. Result

 

1. 추가 커스텀 없는 경우

 

 

LeftNavigationEtcButtonHeaderView(title: "재치 등록하기", etcButton: Image.exit)

 

2. 추가 커스텀이 필요한 경우

 

 

class ChattingRoomHeaderView: EtcButtonHeaderView{
    
    var opponentNameLabel = UILabel().then{
        $0.text = "쑤야"
    }
    
    let townLabel = UILabel().then{
        $0.text = "중계동"
    }
    
    let reservationFinishTag = UILabel().then{
        $0.text = "예약완료"
    }
    
    init(){
        super.init(image: Image.dot)
    }

		.
		.
		.
}

 

이번 리팩토링을 하면서 고민이 들었던 부분이 있다.

 

언제 상속을 사용하고 언제 프로토콜을 사용하지?

 

LeftNavigationEtcButtonHeaderView에 대해서

 

  1. LeftNavigationHeaderView 클래스를 상속 + HeaderFirstEtcButton 프로토콜 채택
  2. HeaderNavigationTitle 프로토콜과 HeaderFirstEtcButton 프로토콜 채택

 

2가지 방법 중 무엇을 선택해야 하지? 고민이 들었다.

 

프로토콜을 통해 조합할 수 있도록 HeaderNavigationTitle 등 프로토콜을 만든 것인데,

LeftNavigationHeaderView를 상속받아서 사용하는 것이 맞는 방법인가? 싶었다.

 

고민해본 결과 1번 방식을 사용하기로 결정했다.

이유는 titleLabel의 속성, 레이아웃을 새로 정의할 필요없이 그대로 사용할 수 있기 때문이다.

 

위의 질문에 대한 고민을 하면서

상속과 프로토콜 중 사용을 결정하는 기준으로

 

부모의 프로퍼티 성질까지 모두 동일하게 적용시켜야 할 경우 상속을

프로퍼티 선언만 동일할 경우는 프로토콜을 사용해야 겠다고 기준을 세우게 되었다.

관련글 더보기