이번 포스팅에서는 HeaderView 관리를 위한 리팩토링을 어떻게 했는지 작성해보도록 하겠다
Zatch 프로젝트에서는 아래와 같이 굉장히 많은 HeaderView 디자인이 존재한다.
navigation controller에 속하는 view controller의 경우
위의 조합에 따라 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: "닫기")
처음엔 이 방법밖에 떠오르지 않아서 이렇게 구성을 하고 사용을 했는데, 사용을 하면서 계속 이 방법이 마음에 들지 않았고, 더 좋은 답이 있을 것 같은데.. 라는 생각을 계속 하게 되었다.
가장 큰 문제라고 생각 했던 것은
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 마다 선언 및 관리를 해주자는 생각을 하게 되었다.
먼저 기본으로 사용할 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)
}
}
}
HeaderNavigationTitle은 네이밍에서 알 수 있듯이 타이틀이 들어간 Header를 구성하기 위한 프로토콜이다.
프로토콜 프로퍼티로
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)
}
}
}
위의 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()
}
}
LeftNavigationEtcButtonHeaderView(title: "재치 등록하기", etcButton: Image.exit)
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에 대해서
2가지 방법 중 무엇을 선택해야 하지? 고민이 들었다.
프로토콜을 통해 조합할 수 있도록 HeaderNavigationTitle 등 프로토콜을 만든 것인데,
LeftNavigationHeaderView를 상속받아서 사용하는 것이 맞는 방법인가? 싶었다.
고민해본 결과 1번 방식을 사용하기로 결정했다.
이유는 titleLabel의 속성, 레이아웃을 새로 정의할 필요없이 그대로 사용할 수 있기 때문이다.
위의 질문에 대한 고민을 하면서
상속과 프로토콜 중 사용을 결정하는 기준으로
부모의 프로퍼티 성질까지 모두 동일하게 적용시켜야 할 경우 상속을
프로퍼티 선언만 동일할 경우는 프로토콜을 사용해야 겠다고 기준을 세우게 되었다.
JSON 파일 생성 및 파싱해 프로젝트에서 사용하기 (0) | 2023.04.27 |
---|---|
Tag DesignSystem 리팩토링 과정 (0) | 2023.03.19 |
UITextField에 값 할당한 경우 이벤트 감지시키기 (0) | 2023.01.08 |
UITableView 상단 space 제거 (0) | 2023.01.03 |
View의 tag 프로퍼티 활용하기 (0) | 2023.01.01 |