Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat/NST-62] #31 AppToast, ToastManager 작성 #32

Merged
merged 6 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions Noostak_iOS/Noostak_iOS/Global/Components/AppToastView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// AppToast.swift
// Noostak_iOS
//
// Created by 박민서 on 2/2/25.
//

import UIKit
import SnapKit
import Then
import RxSwift
import RxCocoa

final class AppToastView: UIView {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토스트 메세지가 현재 뷰가 바뀌어도 유지가 되나요? 아니면 뷰가 dismiss 또는 pop 되는 순간 사라지나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아뇨! 현재 앱에서 표시되고 있는 화면의 window에 추가되는 것이라서 UIWindow에 붙어있는 토스트라고 생각하시면 될 것 같습니다.
화면 전환 시에도 남아있게됩니다.


// 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.leading.equalTo(messageLabel).offset(-20)
$0.trailing.equalTo(messageLabel).offset(20)
$0.top.equalTo(messageLabel).offset(-12)
$0.bottom.equalTo(messageLabel).offset(12)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

왼쪽오른쪽 위아래 값이 같다면, horizontalEdge , verticalEdge 를 써도 좋을 것 같네요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와 맞네요 까마득하게 잊고 있었슴다 horizontal/vertical inset으로 수정해놓겠습니다!

}
}
}

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
107 changes: 107 additions & 0 deletions Noostak_iOS/Noostak_iOS/Global/Utility/ToastManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// 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] = []

/// 토스트 메시지를 화면에 표시합니다.
///
/// - 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) // 토스트 큐에 추가

// 애니메이션 (페이드 인 -> 유지 -> 페이드 아웃)
UIView.animate(withDuration: 0.3, animations: {
toastView.alpha = 1
}) { _ 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
}) { _ in
toastView.removeFromSuperview()
self.toastQueue.removeAll { $0 == toastView }

// 모든 토스트가 사라지면 overlayView도 제거
if self.toastQueue.isEmpty {
self.overlayView?.removeFromSuperview()
self.overlayView = nil
}
}
}
}

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