일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- DP
- 버튼 피드백
- domain data
- BFS
- task cancel
- button configuration
- swift dashed line
- swift custom ui
- identifiable
- task cancellation
- swift bottomsheet
- scene delegate
- reactorkit
- custombottomsheet
- UIKit
- swift 백준
- traits
- RxSwift
- 타임라인 포맷팅
- rxdatasources
- custom ui
- swift navigationcontroller
- swift 점선
- claen architecture
- uikit toast
- swift concurrency
- SWIFT
- coordinator
- Tuist
- custom navigation bar
- Today
- Total
김경록의 앱 개발 여정
[Swift UIKit] 앱 전반에서 사용 될 Toast Message 구현기 본문
앱에서 사용자에게 간단한 알림을 제공할 때 흔히 사용하는 토스트메시지를 구현해 보았습니다.
이번 포스팅에서는 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 관련 로직(토스트 표시)을 직접 처리하지 않고,
CoordinatorBaseViewContoller가 해당 역할을 대신하게 됩니다.
이를 통해 코드의 모듈화와 테스트 용이성이 향상됩니다. - 일관된 메시지 노출: 모든 화면에서 동일한 토스트 메시지 스타일과 동작을 보장할 수 있습니다.
✨ 마무리
우다다다다 눌러도 괜찮음! 😊
'Trouble Shooting' 카테고리의 다른 글
[Swift UIKit] Button 터치 피드백이 안보이는 경우 (0) | 2025.05.02 |
---|---|
[Swift MVVM] UI와 독립적인 ViewModel, 어디까지 고려해야 할까? (feat. ReactorKit) (0) | 2025.04.17 |
[Swift UIKit] 점선 스타일 보더 적용하기(Dashed Border Style) (0) | 2025.04.16 |
[Swift UIKit] Custom Bottom Sheet (0) | 2025.04.11 |
[Swift UIKit] 앱 전반에서 사용되는 Custom NavigationContoller (0) | 2025.04.11 |