앱을 만들다 보면 “저장되었습니다”, “수정했습니다”, “삭제되었습니다” 같은
가벼운 알림을 사용자에게 보여줘야 할 때가 있다.
이때 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)
→ 가장 위 계층에 토스트를 올린다 (항상 보임)
'감정일기(가칭)' 카테고리의 다른 글
| 👍 네비게이션 UI(로고 + 앱 이름 + 우측 버튼들)를 컬렉션뷰 + 컴포지셔널 레이아웃 구조 안의 커스텀 셀 형태로 넣기 (0) | 2025.12.02 |
|---|---|
| iOS 스플래시 화면에서 메인 화면으로 깨끗하게 전환하기— present 대신 rootViewController를 교체하는 방식 (0) | 2025.11.28 |
| 📘 MVVM에서 PassthroughSubject로 화면 이동 이벤트 처리하기 (0) | 2025.11.24 |
| 📅 Swift로 "주차(Week Number)" 계산하는 법 완전 정리 (0) | 2025.11.21 |
| 👉 주어진 날짜(date)가 속한 “주(week)”의 시작 요일(일요일)을 구하는 함수 & 일요일 ~ 토요일까지의 범위(DateInterval)를 생성하는 함수 (0) | 2025.11.21 |