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

2024. 2. 16. 13:10UIKit, SwiftUI, H.I.G

💡 재사용 Component 개발하기 시리즈 글을 시작하며

이번 33기 앱잼이었던 <TOASTER 토스터> 프로젝트에서 우리 iOS 팀이 추구한 방향 중 하나는,
앱 내에서 반복되는 컴포넌트(내비게이션바, 바텀시트, 알림 창, 토스트 메시지 등)를 재사용할 수 있도록 만드는 것이었다.

공통으로 반복되는 부분과, 변화가 필요한 부분을 고민해서 모듈화 시킨 과정을 이번 시리즈에서 같이 느껴볼 수 있길 바란다.

 

1️⃣ Toast Message란?


토스트 메시지(Toast Message)는 원래 안드로이드에만 존재하는 컴포넌트이다.

보통 앱 사용 중, 사용자와의 상호작용은 계속 열어둔 상황에서 동시에 짧은 메시지를 사용자에게 보여주기 위해 사용하는 컴포넌트다.
사용자의 인터랙션이 별도로 없어도 특정한 시간 뒤에 표출, 사라짐 동작이 자동으로 이루어진다는 것이 특징이다.

*Apple은 공식문서 상에 토스트 메시지를 다루지 않는다.
사용자 경험을 중시하는 Apple의 특성상, 기존 콘텐츠 영역을 가리면서 또 다른 콘텐츠를 보여주는 경험을 좋은 정보 전달 방법이라 생각하지 않는 듯하다. 이 방법 대신, 보통은 Alert 창을 표출해서 기존 사용자 행위를 방해하는 방식을 공식문서에서 언급하고 있다는 것까지 알아두자.

 

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


디자이너 선생님들이 앱 내에서 사용되는 토스트 메시지 컴포넌트를 피그마 상에서 아래와 같이 한 번에 정리해 주셨다.

딱 봐도 알겠지만, 반복되는 요소가 엄청 많았다.
기본 토스트 메시지의 형태 (아이콘과 메시지 간격 + 모서리 둥글기), 배경색, 텍스트의 폰트와 크기, 색상, 추가적으로 토스트 메시지가 사용자에게 표출되는 fade in, fade out 시간까지 모두 같았다.

변하는 점은 아이콘이 2개(알림 메시지, 권고 혹은 경고 메시지)로 구분된다는 점,
그리고 토스트 안에 들어가는 메시지의 내용과 그에 따른 토스트 메시지 전체의 길이(기기대응 없이 절댓값 요청받음)가 변한다는 점 정도가 있었다.

그에 따라 이미지와 텍스트의 색상, 폰트,
그리고 토스트 메시지 뷰의 전체적인 배경색, 모서리 둥글기, 이미지와 텍스트 사이의 간격 같은 공통되는 요소를 View로 만들어줬다.

final class ToasterToastMessageView: UIView {
    
    // MARK: - UI Components
    
    private let toastImage = UIImageView().then {
        $0.tintColor = .toasterWhite
    }
    
    private let toastLabel = UILabel().then {
        $0.textColor = .toasterWhite
        $0.font = .suitBold(size: 14)
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupStyle()
        setupHierarchy()
        setupLayout()
    }
    
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

// MARK: - Private Extensions

private extension ToasterToastMessageView {
    func setupStyle() {
        self.backgroundColor = .gray800
        self.makeRounded(radius: 22)
    }
    
    func setupHierarchy() {
        addSubviews(toastImage, toastLabel)
    }
    
    func setupLayout() {
        toastImage.snp.makeConstraints {
            $0.leading.equalToSuperview().inset(22)
            $0.centerY.equalToSuperview()
            $0.size.equalTo(18)
        }
        toastLabel.snp.makeConstraints {
            $0.leading.equalTo(toastImage.snp.trailing).offset(10)
            $0.centerY.equalToSuperview()
        }
    }
}

토스트 메시지가 2가지 타입으로 나누어졌기 때문에, 해당 타입은 열거형의 두 가지 case로 구분했다.

그리고 UIImage를 담는 icon 변수를 따로 할당해서 각 case 상태에 따라 각각의 이미지를 return 하도록 만들었다.

import UIKit

enum ToastStatus {
    case check, warning
    
    var icon: UIImage {
        switch self {
        case .check:
            // 체크 상태의 토스트에서 사용하는 이미지를 return
        case .warning:
            // 권고 상태의 토스트에서 사용하는 이미지를 return
        }
    }
}

이렇게 되면 아래와 같이 사용자에게 메시지의 내용과 토스트 메시지 타입을 입력받아,
각각 이미지와 라벨에 값을 할당할 수 있게 만들 수 있었다.

// MARK: - Extensions

extension ToasterToastMessageView {
    func setupDataBind(message: String, status: ToastStatus) {
        toastImage.image = status.icon
        toastLabel.text = message
    }
}

 

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


ViewController에서 함수 호출 한 번으로 토스트 메시지를 사용하는 것이 목표였기에, UIViewController Extension으로 함수를 만들었다.

변하는 요소였던 토스트 메시지 길이, 타입, 메시지 String값을 사용자에게 파라미터로 입력받으며,
그에 따른 토스트 (변하지 않는) 절댓값 위치를 지정하고, 위에서 만들어준 데이터 바인딩 함수에 값을 할당하게 된다.

그리고 이후 UIView.animate 속성을 이용해서 fade-out 되는 애니메이션을 추가해줬다.

extension UIViewController {

    /// 토스트 메시지를 보여주는 메서드
    func showToastMessage(width: CGFloat,
                          status: ToastStatus,
                          message: String) {
        let toastView = ToasterToastMessageView(
            frame: CGRect(x: view.center.x - width/2,
                          y: view.convertByHeightRatio(658),
                          width: width,
                          height: 46))
                          
        self.view.addSubview(toastView)
        toastView.setupDataBind(message: message, status: status)
        
        UIView.animate(withDuration: 5.0, 
                       delay: 0.0,
                       options: [.curveEaseIn, .beginFromCurrentState], 
                       animations: {
            toastView.alpha = 0.0
        }) { _ in
            toastView.removeFromSuperview()
        }
    }
}

이렇게 추가해줬기 때문에
실제 VC 코드에서는 한 줄의 함수 호출만으로도 편하게 Toast Message를 사용할 수 있게 되었다.

// 왼쪽 (체크 이미지)
showToastMessage(width: 200, status: .check, message: "연습용 토스트 메시지임")

// 오른쪽 (경고 이미지)
showToastMessage(width: 200, status: .warning, message: "연습용 토스트 메시지임")

최종 적용 화면! (왼쪽은 .check 타입일 때, 오른쪽은 .warning 타입일 때)