본문 바로가기
감정일기(가칭)

🍋 UIKit에서 토스트 메시지(Toast Message) 구현하기

by 밤새는 탐험가89 2025. 11. 26.
728x90
SMALL

앱을 만들다 보면 “저장되었습니다”, “수정했습니다”, “삭제되었습니다” 같은
가벼운 알림을 사용자에게 보여줘야 할 때가 있다.

 

이때 Alert 띄우기에는 너무 과하고,
Silent(아무 반응 없음)도 UX가 나쁘기 때문에
보통 Toast Message(토스트 메시지)를 사용한다.

 

이번 글에서는 UIKit 기반의 iOS 앱에서 토스트 메시지를 직접 구현해보고,
실무에서 가장 많이 사용하는 설계 방식을 기준으로
어떤 구조로 만드는 것이 유지보수와 확장성에 가장 좋은지까지 설명해본다.


✅ 1. 우리가 달성하고 싶은 목표

✔ 사용자 동작에 대한 가벼운 알림

  • 감정일기를 “저장”, “수정”, “삭제”했을 때
  • 화면에 잠깐 나타났다 사라지는 형태

✔ 커스텀 가능해야 한다

  • 위치(top/center/bottom) 지정
  • 종류에 따라 아이콘과 색상이 달라야 함
  • 메시지 다국어 지원 (Localizable.strings)

✔ 어디서든 호출할 수 있어야 한다

  • ViewController 구조가 복잡해도 표시되어야 함
  • 모달 여러 개가 중첩되어도 정상적으로 뜨는 구조

🚀 2. 설계 선택: "상위 View(UIWindow)에서 표시하는" 방식

토스트를 띄우는 방식은 크게 두 가지가 있다.


방식 A. ViewController → closure로 전달하여 표시

  • 특정 VC가 토스트를 관리함
  • 화면 이동/VC dismiss에 따라 토스트가 사라질 수 있음
  • 구조 복잡할 때 계층 따라 전달 코드가 복잡해짐

💡 단순한 화면 구조에서는 괜찮지만,
실무에서는 모달 → 모달 → 모달 구조에서 유지보수 지옥을 맞이한다.


방식 B. UIWindow에 직접 올려서 표시 (전역 토스트)

👉 실무에서 가장 많이 사용하는 방식
👉 우리도 이 방식을 선택했다!

  • 현재 화면의 VC 구조와 무관하게 표시됨
  • present/dismiss 상관없이 항상 맨 위에 나타남
  • 어디서든 ToastManager.show() 한 줄로 호출 가능
  • Alert처럼 사용자 입력을 막지 않기 때문에 UX적으로 자연스러움

🍞 3. ToastType 정의하기

토스트의 종류별 속성(아이콘, 메시지, 색상)을 관리한다.

enum ToastType {
    case saved
    case updated
    case deleted
    
    var icon: String {
        switch self {
        case .saved: return "💾"
        case .updated: return "✏️"
        case .deleted: return "🗑️"
        }
    }
    
    var message: String {
        switch self {
        case .saved: return NSLocalizedString("toast.saved", comment: "")
        case .updated: return NSLocalizedString("toast.updated", comment: "")
        case .deleted: return NSLocalizedString("toast.deleted", comment: "")
        }
    }
    
    var backgroundColor: UIColor {
        switch self {
        case .saved: return UIColor.systemGreen.withAlphaComponent(0.9)
        case .updated: return UIColor.systemBlue.withAlphaComponent(0.9)
        case .deleted: return UIColor.systemRed.withAlphaComponent(0.9)
        }
    }
}

 


🗂 4. 토스트 위치 설정 (top / center / bottom)

enum ToastPosition {
    case top
    case center
    case bottom
}

 


🖼 5. ToastView 만들기 (UI 구성 + 애니메이션)

final class ToastView: UIView {
    
    private let messageLabel = UILabel()
    private var hideWorkItem: DispatchWorkItem?
    
    init(type: ToastType) {
        super.init(frame: .zero)
        setupUI(type)
    }
    
    required init?(coder: NSCoder) { fatalError() }
    
    private func setupUI(_ type: ToastType) {
        backgroundColor = type.backgroundColor
        layer.cornerRadius = 12
        alpha = 0
        
        messageLabel.text = "\(type.icon)  \(type.message)"
        messageLabel.textColor = .white
        messageLabel.font = .boldSystemFont(ofSize: 14)
        messageLabel.numberOfLines = 0
        messageLabel.textAlignment = .center
        
        addSubview(messageLabel)
        messageLabel.translatesAutoresizingMaskIntoConstraints = false
        
        NSLayoutConstraint.activate([
            messageLabel.topAnchor.constraint(equalTo: topAnchor, constant: 12),
            messageLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12),
            messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
            messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16)
        ])
    }

 


