[UIKit] 재사용 Component 개발하기 (2) - 팝업창 (Alerts)

2024. 2. 24. 14:16UIKit, SwiftUI, H.I.G

 

[UIKit] 재사용 Component 개발하기 (1) - Toast Message

💡 재사용 Component 개발하기 시리즈 글을 시작하며 이번 33기 앱잼이었던 프로젝트에서 우리 iOS 팀이 추구한 방향 중 하나는, 앱 내에서 반복되는 컴포넌트(내비게이션바, 바텀시트, 알림 창, 토

mini-min-dev.tistory.com

 

1️⃣ HIG 살펴보기 : 팝업창(Alerts)이란?


보통 팝업창으로 알고 있는 알림창을 iOS 공식문서에서는 정확하게 Alerts라고 부른다.

Alerts는 사용자에게 "중요한 문제를 알리는 용도"로 사용한다.
여기서 말하는 "중요한 문제"란 해당 작업으로 데이터가 파괴될 수 있는 경우, 새롭게 시작하는 혹은 앱 안에서 발생한 어떤 중요한 작업에 대해 사용자에게 알리기 위한 경우를 의미한다고 볼 수 있다.


H.I.G 관점에서 Alerts는 3가지 관점에서(사용, 콘텐츠, 버튼) 설명을 하고 있다.
몇 가지 핵심 내용을 위주로 정리해보겠다.

1. 사용 관점에서의 Alerts
- 자주 사용하지 말 것 : 중요한 정보를 제공하는 작업이지만, 사용자의 진행 중인 경험을 방해하는 행위에 해당하기 때문이다.
특히, 단순 정보를 제공하는 용도이거나 선택사항을 제공하는 용도에는 다른 대체 방법 (액션 시트)을 사용하도록 권하고 있다.
- 파괴적인 작업의 경우에도 실행을 취소할 수 있는 일반적인 상황에서는 사용하지 말 것 : 예시로 들고 있는 것이 이메일이나 문자의 삭제 내용이다. 매번 이메일을 삭제할 때마다 알림창을 표출시킨다면, 사용자에게 좋지 않은 경험을 제공해 줄 것이기 때문이다. 뿐만 아니라, 해당 삭제 작업은 복구할 수 있는 대안이 있기에 사용자에게 Alerts를 표출할 만큼 "중요한 문제"로 판달할 수 없다는 것이 요지이다.

2. 콘텐츠 관점에서의 Alerts
- 명확하고 간결한 텍스트를 사용할 것 : 모호하거나, 해당 작업에 대해 사용자에게 비난하는 텍스트를 사용해서는 안된다고 명시하고 있다. 또한, "오류"와 같은 추상적인 타이틀 대신 "네트워크 연결 오류"와 같이 명확하게 이유를 알 수 있는 타이틀을 사용해야 한다. + 타이틀은 두 줄 이상 길어지면 안 되며, 마침표를 사용하는 것도 지양하라고 명시되어 있다.

3. 버튼 관점에서의 Alerts
- 간결하고 명확한 버튼 이름을 사용할 것 : 타이틀, 메시지와 마찬가지로 각 버튼의 액션이 어떤 의도를 가진 작업인지 명시해야 한다.
- 데이터를 지우는 작업을 유도하는 버튼은 .destructive 스타일을 사용할 것 : 삭제와 같은 파괴적인 작업은 AlertAction의 .destructive와 같이 구분되는 색상으로 사용자에게 알려줄 필요가 있다고 말한다. 
- 데이터를 지우는 작업을 사용하는 Alerts는 반드시 그 대안도 함께 제공할 것 : 삭제 버튼을 사용하는 Alerts의 경우에는 "취소"나 "돌아가기"와 같은 대안 버튼도 함께 제공해야 한다는 것이다. 해당 버튼은 파괴 작업을 담당하는 버튼과 구분되는 스타일을 가져야 한다고 한다. 

그럼 Apple이 제공해주는 HIG(Human Interface Guideline)에 맞추어
본격적으로 앱에서 사용된 Alerts Components를 재사용할 수 있도록 만드는 과정에 대해 설명해보겠다.

 

2️⃣ 공통적으로 반복되는 요소와 변하는 요소를 구분하자!


지난번 토스트 메시지 때와 마찬가지로 앱 내에서 사용하는 알럿창을 디자이너 선생님들이 모두 정리해주셨다.

타이틀, 메시지의 정렬이 왼쪽 정렬로 되어있고, 글씨체는 모두 커스텀 폰트가 적용, 버튼의 색상이나 모서리 둥글기까지 모두 Apple에서 기본 제공해주는 UIAlertController를 사용하기엔 어려움이 있어보여 처음부터 UIViewController로 만들게 되었다.

토스터 앱에서 사용하는 모든 알림창 모음

메인 타이틀, 메시지, 버튼이 있는 구조 배경색, 폰트, 모서리 둥글기까지 모두 반복되는 모습이었기에 
*단, 타이틀과 메시지의 경우에는 지금 나온 스타일 가이드 상에는 없었지만, 추후에 사용되지 않는 Alerts창이 나올 수 있다고 판단되어 해당 내용(타이틀, 메시지) 요소의 추가를 필수로 구현하지는 않았다.

유일하게 차이나는 것이 버튼의 개수였기 때문에 해당 부분만 enum으로 Type를 나누고 사용도에 따라 채택해서 사용하도록 했다.

enum ToasterPopupType {
    case Confirmation // 가운데 버튼만 존재
    case Cancelable   // 좌,우 버튼 존재
}

그 안에 들어가는 내용과 버튼의 타이틀, 그리고 Handler 액션을 아래의 두 개 생성자로 나누어서 받을 수 있도록 만들었다.

    // MARK: - Life Cycle
    
    init(mainText: String?,
         subText: String?,
         leftButtonTitle: String,
         rightButtonTitle: String,
         leftButtonHandler: ButtonAction?,
         rightButtonHandler: ButtonAction?) {
        
        self.mainText = mainText
        self.subText = subText
        self.leftButtonTitle = leftButtonTitle
        self.rightButtonTitle = rightButtonTitle
        self.leftButtonHandler = leftButtonHandler
        self.rightButtonHandler = rightButtonHandler
        
        if centerButtonTitle.isEmpty {
            self.popupType = .Cancelable
        } else {
            self.popupType = .Confirmation
        }
        
        super.init(nibName: nil, bundle: nil)
    }
    
    /// 확인 ( 에러 ) 을 위한 Popup init
    init(mainText: String?,
         subText: String?,
         centerButtonTitle: String,
         centerButtonHandler: ButtonAction?) {
        
        self.mainText = mainText
        self.subText = subText
        self.leftButtonTitle = ""
        self.rightButtonTitle = ""
        self.centerButtonTitle = centerButtonTitle
        self.leftButtonHandler = nil
        self.rightButtonHandler = nil
        self.centerButtonHandler = centerButtonHandler
        
        if centerButtonTitle.isEmpty {
            self.popupType = .Cancelable
        } else {
            self.popupType = .Confirmation
        }
    
        super.init(nibName: nil, bundle: nil)
    }

이제 유동적으로 대응할 차례이다.

텍스트에서 받는 내용 (메인, 하위)과 버튼에 따른 타입(Confirmation, Cancelable)에 따른 크기를 조정하기 위해 레이아웃은 한 StackView 안에 2가지 StackView가 다시 들어가있는 형태로 구성했다.
즉, 각 스택뷰(라벨 스택, 버튼 스택)는 해당 내용이 있느냐 없느냐에 따라서 유동적으로 대응할 수 있게 되는 것이다.

func setupHierarchy() {
    // 기본적으로 들어있는 레이아웃
    view.addSubview(popupStackView)
    popupStackView.addArrangedSubviews(labelStackView,
                                           buttonStackView)
                                           
    // 텍스트에 따라서 라벨 스택뷰에 들어가는 내용이 변화
    if mainText != nil {
        labelStackView.addArrangedSubview(mainLabel)
    }
    if subText != nil {
        labelStackView.addArrangedSubview(subLabel)
    }
        
    // 팝업 타입에 따라서 버튼 스택뷰에 들어가는 내용이 변화
    if popupType == .Cancelable {
        buttonStackView.addArrangedSubviews(leftButton,
                                            rightButton)
    } else {
        buttonStackView.addArrangedSubviews(centerButton)
    }
}

 

3️⃣ Extension으로 사용할 수 있도록 만들자!


지난 토스트 메시지 때도 UIViewController의 Extension 함수로 만들어 사용하기 쉽게 만들었던 것만큼, 이번 팝업창도 Extension 함수로 만들어서 재사용하기 쉽게 만들었다.

타입을 파라미터로 받아서 처리할 수 있었지만, 이번 코드에서는 버튼 2개 팝업과 1개 있는 팝업을 구분해서 메서드를 구성했다.

/// 팝업 표출할 수 있도록 하는 메서드
func showPopup(forMainText: String? = nil,
               forSubText: String? = nil,
               forLeftButtonTitle: String,
               forRightButtonTitle: String,
               forLeftButtonHandler: (() -> Void)? = nil,
               forRightButtonHandler: (() -> Void)? = nil) {
    
    let popupViewController = ToasterPopupViewController(mainText: forMainText,
                                                         subText: forSubText,
                                                         leftButtonTitle: forLeftButtonTitle,
                                                         rightButtonTitle: forRightButtonTitle,
                                                         leftButtonHandler: forLeftButtonHandler,
                                                         rightButtonHandler: forRightButtonHandler)
    
    popupViewController.modalPresentationStyle = .overFullScreen
    present(popupViewController, animated: false)
}

/// 팝업 표출할 수 있도록 하는 메서드
func showConfirmationPopup(forMainText: String? = nil,
                           forSubText: String? = nil,
                           centerButtonTitle: String,
                           centerButtonHandler: (() -> Void)? = nil) {
    
    let popupViewController = ToasterPopupViewController(mainText: forMainText,
                                                         subText: forSubText,
                                                         centerButtonTitle: centerButtonTitle,
                                                         centerButtonHandler: centerButtonHandler)
    
    popupViewController.modalPresentationStyle = .overFullScreen
    present(popupViewController, animated: false)
}

해당 메서드로 실제 아무 뷰컨트롤러에서나 메서드를 호출해 쉽게 팝업을 표출할 수 있었다.

rootViewController.showConfirmationPopup(
    forMainText: "네트워크 연결 오류",
    forSubText: "네트워크 오류로\n서비스 접속이 불가능해요",
    centerButtonTitle: StringLiterals.Button.okay
)