diff --git a/Noostak_iOS/Noostak_iOS/Global/AppColorTextField.swift b/Noostak_iOS/Noostak_iOS/Global/Components/AppColorTextField.swift similarity index 100% rename from Noostak_iOS/Noostak_iOS/Global/AppColorTextField.swift rename to Noostak_iOS/Noostak_iOS/Global/Components/AppColorTextField.swift diff --git a/Noostak_iOS/Noostak_iOS/Global/AppColorUnderlineTextField.swift b/Noostak_iOS/Noostak_iOS/Global/Components/AppColorUnderlineTextField.swift similarity index 100% rename from Noostak_iOS/Noostak_iOS/Global/AppColorUnderlineTextField.swift rename to Noostak_iOS/Noostak_iOS/Global/Components/AppColorUnderlineTextField.swift diff --git a/Noostak_iOS/Noostak_iOS/Global/AppGrayTextField.swift b/Noostak_iOS/Noostak_iOS/Global/Components/AppGrayTextField.swift similarity index 100% rename from Noostak_iOS/Noostak_iOS/Global/AppGrayTextField.swift rename to Noostak_iOS/Noostak_iOS/Global/Components/AppGrayTextField.swift diff --git a/Noostak_iOS/Noostak_iOS/Global/AppThemeButton.swift b/Noostak_iOS/Noostak_iOS/Global/Components/AppThemeButton.swift similarity index 100% rename from Noostak_iOS/Noostak_iOS/Global/AppThemeButton.swift rename to Noostak_iOS/Noostak_iOS/Global/Components/AppThemeButton.swift diff --git a/Noostak_iOS/Noostak_iOS/Global/Components/AppToastView.swift b/Noostak_iOS/Noostak_iOS/Global/Components/AppToastView.swift new file mode 100644 index 0000000..9e65de7 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Global/Components/AppToastView.swift @@ -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일까지 선택할 수 있어요")) +} diff --git a/Noostak_iOS/Noostak_iOS/Global/PaddedTextField.swift b/Noostak_iOS/Noostak_iOS/Global/Components/PaddedTextField.swift similarity index 100% rename from Noostak_iOS/Noostak_iOS/Global/PaddedTextField.swift rename to Noostak_iOS/Noostak_iOS/Global/Components/PaddedTextField.swift diff --git a/Noostak_iOS/Noostak_iOS/Global/ProfileImagePicker.swift b/Noostak_iOS/Noostak_iOS/Global/Components/ProfileImagePicker.swift similarity index 100% rename from Noostak_iOS/Noostak_iOS/Global/ProfileImagePicker.swift rename to Noostak_iOS/Noostak_iOS/Global/Components/ProfileImagePicker.swift diff --git a/Noostak_iOS/Noostak_iOS/Global/Extension/String+.swift b/Noostak_iOS/Noostak_iOS/Global/Extension/String+.swift index 9d81c28..02f546a 100644 --- a/Noostak_iOS/Noostak_iOS/Global/Extension/String+.swift +++ b/Noostak_iOS/Noostak_iOS/Global/Extension/String+.swift @@ -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 @@ -31,7 +32,7 @@ extension String { let attributes: [NSAttributedString.Key: Any] = [ .font: font, - .foregroundColor: UIColor.appGray800.cgColor, + .foregroundColor: color, .paragraphStyle: paragraphStyle, .kern: letterSpacing ] diff --git a/Noostak_iOS/Noostak_iOS/Global/Utility/ToastManager.swift b/Noostak_iOS/Noostak_iOS/Global/Utility/ToastManager.swift new file mode 100644 index 0000000..bf4a2a2 --- /dev/null +++ b/Noostak_iOS/Noostak_iOS/Global/Utility/ToastManager.swift @@ -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 + } + } +}