🔔 6. 토스트 표시하기 (UIWindow 사용 + 햅틱 + 애니메이션)

func present(position: ToastPosition) {
    guard
        let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
        let window = windowScene.windows.first
    else { return }
    
    window.addSubview(self)
    translatesAutoresizingMaskIntoConstraints = false
    
    let verticalConstraint: NSLayoutConstraint
    
    switch position {
    case .top:
        verticalConstraint = topAnchor.constraint(equalTo: window.safeAreaLayoutGuide.topAnchor, constant: 20)
    case .center:
        verticalConstraint = centerYAnchor.constraint(equalTo: window.centerYAnchor)
    case .bottom:
        verticalConstraint = bottomAnchor.constraint(equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -40)
    }
    
    NSLayoutConstraint.activate([
        centerXAnchor.constraint(equalTo: window.centerXAnchor),
        widthAnchor.constraint(lessThanOrEqualTo: window.widthAnchor, multiplier: 0.85),
        verticalConstraint
    ])
    
    // 🔥 햅틱
    let generator = UINotificationFeedbackGenerator()
    generator.notificationOccurred(.success)
    
    window.layoutIfNeeded()
    
    UIView.animate(withDuration: 0.25) {
        self.alpha = 1
    }
    
    let workItem = DispatchWorkItem { [weak self] in
        self?.dismiss()
    }
    hideWorkItem = workItem
    DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem)
}

 


👋 7. 토스트 사라지게 하기

private func dismiss() {
    UIView.animate(withDuration: 0.25, animations: {
        self.alpha = 0
    }) { _ in
        self.removeFromSuperview()
    }
}

 


📣 8. ToastManager: 어디서든 쉽게 호출하는 최종 API

final class ToastManager {
    static func show(_ type: ToastType, position: ToastPosition = .bottom) {
        let toast = ToastView(type: type)
        toast.present(position: position)
    }
}

 


🎮 9. 실제 사용 예시

일기 저장 버튼 누를 때:

ToastManager.show(.saved)

 

수정 완료 시:

ToastManager.show(.updated, position: .top)

 

삭제 완료 시:

ToastManager.show(.deleted, position: .center)

 


🧠 10. 왜 이 구조가 좋은가?

✔ 구조가 단순하고 호출이 쉽다

ToastManager.show() 딱 한 줄이면 끝.

✔ 실무에서도 쓰는 방식

View 계층과 무관하게 안정적으로 UI를 띄울 수 있기 때문.

✔ 외부 영향(dismiss/present)에 흔들리지 않음

상위 VC가 사라져도 토스트가 보임 → 경험적으로 가장 자연스러움.

✔ 확장성 좋음

  • 애니메이션 교체
  • 색상 테마 변경
  • 토스트 큐 시스템 추가
    등등…

 

🎨 UIKit의 화면 구조(계층)를 한눈에 보는 다이어그램

아래 그림이 핵심이다.

📱 iOS 화면 전체
│
└── UIWindowScene (장면)
      │
      └── UIWindow (앱 최상위 View)
            │
            ├── UIViewController.view (화면 루트)
            │      ├── NavigationController.view
            │      │      ├── ViewController A.view
            │      │      ├── A가 present 한 ViewController B.view
            │      │      └── B가 present 한 ViewController C.view
            │      └── ...
            │
            └── 🍞 ToastView  ← 항상 여기 붙음!

 

✨ 핵심 포인트

  • UIWindow는 앱 전체 화면의 최상위 뷰
  • 모든 ViewController의 화면은 window 아래에 순서대로 쌓인다
  • 그러나 ToastView는 window에 바로 붙기 때문에
    어느 화면이든 항상 맨 위에서 표시된다

UIWindow 안의 View 계층 구조

UIWindow   ← 여기에 ToastView를 addSubview()
│
├── rootViewController.view (앱의 기본 화면)
│      ├── NavigationController.view
│      │      ├── HomeVC.view
│      │      ├── DetailVC.view
│      │      ├── EditorVC.view
│      │      └── …
│      └── 기타 ViewControllers
│
└── ToastView  ← 모든 VC 위에서 표시됨

 

 

✨ 위 원리가 적용된 핵심 코드 다시 보기

guard
    let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
    let window = windowScene.windows.first
else { return }

 

 

1) connectedScenes

→ 현재 활성화된 scene 가져오기

 

2) UIWindowScene으로 타입 캐스팅

→ UI를 담당하는 scene만 선택

 

3) windows.first

→ 해당 scene의 메인 UIWindow 가져오기

 

4) window.addSubview(toastView)

→ 가장 위 계층에 토스트를 올린다 (항상 보임)

728x90
LIST