diff --git a/project/Projects/Presentation/DSKit/Resources/Icons.xcassets/success_check.imageset/Contents.json b/project/Projects/Presentation/DSKit/Resources/Icons.xcassets/success_check.imageset/Contents.json new file mode 100644 index 00000000..5c4ff470 --- /dev/null +++ b/project/Projects/Presentation/DSKit/Resources/Icons.xcassets/success_check.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "success_check.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/project/Projects/Presentation/DSKit/Resources/Icons.xcassets/success_check.imageset/success_check.svg b/project/Projects/Presentation/DSKit/Resources/Icons.xcassets/success_check.imageset/success_check.svg new file mode 100644 index 00000000..fb095f26 --- /dev/null +++ b/project/Projects/Presentation/DSKit/Resources/Icons.xcassets/success_check.imageset/success_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/project/Projects/Presentation/DSKit/Sources/CommonUI/SnackBar/IdleSnackBar.swift b/project/Projects/Presentation/DSKit/Sources/CommonUI/SnackBar/IdleSnackBar.swift new file mode 100644 index 00000000..169dc732 --- /dev/null +++ b/project/Projects/Presentation/DSKit/Sources/CommonUI/SnackBar/IdleSnackBar.swift @@ -0,0 +1,103 @@ +// +// IdleSnackBar.swift +// DSKit +// +// Created by choijunios on 9/12/24. +// + +import UIKit +import RxSwift +import RxCocoa +import Entity + +public struct IdleSnackBarRO { + let titleText: String + let icon: UIImage? + let backgroundColor: UIColor? + + public init( + titleText: String="완료되었습니다.", + icon: UIImage?=DSIcon.successCheck.image, + backgroundColor: UIColor?=DSColor.gray500.color + ) { + self.titleText = titleText + self.icon = icon + self.backgroundColor = backgroundColor + } +} + +public class IdleSnackBar: UIView { + + private let titleLabel: IdleLabel = { + let label = IdleLabel(typography: .Subtitle4) + label.attrTextColor = DSColor.gray0.color + return label + }() + + private let titleIcon: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = DSColor.gray0.color + return imageView + }() + + public override init(frame: CGRect) { + super.init(frame: frame) + + setAppearance() + setLayout() + } + + public required init?(coder: NSCoder) { nil } + + func setAppearance() { + self.backgroundColor = DSColor.gray500.color + self.layer.cornerRadius = 8 + } + + func setLayout() { + + self.layoutMargins = .init(top: 12, left: 16, bottom: 12, right: 16) + + let mainStack = HStack([ + titleIcon, titleLabel, Spacer() + ], spacing: 4, alignment: .center) + + [ + mainStack + ].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + self.addSubview($0) + } + + NSLayoutConstraint.activate([ + mainStack.topAnchor.constraint(equalTo: self.layoutMarginsGuide.topAnchor), + mainStack.leftAnchor.constraint(equalTo: self.layoutMarginsGuide.leftAnchor), + mainStack.rightAnchor.constraint(equalTo: self.layoutMarginsGuide.rightAnchor), + mainStack.bottomAnchor.constraint(equalTo: self.layoutMarginsGuide.bottomAnchor), + ]) + } + + public func applyRO(_ ro: IdleSnackBarRO) { + self.backgroundColor = ro.backgroundColor + titleLabel.textString = ro.titleText + titleIcon.image = ro.icon + } +} + +@available(iOS 17.0, *) +#Preview("Preview", traits: .defaultLayout) { + let view = IdleSnackBar( + frame: .init( + origin: .init(x: 20, y: 300), + size: .init(width: 300, height: 48) + ) + ) + + DispatchQueue.main.asyncAfter(deadline: .now()+3) { + + UIView.animate(withDuration: 0.35) { + view.alpha = 0 + } + } + return view +} diff --git a/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/SceneDelegate.swift b/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/SceneDelegate.swift index 7c3560ed..7e4d470e 100644 --- a/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/SceneDelegate.swift +++ b/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/SceneDelegate.swift @@ -16,8 +16,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let windowScene = scene as? UIWindowScene else { return } + let vm = BaseViewModel() + let vc = ViewController() + vc.bind(viewModel: vm) + window = UIWindow(windowScene: windowScene) - window?.rootViewController = UIViewController() + window?.rootViewController = vc window?.makeKeyAndVisible() } diff --git a/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/ViewController.swift b/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/ViewController.swift index e439d432..c04d8514 100644 --- a/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/ViewController.swift +++ b/project/Projects/Presentation/Feature/Base/ExampleApp/Sources/ViewController.swift @@ -6,8 +6,9 @@ // import UIKit +import BaseFeature -class ViewController: UIViewController { +class ViewController: BaseViewController { override func viewDidLoad() { @@ -24,6 +25,15 @@ class ViewController: UIViewController { initialLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), initialLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) + + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.viewModel? + .snackBar + .onNext(.init(titleText: "테스트테스트테스트")) } } diff --git a/project/Projects/Presentation/Feature/Base/Sources/View/ViewController/Base/BaseViewController.swift b/project/Projects/Presentation/Feature/Base/Sources/View/ViewController/Base/BaseViewController.swift index c1ac2290..d4ae4a2f 100644 --- a/project/Projects/Presentation/Feature/Base/Sources/View/ViewController/Base/BaseViewController.swift +++ b/project/Projects/Presentation/Feature/Base/Sources/View/ViewController/Base/BaseViewController.swift @@ -21,6 +21,8 @@ open class BaseViewController: UIViewController { private var loadingDimissionRequested: Bool = false private var loadingVC: UIViewController? + open var snackBarBottomPadding: CGFloat = 0.0 + /// disposeBag public let disposeBag = DisposeBag() @@ -29,6 +31,12 @@ open class BaseViewController: UIViewController { self.viewModel = viewModel + // Life cycle + rx.viewDidAppear + .map({ _ in }) + .bind(to: viewModel.viewDidAppear) + .disposed(by: disposeBag) + // Alert viewModel .alertDriver? @@ -44,6 +52,14 @@ open class BaseViewController: UIViewController { }) .disposed(by: disposeBag) + // Snack bar + viewModel + .snackBarDriver? + .drive(onNext: { [weak self] snackBarRO in + self?.showSnackBar(ro: snackBarRO) + }) + .disposed(by: disposeBag) + // 로딩 viewModel .showLoadingDriver? @@ -106,6 +122,53 @@ public extension BaseViewController { } } +// MARK: Snack bar +extension BaseViewController { + + func showSnackBar(ro: IdleSnackBarRO) { + + let viewSize = self.view.bounds.size + let horizontalPadding: CGFloat = 20 + let bottomPadding: CGFloat = 20 + snackBarBottomPadding + + let snackBarHeight: CGFloat = 48 + let snackBarWidth: CGFloat = viewSize.width - horizontalPadding*2 + + let snackBarXPos: CGFloat = horizontalPadding + let snackBarYPos: CGFloat = viewSize.height - bottomPadding - snackBarHeight + + let snackBar = IdleSnackBar( + frame: .init( + origin: .init(x: snackBarXPos, y: snackBarYPos), + size: .init(width: snackBarWidth, height: snackBarHeight) + ) + ) + + // ro적용 + snackBar.applyRO(ro) + + // 스낵바를 하단에 감춘다 + snackBar.transform = .init(translationX: 0, y: snackBarHeight+horizontalPadding) + snackBar.alpha = 0.5 + + // 뷰계층에 추가 + view.addSubview(snackBar) + + let snackBarShowingDuration: CGFloat = 2 + + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) { + snackBar.transform = .identity + snackBar.alpha = 1.0 + } completion: { _ in + + UIView.animate(withDuration: 0.2, delay: snackBarShowingDuration, options: .curveEaseIn) { + snackBar.transform = .init(translationX: 0, y: snackBarHeight+horizontalPadding) + snackBar.alpha = 0.5 + } + } + } +} + public extension BaseViewController { func showDefaultLoadingScreen() { diff --git a/project/Projects/Presentation/Feature/Base/Sources/ViewModelType/BaseViewModel.swift b/project/Projects/Presentation/Feature/Base/Sources/ViewModelType/BaseViewModel.swift index bbf6d292..b8e42549 100644 --- a/project/Projects/Presentation/Feature/Base/Sources/ViewModelType/BaseViewModel.swift +++ b/project/Projects/Presentation/Feature/Base/Sources/ViewModelType/BaseViewModel.swift @@ -20,6 +20,15 @@ open class BaseViewModel { public let alertObject: PublishSubject = .init() var alertObjectDriver: Driver? + // Snack bar + public let snackBar: PublishSubject = .init() + var snackBarDriver: Driver? + + // MARK: SnackBarStack + private var snackBarStack: [IdleSnackBarRO] = [] + + let viewDidAppear: PublishSubject = .init() + // 로딩 public let showLoading: PublishSubject = .init() public let dismissLoading: PublishSubject = .init() @@ -35,12 +44,29 @@ open class BaseViewModel { self.alertObjectDriver = alertObject .asDriver(onErrorDriveWith: .never()) + + self.snackBarDriver = snackBar + .asDriver(onErrorDriveWith: .never()) self.showLoadingDriver = showLoading .asDriver(onErrorDriveWith: .never()) self.dismissLoadingDriver = dismissLoading .asDriver(onErrorDriveWith: .never()) + + // life cycle + viewDidAppear + .compactMap { [weak self] in + let bars = self?.snackBarStack + self?.snackBarStack = [] + return bars + } + .flatMap { bars in + Observable.from(bars) + } + .debounce(.milliseconds(350), scheduler: MainScheduler.asyncInstance) + .bind(to: snackBar) + .disposed(by: disposeBag) } public func mapStartLoading(_ target: Observable) -> Observable { @@ -64,4 +90,8 @@ open class BaseViewModel { return item } } + + public func addSnackBar(ro: IdleSnackBarRO) { + self.snackBarStack.append(ro) + } } diff --git a/project/Projects/Presentation/Feature/Center/Sources/Coordinator/RecruitmentPost/EditPostCoordinator.swift b/project/Projects/Presentation/Feature/Center/Sources/Coordinator/RecruitmentPost/EditPostCoordinator.swift index c9ffb3fa..240401a8 100644 --- a/project/Projects/Presentation/Feature/Center/Sources/Coordinator/RecruitmentPost/EditPostCoordinator.swift +++ b/project/Projects/Presentation/Feature/Center/Sources/Coordinator/RecruitmentPost/EditPostCoordinator.swift @@ -7,8 +7,10 @@ import UIKit import PresentationCore +import BaseFeature import UseCaseInterface import Entity +import DSKit public class EditPostCoordinator: ChildCoordinator { @@ -51,5 +53,20 @@ public class EditPostCoordinator: ChildCoordinator { popViewController() parent?.removeChildCoordinator(self) } + + func coordinatorDidFinishWithSnackBar(ro: IdleSnackBarRO) { + let belowIndex = navigationController.children.count-2 + + if belowIndex >= 0 { + let belowVC = navigationController.children[belowIndex] + + if let baseVC = belowVC as? BaseViewController { + + baseVC.viewModel?.addSnackBar(ro: ro) + } + } + + coordinatorDidFinish() + } } diff --git a/project/Projects/Presentation/Feature/Center/Sources/Coordinator/RecruitmentPost/PostDetailForCenterCoordinator.swift b/project/Projects/Presentation/Feature/Center/Sources/Coordinator/RecruitmentPost/PostDetailForCenterCoordinator.swift index bf3561d1..a885cf46 100644 --- a/project/Projects/Presentation/Feature/Center/Sources/Coordinator/RecruitmentPost/PostDetailForCenterCoordinator.swift +++ b/project/Projects/Presentation/Feature/Center/Sources/Coordinator/RecruitmentPost/PostDetailForCenterCoordinator.swift @@ -6,9 +6,11 @@ // import UIKit +import BaseFeature import PresentationCore import UseCaseInterface import Entity +import DSKit public class PostDetailForCenterCoordinator: ChildCoordinator { @@ -64,6 +66,21 @@ public class PostDetailForCenterCoordinator: ChildCoordinator { popViewController() parent?.removeChildCoordinator(self) } + + public func coordinatorDidFinishWithSnackBar(ro: IdleSnackBarRO) { + let belowIndex = navigationController.children.count-2 + + if belowIndex >= 0 { + let belowVC = navigationController.children[belowIndex] + + if let baseVC = belowVC as? BaseViewController { + + baseVC.viewModel?.addSnackBar(ro: ro) + } + } + + coordinatorDidFinish() + } } extension PostDetailForCenterCoordinator { diff --git a/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/CenterRecruitmentPostBoardVM.swift b/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/CenterRecruitmentPostBoardVM.swift index 2c4cd58d..3d3a444c 100644 --- a/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/CenterRecruitmentPostBoardVM.swift +++ b/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/CenterRecruitmentPostBoardVM.swift @@ -124,6 +124,12 @@ public class CenterRecruitmentPostBoardVM: BaseViewModel, CenterRecruitmentPostB // 새로고침 closePostSuccess + .map({ [weak self] value in + // 스낵바 + self?.snackBar.onNext(.init(titleText: "채용을 종료했어요.")) + + return value + }) .bind(to: requestOngoingPost) .disposed(by: disposeBag) diff --git a/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/EditPostVM.swift b/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/EditPostVM.swift index 2f4ffb66..5e895123 100644 --- a/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/EditPostVM.swift +++ b/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/EditPostVM.swift @@ -409,7 +409,8 @@ public class EditPostVM: BaseViewModel, EditPostViewModelable { guard let self else { return } // 성공적으로 수정됨 - self.editPostCoordinator?.coordinatorDidFinish() + self.editPostCoordinator? + .coordinatorDidFinishWithSnackBar(ro: .init(titleText: "공고가 수정되었어요.")) } .disposed(by: disposeBag) diff --git a/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/PostDetailForCenterVM.swift b/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/PostDetailForCenterVM.swift index 3890add2..f539f3ab 100644 --- a/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/PostDetailForCenterVM.swift +++ b/project/Projects/Presentation/Feature/Center/Sources/ViewModel/RecruitmentPost/PostDetailForCenterVM.swift @@ -12,6 +12,7 @@ import Entity import PresentationCore import UseCaseInterface import BaseFeature +import DSKit public protocol PostDetailViewModelable: AnyObject, @@ -221,10 +222,18 @@ public class PostDetailForCenterVM: BaseViewModel, PostDetailViewModelable { let removePostSuccess = removePostResult.compactMap { $0.value } let removePostFailure = removePostResult.compactMap { $0.error } + + let closePostSuccessSnackBarRO = closePostSuccess + .map { _ in IdleSnackBarRO(titleText: "채용을 종료했어요.") } + + let removePostSuccessSnackBarRO = removePostSuccess + .map { _ in IdleSnackBarRO(titleText: "공고를 삭제했어요.") } + Observable - .merge(closePostSuccess, removePostSuccess) - .subscribe(onNext: { [weak self] _ in - self?.coordinator?.coordinatorDidFinish() + .merge(closePostSuccessSnackBarRO, removePostSuccessSnackBarRO) + .subscribe(onNext: { [weak self] ro in + self?.coordinator? + .coordinatorDidFinishWithSnackBar(ro: ro) }) .disposed(by: disposeBag) diff --git a/project/Projects/Presentation/Feature/Worker/Sources/ViewModel/RecruitmentPost/OnGoingPostBoard/WorkerRecruitmentPostBoardVM.swift b/project/Projects/Presentation/Feature/Worker/Sources/ViewModel/RecruitmentPost/OnGoingPostBoard/WorkerRecruitmentPostBoardVM.swift index 146f3e8e..c8c74b5c 100644 --- a/project/Projects/Presentation/Feature/Worker/Sources/ViewModel/RecruitmentPost/OnGoingPostBoard/WorkerRecruitmentPostBoardVM.swift +++ b/project/Projects/Presentation/Feature/Worker/Sources/ViewModel/RecruitmentPost/OnGoingPostBoard/WorkerRecruitmentPostBoardVM.swift @@ -123,23 +123,28 @@ public class WorkerRecruitmentPostBoardVM: BaseViewModel, WorkerRecruitmentPostB } } .asDriver(onErrorDriveWith: .never()) - - // 로딩 시작 - loadingStartObservables.append(applyRequest.map { _ in }) - let applyRequestResult = applyRequest + let applyRequestResult = mapEndLoading(mapStartLoading(applyRequest.asObservable()) .flatMap { [recruitmentPostUseCase] postId in // 리스트화면에서는 앱내 지원만 지원합니다. return recruitmentPostUseCase .applyToPost(postId: postId, method: .app) - } + }) .share() - // 로딩 종료 - loadingEndObservables.append(applyRequestResult.map { _ in }) - let applyRequestSuccess = applyRequestResult.compactMap { $0.value } + let applyRequestSuccess = applyRequestResult.compactMap { $0.value }.share() + + // 스낵바 + applyRequestSuccess + .subscribe { [weak self] _ in + + self?.snackBar.onNext( + .init(titleText: "지원이 완료되었어요.") + ) + } + .disposed(by: dispostBag) // 지원하기 성공시 새로고침 applyRequestSuccess @@ -155,7 +160,6 @@ public class WorkerRecruitmentPostBoardVM: BaseViewModel, WorkerRecruitmentPostB message: error.message ) } - // MARK: 공고리스트 처음부터 요청하기 let initialRequest = requestInitialPageRequest