Skip to content

Commit

Permalink
Merge pull request #32 from Noostak/feat/NST-62/toast
Browse files Browse the repository at this point in the history
[Feat/NST-62] #31 AppToast, ToastManager 작성
  • Loading branch information
FpRaArNkK authored Feb 11, 2025
2 parents 924c136 + 12a70f2 commit f63f253
Show file tree
Hide file tree
Showing 9 changed files with 241 additions and 3 deletions.
108 changes: 108 additions & 0 deletions Noostak_iOS/Noostak_iOS/Global/Components/AppToastView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//
// AppToast.swift
// Noostak_iOS
//
// Created by 박민서 on 2/2/25.
//

import UIKit
import SnapKit
import Then
import RxSwift
import RxCocoa

final class AppToastView: UIView {

// MARK: Properties
private var status: Status

// MARK: Views
private let messageLabel = UILabel()
private let backgroundView = UIView()

// MARK: Init
init(status: Status) {
self.status = status
super.init(frame: .zero)
setUpHierarchy()
setUpUI()
setUpLayout()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func layoutSubviews() {
super.layoutSubviews()
backgroundView.layer.cornerRadius = backgroundView.frame.height / 2
}

// MARK: setUpHierarchy
private func setUpHierarchy() {
[
backgroundView,
messageLabel
].forEach { self.addSubview($0) }
}

// MARK: setUpUI
private func setUpUI() {
messageLabel.do {
$0.attributedText = status.attributedText
$0.textAlignment = .center
$0.numberOfLines = 0
}

backgroundView.do {
$0.backgroundColor = status.backgroundColor
$0.layer.cornerRadius = 21 // layoutSubviews에서 조정됩니다
$0.clipsToBounds = true
}
}

// MARK: setUpLayout
private func setUpLayout() {
messageLabel.snp.makeConstraints {
$0.center.equalToSuperview()
}

backgroundView.snp.makeConstraints {
$0.horizontalEdges.equalTo(messageLabel).inset(-20)
$0.verticalEdges.equalTo(messageLabel).inset(-12)
}
}
}

extension AppToastView {
enum Status {
case `default`(message: String)
case `error`(message: String)

var backgroundColor: UIColor {
switch self {

case .default:
return .appGray800
case .error:
return .appPink
}
}

var attributedText: NSAttributedString {
switch self {

case .default(let message):
return message.pretendardStyled(style: .c3_r, color: .appWhite)
case .error(let message):
// TODO: 폰트 시스템 C3_SB 추가되면 수정
return message.pretendardStyled(style: .c3_r, color: .appRed01)
}
}
}
}

#Preview {
// AppToastView(status: .default(message: "그룹 코드가 복사되었습니다"))
AppToastView(status: .error(message: "최대 7일까지 선택할 수 있어요"))
}
7 changes: 4 additions & 3 deletions Noostak_iOS/Noostak_iOS/Global/Extension/String+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ extension String {
///
/// - Parameters:
/// - style: Pretendard 스타일 (e.g., `.h1_b`, `.h2_b`)
/// - color: 적용할 텍스트 색상 (기본값: `.appGray800`)
///
/// - Returns: NSAttributedString으로 반환된 텍스트
///
/// - Usage:
/// ```swift
/// let styledText = "Custom Styled Text".pretendardStyled(style: .h1_b)
/// let styledText = "Custom Styled Text".pretendardStyled(style: .h1_b, color: .red)
/// label.attributedText = styledText
/// ```
func pretendardStyled(style: UIFont.PretendardStyle) -> NSAttributedString {
func pretendardStyled(style: UIFont.PretendardStyle, color: UIColor = .appGray800) -> NSAttributedString {
let font = style.font
let lineHeight = font.pointSize * style.lineHeightUnit / 100
let letterSpacing = style.letterSpacingUnit
Expand All @@ -31,7 +32,7 @@ extension String {

let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.appGray800.cgColor,
.foregroundColor: color,
.paragraphStyle: paragraphStyle,
.kern: letterSpacing
]
Expand Down
129 changes: 129 additions & 0 deletions Noostak_iOS/Noostak_iOS/Global/Utility/ToastManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//
// ToastManager.swift
// Noostak_iOS
//
// Created by 박민서 on 2/2/25.
//

import UIKit
import SnapKit

final class ToastManager {
static let shared = ToastManager()

private init() {}

private var overlayView: UIView?
private var toastQueue: [(AppToastView, TimeInterval)] = []
private var isShowingToast = false

/// 토스트 메시지를 화면에 표시합니다.
///
/// - Parameters:
/// - status: 표시할 토스트의 상태 (텍스트, 색상 등 지정)
/// - duration: 토스트가 화면에 유지되는 시간 (기본값: 2.0초)
/// - bottomFrom: 토스트가 화면 하단에서부터 얼마나 떨어져서 표시될지 지정 (기본값: 80pt)
func showToast(status: AppToastView.Status, duration: TimeInterval = 2.0, bottomFrom: CGFloat = 80) {
guard let windowScene = UIApplication.shared.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
let window = windowScene.windows.first else { return }

// 기존 overlayView가 없으면 생성
if overlayView == nil {
self.overlayView = makeOverlayView(window: window)
}

// 토스트 뷰 생성 후 큐에 추가
let toastView = makeToastView(status: status, bottomFrom: bottomFrom)
toastQueue.append((toastView, duration))

// 현재 토스트가 표시 중이 아닐 때만 새로 표시
if !isShowingToast {
showNextToast()
}
}

/// 큐에서 다음 토스트를 표시합니다
private func showNextToast() {
// 토스트 큐에 토스트 있을 때 다음으로
guard !toastQueue.isEmpty else {
isShowingToast = false
return
}

isShowingToast = true
let (toastView, duration) = toastQueue.removeFirst() // FIFO

// 애니메이션 (페이드 인 -> 유지 -> 페이드 아웃)
UIView.animate(withDuration: 0.3, animations: {
toastView.alpha = 1
}, completion: { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
self.dismissToast(toastView)
}
})
}

/// overlay 뷰가 없는 경우 새로운 뷰를 생성합니다
private func makeOverlayView(window: UIWindow) -> UIView {
let overlay = UIView(frame: window.bounds)
overlay.backgroundColor = UIColor.clear
window.addSubview(overlay)

// 제스처 추가 (화면 터치 시 모든 토스트 닫기)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissAllToasts))
overlay.addGestureRecognizer(tapGesture)

return overlay
}

/// 새로운 토스트 뷰를 생성합니다
private func makeToastView(status: AppToastView.Status, bottomFrom: CGFloat) -> AppToastView {
let toastView = AppToastView(status: status)
toastView.alpha = 0 // clear 상태로 추가
overlayView?.addSubview(toastView)

toastView.snp.makeConstraints {
$0.centerX.equalToSuperview()
$0.bottom.equalToSuperview().inset(bottomFrom)
}

toastView.layoutIfNeeded()
return toastView
}

/// 해당 토스트를 dimiss합니다 with Animation
/// 큐에서 해당 토스트를 삭제하고, 다음 토스트를 실행합니다
private func dismissToast(_ toastView: AppToastView) {
DispatchQueue.main.async {
UIView.animate(withDuration: 0.3, animations: {
toastView.alpha = 0
}, completion: { _ in
toastView.removeFromSuperview()

// 다음 토스트 실행
if !self.toastQueue.isEmpty {
self.showNextToast()
} else {
// 모든 토스트가 사라지면 overlayView도 제거
self.overlayView?.removeFromSuperview()
self.overlayView = nil
self.isShowingToast = false
}
})
}
}

/// 큐에 쌓인 토스트를 일괄 삭제합니다
@objc private func dismissAllToasts() {
DispatchQueue.main.async {
for (toast, _) in self.toastQueue {
toast.removeFromSuperview()
}
self.toastQueue.removeAll()
self.overlayView?.removeFromSuperview()
self.overlayView = nil
self.isShowingToast = false
}
}
}

0 comments on commit f63f253

Please sign in to comment.