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