김경록의 앱 개발 여정

[Swift UIKit] 앱 전반에서 사용 될 Toast Message 구현기 본문

Trouble Shooting

[Swift UIKit] 앱 전반에서 사용 될 Toast Message 구현기

Kim Roks 2025. 4. 16. 18:43

앱에서 사용자에게 간단한 알림을 제공할 때 흔히 사용하는 토스트메시지를 구현해 보았습니다.

이번 포스팅에서는 iOS에서 토스트 메시지를 어떻게 구현했는지,

그리고 이를 앱 전반에서 안전하고 일관되게 사용하기 위해 어떤 설계 원칙을 적용했는지에 대한 경험을 적어보겠습니다.

 🍞 토스트 메시지 구현

토스트 메시지는 화면 하단(혹은 지정한 위치)에 잠깐 나타났다가 사라지는 UI 컴포넌트입니다.

저는 아래와 같이 토스트 메시지를 구현했습니다.

import UIKit

import SnapKit

final class ToastManager {
    static let shared = ToastManager()
    
    private var isShowingToast = false
    
    private init() {}
    
    private let toastView: UIView = {
        let view = UIView()
        view.backgroundColor = UIColor.black.withAlphaComponent(0.56)
        view.layer.cornerRadius = 10
        view.clipsToBounds = true
        view.alpha = 0
        view.isUserInteractionEnabled = false
        return view
    }()
    
    private let toastLabel: UILabel = {
        let label = UILabel()
        label.textColor = .white
        label.font = .et_pretendard(
            style: .medium,
            size: 14
        )
        label.numberOfLines = 0
        label.textAlignment = .center
        return label
    }()
    
    func show(message: String) {
        guard let windowScene = UIApplication.shared.connectedScenes
            .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
              let window = windowScene.windows.first(where: { $0.isKeyWindow }),
              !isShowingToast else {
            return
        }
        
        isShowingToast = true
        
        toastLabel.text = message
        
        if toastLabel.superview == nil {
            toastView.addSubview(toastLabel)
        }
        if toastView.superview == nil {
            window.addSubview(toastView)
        }
        
        toastLabel.snp.remakeConstraints {
            $0.top.bottom.equalToSuperview().inset(10)
            $0.centerY.equalToSuperview()
            $0.leading.trailing.equalToSuperview().inset(12)
        }
        
        toastView.snp.remakeConstraints {
            $0.centerX.equalToSuperview()
            $0.bottom.equalTo(window.safeAreaLayoutGuide.snp.bottom).offset(-80)
            $0.leading.greaterThanOrEqualToSuperview().offset(40)
            $0.trailing.lessThanOrEqualToSuperview().offset(-40)
        }
        
        UIView.animate(
            withDuration: 0.3,
            animations: {
                self.toastView.alpha = 1
            }) { _ in
                UIView.animate(
                    withDuration: 0.3,
                    delay: 1.6,
                    options: [],
                    animations: {
                        self.toastView.alpha = 0
                    }) { _ in
                        self.toastView.removeFromSuperview()
                        self.toastLabel.removeFromSuperview()
                        self.isShowingToast = false
                    }
            }
    }
}
 
 

토스트 뷰토스트 라벨을 각각 클로저를 통해 생성하고, 애니메이션으로 나타나고 사라지는 효과를 주었습니다.

Duration과 Delay의 경우 따로 지침이 없어서 개인적으로 자연스럽다고 생각하는 수치를 제공했습니다.

 

💻 UIWindowScene을 통해 현재 활성 윈도우에 접근하기

iOS 13부터 도입된 멀티 씬 환경에서는 UIApplication.shared.windows 대신
UIApplication.shared.connectedScenes에서 현재 포그라운드(활성화) 상태인 UIWindowScene을 찾아야 합니다.
이렇게 하면 멀티 윈도우(iPad의 여러 창 등) 상황에서도 정확히 사용자에게 보이는 윈도우를 얻을 수 있어요.

guard let scene = UIApplication.shared.connectedScenes
        .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
      let window = scene.windows.first(where: { $0.isKeyWindow }) else {
    return
}
 
  • connectedScenes → 앱에 연결된 모든 씬 목록
  • activationState == .foregroundActive → 화면에 표시 중인 씬 필터링
  • isKeyWindow → 키 윈도우(입력과 렌더링의 중심이 되는 창) 선택

✅  사용자 경험을 위한 고려 사항

간혹 유저의 의도치 않은 동작을 마주하게 될 거라고 생각합니다.

예를 들어 토스트 메시지를 300번 띄워버린다던가.. 여러 각기 다른 toast메시지를 동시에 띄우려고 한다던가

그러면 화면에 과부하가 걸리거나.. 무슨 글자가 떠있는지 모르거나 하는 사용자 경험이 저하될 거라고 생각했습니다.

그런 시나리오를 방지하기 위해 싱글톤isShowingToast 상태를 활용했습니다

 

final class ToastManager {
    static let shared = ToastManager()
    
    private var isShowingToast = false
    
    private init() {}
}
  • 문제점: 여러 메시지가 동시에 노출되면 UI가 혼란스러워지고 사용자 경험이 떨어집니다.
  • 해결책: isShowingToast 플래그를 활용하여 토스트가 이미 표시 중이면 새로운 토스트를 띄우지 않도록 합니다.
  • 싱글톤 사용 이유:
    단일 인스턴스에서 상태를 관리해야 중복 호출 방지 로직이 정상적으로 작동합니다.
    싱글톤으로 구현하면 앱 전역에서 동일한 인스턴스를 공유하므로, 여러 화면에서 토스트 메시지를 호출해도 상태가 누적되지 않습니다.

⭐️ DIP(의존성 역전 원칙) 적용 및 프로토콜 활용

저는 MVVM-C  + RxSwift, reactorKit을 사용하고 있습니다.

Reactor나 ViewModel이 직접 UIKit에 의존하는 것을 피하고 싶었고, 토스트 메시지의 표시를 기존 작성된 Coordinator가 담당하도록 설계했습니다.

고민을 좀 했는데 Coordinator의 역할로는 적합하지 않다 판단하여 BaesViewController가 채택하도록 수정했습니다!


이를 위해 ToastDisplayable 프로토콜을 도입하고, 기본 구현을 통해 ToastManager의 싱글톤을 호출하도록 했습니다.

public protocol ToastDisplayable {
    func showToast(message: String)
}

public extension ToastDisplayable {
    func showToast(message: String) {
        ToastManager.shared.show(message: message)
    }
}

Coordinator: AnyObject, ToastDisplayable

public protocol Coordinator: AnyObject, ToastDisplayable {}
 

이 구조를 통해 얻는 이점은 다음과 같습니다:

  • DIP 적용: 상위 계층(예: ViewModel, Reactor)은 구체적인 토스트 구현체(ToastManager)를 몰라도 되고, ToastDisplayable 프로토콜을 통해 추상화된 방식으로 호출할 수 있습니다.
  • 책임 분리: Reactor나 ViewModel은 UI 관련 로직(토스트 표시)을 직접 처리하지 않고, Coordinator BaseViewContoller가 해당 역할을 대신하게 됩니다.
    이를 통해 코드의 모듈화와 테스트 용이성이 향상됩니다.
  • 일관된 메시지 노출: 모든 화면에서 동일한 토스트 메시지 스타일과 동작을 보장할 수 있습니다.

✨ 마무리

 

우다다다다 눌러도 괜찮음! 😊