diff --git a/Projects/App/Sources/View/AppRootView.swift b/Projects/App/Sources/View/AppRootView.swift index 0a79b272..fe5de3b3 100644 --- a/Projects/App/Sources/View/AppRootView.swift +++ b/Projects/App/Sources/View/AppRootView.swift @@ -50,11 +50,14 @@ private extension AppRootView { if let route = currentRoute { switch route { case .login: - LoginRootView { nickname in - handleLoginCompleted(nickname: nickname) - } onSignupCompleted: { nickname in - handleSignupCompleted(nickname: nickname) - } + LoginContentView( + onLoginCompleted: { nickname in + handleLoginCompleted(nickname: nickname) + }, + onSignupCompleted: { nickname in + handleSignupCompleted(nickname: nickname) + } + ) case .main: LivithMainTabView(nickname: $nickname) diff --git a/Projects/App/Sources/View/LivithMainTabView.swift b/Projects/App/Sources/View/LivithMainTabView.swift index 780656d7..898a859d 100644 --- a/Projects/App/Sources/View/LivithMainTabView.swift +++ b/Projects/App/Sources/View/LivithMainTabView.swift @@ -62,11 +62,11 @@ struct LivithMainTabView: View { var body: some View { ZStack(alignment: .bottom) { TabView(selection: $selectedTab) { - HomeRootView(nickname: $nickname) + HomeContentView(nickname: $nickname, isTabBarHidden: $isTabBarHidden) .tag(Tab.home) .toolbar(.hidden, for: .tabBar) - SearchRootView(isTabBarHidden: $isTabBarHidden) + SearchContentView(isTabBarHidden: $isTabBarHidden) .tag(Tab.search) .toolbar(.hidden, for: .tabBar) diff --git a/Projects/Core/Project.swift b/Projects/Core/Project.swift index 035eca3b..f82f1c49 100644 --- a/Projects/Core/Project.swift +++ b/Projects/Core/Project.swift @@ -32,10 +32,6 @@ let project = Project.make( target: .core(.persistence), product: .framework ), - .make( - target: .core(.router), - product: .framework - ), .make( target: .core(.livithConcurrency), product: .framework diff --git a/Projects/Core/Router/Sources/PresentationStyle.swift b/Projects/Core/Router/Sources/PresentationStyle.swift deleted file mode 100644 index 7c530207..00000000 --- a/Projects/Core/Router/Sources/PresentationStyle.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// PresentationStyle.swift -// core -// -// Created by 김진웅 on 10/12/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import Foundation - -public enum PresentationStyle { - case push - case sheet - case fullScreen -} diff --git a/Projects/Core/Router/Sources/Router.swift b/Projects/Core/Router/Sources/Router.swift deleted file mode 100644 index 98a4de0a..00000000 --- a/Projects/Core/Router/Sources/Router.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// Router.swift -// core -// -// Created by 김진웅 on 10/12/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -@MainActor -public protocol Router: ObservableObject { - associatedtype R: Route - - var path: NavigationPath { get set } - var sheet: R? { get set } - var fullScreenCover: R? { get set } - - @ViewBuilder - func view(to route: R, with style: PresentationStyle) -> AnyView -} - -public extension Router { - func push(_ page: R...) { - page.forEach { - path.append($0) - } - } - - func sheet(_ sheet: R) { - self.sheet = sheet - } - - func fullScreenCover(_ fullScreenCover: R) { - self.fullScreenCover = fullScreenCover - } - - func pop() { - guard !path.isEmpty else { return } - path.removeLast() - } - - func popToRoot() { - guard !path.isEmpty else { return } - let count = path.count - path.removeLast(count) - } - - func dismissSheet() { - sheet = nil - } - - func dismissFullScreen() { - fullScreenCover = nil - } -} diff --git a/Projects/DSKit/Sources/Component/SearchBarView.swift b/Projects/DSKit/Sources/Component/SearchBarView.swift index 58f50b03..e15b6245 100644 --- a/Projects/DSKit/Sources/Component/SearchBarView.swift +++ b/Projects/DSKit/Sources/Component/SearchBarView.swift @@ -14,19 +14,23 @@ public struct SearchBarView: View { @Binding var input: String @FocusState private var isFocused: Bool - var onChange: () -> Void - var onClear: () -> Void - var onSubmit: () -> Void + + private let onBack: () -> Void + private let onChange: () -> Void + private let onClear: () -> Void + private let onSubmit: () -> Void // MARK: - LifeCycle public init( input: Binding, + onBack: @escaping () -> Void, onChange: @escaping () -> Void, onClear: @escaping () -> Void, onSubmit: @escaping () -> Void ) { self._input = input + self.onBack = onBack self.onChange = onChange self.onClear = onClear self.onSubmit = onSubmit @@ -50,7 +54,7 @@ private extension SearchBarView { @ViewBuilder func backButton() -> some View { Button (action: { - // TODO: 화면 전환 구현 + onBack() }) { Image.livithIcon(.backLineDefault) .resizable() diff --git a/Projects/DSKit/Sources/Coordinator/Coordinator.swift b/Projects/DSKit/Sources/Coordinator/Coordinator.swift new file mode 100644 index 00000000..8aa30ffd --- /dev/null +++ b/Projects/DSKit/Sources/Coordinator/Coordinator.swift @@ -0,0 +1,110 @@ +// +// Coordinator.swift +// DSKit +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import UIKit + +/// 화면 전환 흐름을 관리하는 코디네이터 추상화. +/// +/// - Note: `@MainActor`에서 동작하며 모든 UI 관련 트랜잭션을 +/// 메인 스레드에서 안전하게 처리합니다. +/// - Important: 코디네이터는 네비게이션 상태 일관성을 유지해야 하며, +/// `buildViewController(for:)`는 주어진 `Route`에 대한 화면 생성을 +/// 명확하고 예측 가능하게 구현해야 합니다. +/// +/// 제네릭 파라미터 +/// - `R`: 화면 전환을 정의하는 `Route` 타입 +@MainActor +public protocol Coordinator: AnyObject { + associatedtype R: Route + + /// 화면 전환을 주도하는 루트 `UINavigationController`. + var navigationController: UINavigationController { get } + + /// 주어진 `route`에 대응하는 화면을 생성합니다. + /// - Parameter route: 화면을 생성할 `Route` + /// - Returns: 생성된 `UIViewController` + /// + /// - Tip: SwiftUI의 `View`를 사용해 화면을 구성한다면 + /// UIKit과의 호환을 위해 `UIHostingController(rootView:)`로 감싸서 반환하세요. + /// + /// 예시: + /// ```swift + /// func buildViewController(for route: AppRoute) -> UIViewController { + /// switch route { + /// case .home: + /// let view = HomeView(viewModel: HomeViewModel()) + /// return UIHostingController(rootView: view) + /// case .detail(let id): + /// let view = DetailView(id: id) + /// return UIHostingController(rootView: view) + /// } + /// } + /// ``` + func buildViewController(for route: R) -> UIViewController + + /// 코디네이터 시작 지점. 초기 화면 진입 로직을 구현합니다. + func start() +} + +public extension Coordinator { + /// 지정한 `route`로 화면을 푸시합니다. + /// - Parameters: + /// - route: 이동할 대상 `Route` + /// - animated: 애니메이션 여부. 기본값은 `true` + func push(to route: R, animated: Bool = true) { + let viewController = buildViewController(for: route) + navigationController.pushViewController(viewController, animated: true) + } + + /// 현재 화면을 하나 뒤로 되돌립니다. + /// - Parameter animated: 애니메이션 여부. 기본값은 `true` + func pop(animated: Bool = true) { + navigationController.popViewController(animated: animated) + } + + /// 루트 화면까지 되돌립니다. + /// - Parameter animated: 애니메이션 여부. 기본값은 `true` + func popToRoot(animated: Bool = true) { + navigationController.popToRootViewController(animated: animated) + } + + /// 지정한 `route`를 모달로 표시합니다. + /// - Parameters: + /// - route: 표시할 대상 `Route` + /// - animated: 애니메이션 여부. 기본값은 `true` + /// - presentationStyle: `UIModalPresentationStyle` 지정 시 해당 스타일로 표시 + /// - transitionStyle: `UIModalTransitionStyle` 지정 시 해당 전환 효과 적용 + /// - completion: 표시 완료 후 호출되는 클로저 + func present( + to route: R, + animated: Bool = true, + presentationStyle: UIModalPresentationStyle? = nil, + transitionStyle: UIModalTransitionStyle? = nil, + completion: (() -> Void)? = nil + ) { + let viewController = buildViewController(for: route) + + if let style = presentationStyle { + viewController.modalPresentationStyle = style + } + + if let style = transitionStyle { + viewController.modalTransitionStyle = style + } + + navigationController.present(viewController, animated: animated, completion: completion) + } + + /// 현재 표시 중인 모달을 닫습니다. + /// - Parameters: + /// - animated: 애니메이션 여부. 기본값은 `true` + /// - completion: 닫기 완료 후 호출되는 클로저 + func dismiss(animated: Bool = true, completion: (() -> Void)? = nil) { + navigationController.dismiss(animated: animated, completion: completion) + } +} diff --git a/Projects/Core/Router/Sources/Route.swift b/Projects/DSKit/Sources/Coordinator/Route.swift similarity index 50% rename from Projects/Core/Router/Sources/Route.swift rename to Projects/DSKit/Sources/Coordinator/Route.swift index 13b619c5..886e9c6c 100644 --- a/Projects/Core/Router/Sources/Route.swift +++ b/Projects/DSKit/Sources/Coordinator/Route.swift @@ -1,11 +1,11 @@ // // Route.swift -// core +// DSKit // -// Created by 김진웅 on 10/12/25. +// Created by 김진웅 on 12/27/25. // Copyright © 2025 Livith. All rights reserved. // import Foundation -public protocol Route: Hashable, Identifiable {} +public protocol Route: Hashable {} diff --git a/Projects/Home/HomeFeature/Sources/Coordinator/EnvironmentValues+HomeCoordinator.swift b/Projects/Home/HomeFeature/Sources/Coordinator/EnvironmentValues+HomeCoordinator.swift new file mode 100644 index 00000000..b229bac8 --- /dev/null +++ b/Projects/Home/HomeFeature/Sources/Coordinator/EnvironmentValues+HomeCoordinator.swift @@ -0,0 +1,20 @@ +// +// EnvironmentValues+HomeCoordinator.swift +// HomeFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI + +private struct HomeCoordinatorKey: EnvironmentKey { + static let defaultValue: HomeCoordinator? = nil +} + +extension EnvironmentValues { + var homeCoordinator: HomeCoordinator? { + get { self[HomeCoordinatorKey.self] } + set { self[HomeCoordinatorKey.self] = newValue } + } +} diff --git a/Projects/Home/HomeFeature/Sources/Coordinator/HomeContentView.swift b/Projects/Home/HomeFeature/Sources/Coordinator/HomeContentView.swift new file mode 100644 index 00000000..a3b19174 --- /dev/null +++ b/Projects/Home/HomeFeature/Sources/Coordinator/HomeContentView.swift @@ -0,0 +1,68 @@ +// +// HomeContentView.swift +// HomeFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI +import UIKit + +public struct HomeContentView: View { + @State private var coordinator: HomeCoordinator + @Binding private var isTabBarHidden: Bool + + public init(nickname: Binding, isTabBarHidden: Binding) { + self._coordinator = State(initialValue: HomeCoordinator(nickname: nickname)) + self._isTabBarHidden = isTabBarHidden + } + + public var body: some View { + HomeNavigationHost(coordinator: coordinator, isTabBarHidden: $isTabBarHidden) + .ignoresSafeArea() + } +} + +// MARK: - NavigationHost + +private extension HomeContentView { + struct HomeNavigationHost: UIViewControllerRepresentable { + let coordinator: HomeCoordinator + @Binding var isTabBarHidden: Bool + + func makeCoordinator() -> NavDelegate { + NavDelegate(isTabBarHidden: $isTabBarHidden) + } + + final class NavDelegate: NSObject, UINavigationControllerDelegate { + @Binding var isTabBarHidden: Bool + + init(isTabBarHidden: Binding) { + self._isTabBarHidden = isTabBarHidden + } + + func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + let stackCount = navigationController.viewControllers.count + Task { @MainActor in + isTabBarHidden = stackCount > 1 + } + } + } + + func makeUIViewController(context: Context) -> UINavigationController { + let nav = coordinator.navigationController + nav.delegate = context.coordinator + if nav.viewControllers.isEmpty { + coordinator.start() + } + return nav + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} + } +} diff --git a/Projects/Home/HomeFeature/Sources/Coordinator/HomeCoordinator.swift b/Projects/Home/HomeFeature/Sources/Coordinator/HomeCoordinator.swift new file mode 100644 index 00000000..bba56fee --- /dev/null +++ b/Projects/Home/HomeFeature/Sources/Coordinator/HomeCoordinator.swift @@ -0,0 +1,40 @@ +// +// HomeCoordinator.swift +// HomeFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI + +import DSKit + +final class HomeCoordinator: Coordinator { + typealias R = HomeRoute + + let navigationController: UINavigationController + + private let nickname: Binding + + init(nickname: Binding) { + self.navigationController = UINavigationController() + self.nickname = nickname + + self.navigationController.setNavigationBarHidden(true, animated: false) + } + + func start() { + push(to: .home, animated: false) + } + + func buildViewController(for route: R) -> UIViewController { + switch route { + case .home: + return UIHostingController(rootView: HomeView(nickname: nickname).environment(\.homeCoordinator, self)) + + case .interest: + return UIHostingController(rootView: InterestTempView().environment(\.homeCoordinator, self)) + } + } +} diff --git a/Projects/Home/HomeFeature/Sources/Coordinator/HomeRoute.swift b/Projects/Home/HomeFeature/Sources/Coordinator/HomeRoute.swift new file mode 100644 index 00000000..edb92164 --- /dev/null +++ b/Projects/Home/HomeFeature/Sources/Coordinator/HomeRoute.swift @@ -0,0 +1,16 @@ +// +// HomeRoute.swift +// HomeFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import Foundation + +import DSKit + +enum HomeRoute: Route { + case home + case interest +} diff --git a/Projects/Home/HomeFeature/Sources/Home/View/HomeView.swift b/Projects/Home/HomeFeature/Sources/Home/View/HomeView.swift index 6bd78e63..97df9419 100644 --- a/Projects/Home/HomeFeature/Sources/Home/View/HomeView.swift +++ b/Projects/Home/HomeFeature/Sources/Home/View/HomeView.swift @@ -12,7 +12,7 @@ import DSKit import HomeDomain struct HomeView: View { - @Environment(HomeRouter.self) var router + @Environment(\.homeCoordinator) var coordinator @Binding var nickname: String @@ -27,7 +27,7 @@ struct HomeView: View { HomeHeaderView( nickname: nickname, action: { - // TODO: 관심 콘서트 설정 화면으로 이동 + coordinator?.push(to: .interest) } ) @@ -70,7 +70,7 @@ private extension HomeView { #Preview { let nickname = Binding.constant("유지미") - let router = HomeRouter(nickname: nickname) - return HomeView(nickname: nickname) - .environment(router) + let coordinator = HomeCoordinator(nickname: nickname) + HomeView(nickname: nickname) + .environment(\.homeCoordinator, coordinator) } diff --git a/Projects/Home/HomeFeature/Sources/Interest/View/InterestTempView.swift b/Projects/Home/HomeFeature/Sources/Interest/View/InterestTempView.swift new file mode 100644 index 00000000..599c7ed6 --- /dev/null +++ b/Projects/Home/HomeFeature/Sources/Interest/View/InterestTempView.swift @@ -0,0 +1,60 @@ +// +// InterestTempView.swift +// HomeFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI + +import DSKit + +struct InterestTempView: View { + @Environment(\.homeCoordinator) var coordinator + + var body: some View { + VStack(spacing: .zero) { + HStack { + Button(action: { + coordinator?.pop() + }) { + HStack(spacing: 6) { + Image(systemName: "chevron.left") + .font(.system(size: 18, weight: .semibold)) + Text("뒤로") + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Color(.systemGray).opacity(0.15)) + .cornerRadius(8) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 8) + + Spacer().frame(height: 24) + + Text("임시 Interest 뷰") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.top, 16) + + Spacer() + } + .background(.livithColor(.black90)) + .ignoresSafeArea(edges: .bottom) + } +} + +#Preview { + let nickname = Binding.constant("유지미") + let coordinator = HomeCoordinator(nickname: nickname) + InterestTempView() + .environment(\.homeCoordinator, coordinator) +} diff --git a/Projects/Home/HomeFeature/Sources/Router/HomeRootView.swift b/Projects/Home/HomeFeature/Sources/Router/HomeRootView.swift deleted file mode 100644 index 5ef7bbe2..00000000 --- a/Projects/Home/HomeFeature/Sources/Router/HomeRootView.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// HomeRootView.swift -// HomeFeature -// -// Created by 김진웅 on 12/24/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -import Router - -public struct HomeRootView: View { - @State private var router: HomeRouter - - public init(nickname: Binding) { - _router = State(initialValue: HomeRouter(nickname: nickname)) - } - - public var body: some View { - NavigationStack(path: $router.path) { - router.view(to: .home, with: .push) - .navigationDestination(for: HomeRoute.self) { route in - router.view(to: route, with: .push) - .navigationBarHidden(true) - } - } - .sheet(item: $router.sheet) { route in - router.view(to: route, with: .sheet) - } - .fullScreenCover(item: $router.fullScreenCover) { route in - router.view(to: route, with: .fullScreen) - } - .environment(router) - } -} diff --git a/Projects/Home/HomeFeature/Sources/Router/HomeRoute.swift b/Projects/Home/HomeFeature/Sources/Router/HomeRoute.swift deleted file mode 100644 index 83bc2ecc..00000000 --- a/Projects/Home/HomeFeature/Sources/Router/HomeRoute.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// HomeRoute.swift -// HomeFeature -// -// Created by 김진웅 on 12/24/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import Foundation - -import Router - -enum HomeRoute: Route { - case home - - var id: String { - switch self { - case .home: - return "home" - } - } -} diff --git a/Projects/Home/HomeFeature/Sources/Router/HomeRouter.swift b/Projects/Home/HomeFeature/Sources/Router/HomeRouter.swift deleted file mode 100644 index 2f027776..00000000 --- a/Projects/Home/HomeFeature/Sources/Router/HomeRouter.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// HomeRouter.swift -// HomeFeature -// -// Created by 김진웅 on 12/24/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI -import Observation - -import Router - -@Observable -final class HomeRouter: Router { - typealias R = HomeRoute - - var path: NavigationPath = .init() - var sheet: HomeRoute? - var fullScreenCover: HomeRoute? - - private let nickname: Binding - - init(nickname: Binding) { - self.nickname = nickname - } - - func view(to route: HomeRoute, with style: PresentationStyle) -> AnyView { - switch route { - case .home: - AnyView(HomeView(nickname: nickname)) - } - } -} diff --git a/Projects/Home/Project.swift b/Projects/Home/Project.swift index 70eeb40f..a73b00b1 100644 --- a/Projects/Home/Project.swift +++ b/Projects/Home/Project.swift @@ -28,8 +28,7 @@ let project = Project.make( .home(.homeDomain), .dsKit(), .core(.diContainer), - .core(.livithConcurrency), - .core(.router) + .core(.livithConcurrency) ] ) ] diff --git a/Projects/Login/LoginFeature/Sources/Coordinator/EnvironmentValues+LoginCoordinator.swift b/Projects/Login/LoginFeature/Sources/Coordinator/EnvironmentValues+LoginCoordinator.swift new file mode 100644 index 00000000..70b12ef4 --- /dev/null +++ b/Projects/Login/LoginFeature/Sources/Coordinator/EnvironmentValues+LoginCoordinator.swift @@ -0,0 +1,20 @@ +// +// EnvironmentValues+LoginCoordinator.swift +// LoginFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI + +private struct LoginCoordinatorKey: EnvironmentKey { + static let defaultValue: LoginCoordinator? = nil +} + +extension EnvironmentValues { + var loginCoordinator: LoginCoordinator? { + get { self[LoginCoordinatorKey.self] } + set { self[LoginCoordinatorKey.self] = newValue } + } +} diff --git a/Projects/Login/LoginFeature/Sources/Coordinator/LoginContentView.swift b/Projects/Login/LoginFeature/Sources/Coordinator/LoginContentView.swift new file mode 100644 index 00000000..4c978bd8 --- /dev/null +++ b/Projects/Login/LoginFeature/Sources/Coordinator/LoginContentView.swift @@ -0,0 +1,49 @@ +// +// LoginContentView.swift +// LoginFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI +import UIKit + +public struct LoginContentView: View { + @State private var coordinator: LoginCoordinator + + public init( + onLoginCompleted: @escaping (String) -> Void, + onSignupCompleted: @escaping (String) -> Void + ) { + _coordinator = State( + initialValue: LoginCoordinator( + onLoginCompleted: onLoginCompleted, + onSignupCompleted: onSignupCompleted + ) + ) + } + + public var body: some View { + LoginNavigationHost(coordinator: coordinator) + .ignoresSafeArea() + } +} + +// MARK: - NavigationHost + +private extension LoginContentView { + struct LoginNavigationHost: UIViewControllerRepresentable { + let coordinator: LoginCoordinator + + func makeUIViewController(context: Context) -> UINavigationController { + let nav = coordinator.navigationController + if nav.viewControllers.isEmpty { + coordinator.start() + } + return nav + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} + } +} diff --git a/Projects/Login/LoginFeature/Sources/Coordinator/LoginCoordinator.swift b/Projects/Login/LoginFeature/Sources/Coordinator/LoginCoordinator.swift new file mode 100644 index 00000000..55616f76 --- /dev/null +++ b/Projects/Login/LoginFeature/Sources/Coordinator/LoginCoordinator.swift @@ -0,0 +1,93 @@ +// +// LoginCoordinator.swift +// LoginFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI +import UIKit + +import DSKit +import LoginDomain + +final class LoginCoordinator: Coordinator { + typealias R = LoginRoute + + let navigationController: UINavigationController + + private var tempUser: TempUser? + + private let onLoginCompleted: ((String) -> Void) + private let onSignupCompleted: ((String) -> Void) + + init( + onLoginCompleted: @escaping (String) -> Void = { _ in }, + onSignupCompleted: @escaping (String) -> Void = { _ in } + ) { + self.navigationController = UINavigationController() + self.onLoginCompleted = onLoginCompleted + self.onSignupCompleted = onSignupCompleted + + self.navigationController.setNavigationBarHidden(true, animated: false) + } + + func start() { + push(to: .login, animated: false) + } + + func buildViewController(for route: LoginRoute) -> UIViewController { + switch route { + case .login: + return UIHostingController(rootView: LoginView().environment(\.loginCoordinator, self)) + + case .loginForbidden: + return UIHostingController( + rootView: ErrorSheetView( + title: "탈퇴 후 7일이 지나지 않았어요", + message: "7일이 지난 후 다시 시도해주세요" + ) { [weak self] in + self?.dismiss() + } + ) + + case .terms(let tempUser): + self.tempUser = tempUser + return UIHostingController(rootView: TermsView().environment(\.loginCoordinator, self)) + + case .nickname(let marketingConsent): + guard let tempUser = tempUser else { + return UIHostingController(rootView: EmptyView()) + } + + let store = NicknameSettingStore(marketingConsent: marketingConsent, tempUser: tempUser) + return UIHostingController(rootView: NicknameSettingView(store: store).environment(\.loginCoordinator, self)) + + case .signupFailed: + return UIHostingController( + rootView: ErrorSheetView( + title: "오류가 발생했어요!", + message: "잠시 후 다시 시도해주세요", + confirmTitle: "로그인으로 돌아가기" + ) { [weak self] in + self?.dismiss(completion: { self?.popToRoot() }) + } + ) + + case .safari(let url): + let safariView = SafariView(url: url) { [weak self] in + self?.dismiss() + }.ignoresSafeArea() + return UIHostingController(rootView: safariView) + } + } + + func completeLogin(with nickname: String) { + onLoginCompleted(nickname) + } + + func completeSignup(with nickname: String) { + onSignupCompleted(nickname) + } +} diff --git a/Projects/Login/LoginFeature/Sources/Coordinator/LoginRoute.swift b/Projects/Login/LoginFeature/Sources/Coordinator/LoginRoute.swift new file mode 100644 index 00000000..18a58cd0 --- /dev/null +++ b/Projects/Login/LoginFeature/Sources/Coordinator/LoginRoute.swift @@ -0,0 +1,21 @@ +// +// LoginRoute.swift +// LoginFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import Foundation + +import DSKit +import LoginDomain + +enum LoginRoute: Route { + case login + case loginForbidden + case terms(TempUser) + case nickname(Bool) + case signupFailed + case safari(URL) +} diff --git a/Projects/Login/LoginFeature/Sources/Login/View/LoginView.swift b/Projects/Login/LoginFeature/Sources/Login/View/LoginView.swift index c53dce43..ed4b01d9 100644 --- a/Projects/Login/LoginFeature/Sources/Login/View/LoginView.swift +++ b/Projects/Login/LoginFeature/Sources/Login/View/LoginView.swift @@ -13,7 +13,7 @@ import LoginDomain struct LoginView: View { @StateObject private var store = LoginStore() - @EnvironmentObject private var router: LoginRouter + @Environment(\.loginCoordinator) private var coordinator var body: some View { ZStack { @@ -46,11 +46,11 @@ struct LoginView: View { private func handleLoginSuccess(_ status: LoginStatus) { switch status { case .existingUser(let nickname): - router.completeLogin(nickname: nickname) + coordinator?.completeLogin(with: nickname) case .newUser(let tempUser): - router.push(.terms(tempUser)) + coordinator?.push(to: .terms(tempUser)) case .forbidden: - router.fullScreenCover(.loginForbidden) + coordinator?.present(to: .loginForbidden, presentationStyle: .fullScreen, transitionStyle: .crossDissolve) } } } diff --git a/Projects/Login/LoginFeature/Sources/Onboarding/View/NicknameSettingView.swift b/Projects/Login/LoginFeature/Sources/Onboarding/View/NicknameSettingView.swift index 933ff70a..df4bd05a 100644 --- a/Projects/Login/LoginFeature/Sources/Onboarding/View/NicknameSettingView.swift +++ b/Projects/Login/LoginFeature/Sources/Onboarding/View/NicknameSettingView.swift @@ -13,7 +13,7 @@ import LoginDomain struct NicknameSettingView: View { @ObservedObject var store: NicknameSettingStore - @EnvironmentObject private var router: LoginRouter + @Environment(\.loginCoordinator) private var coordinator @FocusState private var isNicknameFocused: Bool private let maxNicknameLength = 10 @@ -62,13 +62,19 @@ struct NicknameSettingView: View { duration: 2 ) .ignoresSafeArea(.all, edges: .bottom) - .onChange(of: store.state.signupStatus) { oldValue, newValue in + .onChange(of: store.state.signupStatus) { + oldValue, + newValue in switch newValue { case .success: - router.completeSignup(nickname: store.state.nickname) + coordinator?.completeSignup(with: store.state.nickname) case .failure: - router.fullScreenCover(.signupFailed) + coordinator?.present( + to: .signupFailed, + presentationStyle: .overFullScreen, + transitionStyle: .crossDissolve + ) default: break @@ -83,7 +89,7 @@ private extension NicknameSettingView { var navigationBar: some View { HStack { Button(action: { - router.pop() + coordinator?.pop() }) { Image.livithIcon(.backLineDefault) .foregroundColor(.livithColor(.white100)) diff --git a/Projects/Login/LoginFeature/Sources/Onboarding/View/TermsView.swift b/Projects/Login/LoginFeature/Sources/Onboarding/View/TermsView.swift index f1733930..9bea9a23 100644 --- a/Projects/Login/LoginFeature/Sources/Onboarding/View/TermsView.swift +++ b/Projects/Login/LoginFeature/Sources/Onboarding/View/TermsView.swift @@ -12,7 +12,7 @@ import DSKit struct TermsView: View { @StateObject private var store = TermsStore() - @EnvironmentObject private var router: LoginRouter + @Environment(\.loginCoordinator) private var coordinator @Environment(\.openURL) private var openURL var body: some View { @@ -53,7 +53,7 @@ private extension TermsView { var navigationBar: some View { HStack { Button { - router.pop() + coordinator?.pop() } label: { Image.livithIcon(.backLineDefault) .foregroundColor(.livithColor(.white100)) @@ -132,7 +132,7 @@ private extension TermsView { Button { guard let url = URL(string: Literals.termsURLString) else { return } - router.sheet(.safari(url)) + coordinator?.present(to: .safari(url)) } label: { Text(Literals.moreButtonText) .notosans(.caption2Semibold) @@ -158,7 +158,7 @@ private extension TermsView { var nextButton: some View { Button { - router.push(.nickname(store.state.isMarketingAgreed)) + coordinator?.push(to: .nickname(store.state.isMarketingAgreed)) } label: { Text(Literals.nextButtonText) .notosans(.body2Medium) diff --git a/Projects/Login/LoginFeature/Sources/Route/LoginRootView.swift b/Projects/Login/LoginFeature/Sources/Route/LoginRootView.swift deleted file mode 100644 index 65318c41..00000000 --- a/Projects/Login/LoginFeature/Sources/Route/LoginRootView.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// LoginRootView.swift -// LoginFeature -// -// Created by 김진웅 on 12/8/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import Foundation -import SwiftUI - -import Router - -public struct LoginRootView: View { - @StateObject private var router: LoginRouter - - public init( - onLoginCompleted: @escaping (String) -> Void, - onSignupCompleted: @escaping (String) -> Void - ) { - _router = StateObject( - wrappedValue: LoginRouter( - onLoginCompleted: onLoginCompleted, - onSignupCompleted: onSignupCompleted - ) - ) - } - - public var body: some View { - NavigationStack(path: $router.path) { - router.view(to: .login, with: .push) - .navigationDestination(for: LoginRoute.self) { route in - router.view(to: route, with: .push) - .navigationBarHidden(true) - } - } - .sheet(item: $router.sheet) { route in - router.view(to: route, with: .sheet) - } - .fullScreenCover(item: $router.fullScreenCover) { route in - router.view(to: route, with: .fullScreen) - } - .environmentObject(router) - } -} diff --git a/Projects/Login/LoginFeature/Sources/Route/LoginRoute.swift b/Projects/Login/LoginFeature/Sources/Route/LoginRoute.swift deleted file mode 100644 index 6a03e77f..00000000 --- a/Projects/Login/LoginFeature/Sources/Route/LoginRoute.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// LoginRoute.swift -// LoginFeature -// -// Created by 김진웅 on 12/8/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import Foundation - -import Router -import LoginDomain - -enum LoginRoute: Route { - case login - case loginForbidden - case terms(TempUser) - case nickname(Bool) - case signupFailed - case safari(URL) - - var id: String { - switch self { - case .login: "login" - case .loginForbidden: "loginForbidden" - case .terms: "terms" - case .nickname: "nickname" - case .signupFailed: "signupFailed" - case .safari: "safari" - } - } -} diff --git a/Projects/Login/LoginFeature/Sources/Route/LoginRouter.swift b/Projects/Login/LoginFeature/Sources/Route/LoginRouter.swift deleted file mode 100644 index ac9f1bd9..00000000 --- a/Projects/Login/LoginFeature/Sources/Route/LoginRouter.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// LoginRouter.swift -// LoginFeature -// -// Created by 김진웅 on 12/8/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import Foundation -import SwiftUI - -import Router -import LoginDomain -import DSKit - -@MainActor -final class LoginRouter: Router { - typealias Route = LoginRoute - - @Published var path: NavigationPath = NavigationPath() - @Published var sheet: Route? - @Published var fullScreenCover: Route? - - private let onLoginCompleted: (String) -> Void - private let onSignupCompleted: (String) -> Void - - init( - onLoginCompleted: @escaping (String) -> Void = { _ in }, - onSignupCompleted: @escaping (String) -> Void = { _ in } - ) { - self.onLoginCompleted = onLoginCompleted - self.onSignupCompleted = onSignupCompleted - } - - private var tempUser: TempUser? - - func view(to route: Route, with style: PresentationStyle) -> AnyView { - switch route { - case .login: - return AnyView(LoginView()) - - case .loginForbidden: - return AnyView( - ErrorSheetView( - title: "탈퇴 후 7일이 지나지 않았어요", - message: "7일이 지난 후 다시 시도해주세요" - ) { [weak self] in - self?.dismissFullScreen() - } - ) - - case .terms(let tempUser): - self.tempUser = tempUser - - return AnyView(TermsView()) - - case .nickname(let marketingConsent): - guard let tempUser = tempUser else { - return AnyView(EmptyView()) - } - - let store = NicknameSettingStore(marketingConsent: marketingConsent, tempUser: tempUser) - return AnyView(NicknameSettingView(store: store)) - - case .signupFailed: - return AnyView( - ErrorSheetView( - title: "오류가 발생했어요!", - message: "잠시 후 다시 시도해주세요", - confirmTitle: "로그인으로 돌아가기" - ) { [weak self] in - self?.popToRoot() - self?.dismissFullScreen() - } - ) - - case .safari(let url): - let safariView = SafariView(url: url) { [weak self] in - self?.dismissSheet() - } - .ignoresSafeArea() - - return AnyView(safariView) - } - } - - func completeLogin(nickname: String) { - onLoginCompleted(nickname) - } - - func completeSignup(nickname: String) { - onSignupCompleted(nickname) - } -} diff --git a/Projects/Login/Project.swift b/Projects/Login/Project.swift index 16ba9099..b0c3cc16 100644 --- a/Projects/Login/Project.swift +++ b/Projects/Login/Project.swift @@ -32,7 +32,6 @@ let project = Project.make( dependencies: [ .login(.loginDomain), .dsKit(), - .core(.router), .core(.diContainer) ] ) diff --git a/Projects/Search/Project.swift b/Projects/Search/Project.swift index 3fc9ecaf..383252a9 100644 --- a/Projects/Search/Project.swift +++ b/Projects/Search/Project.swift @@ -31,8 +31,7 @@ let project = Project.make( .search(.searchDomain), .dsKit(), .core(.diContainer), - .core(.livithConcurrency), - .core(.router) + .core(.livithConcurrency) ] ) ] diff --git a/Projects/Search/SearchFeature/Sources/Coordinator/EnvironmentValues+SearchCoordinator.swift b/Projects/Search/SearchFeature/Sources/Coordinator/EnvironmentValues+SearchCoordinator.swift new file mode 100644 index 00000000..ceb653f0 --- /dev/null +++ b/Projects/Search/SearchFeature/Sources/Coordinator/EnvironmentValues+SearchCoordinator.swift @@ -0,0 +1,20 @@ +// +// EnvironmentValues+SearchCoordinator.swift +// SearchFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI + +private struct SearchCoordinatorKey: EnvironmentKey { + static let defaultValue: SearchCoordinator? = nil +} + +extension EnvironmentValues { + var searchCoordinator: SearchCoordinator? { + get { self[SearchCoordinatorKey.self] } + set { self[SearchCoordinatorKey.self] = newValue } + } +} diff --git a/Projects/Search/SearchFeature/Sources/Coordinator/SearchContentView.swift b/Projects/Search/SearchFeature/Sources/Coordinator/SearchContentView.swift new file mode 100644 index 00000000..7e7b23d5 --- /dev/null +++ b/Projects/Search/SearchFeature/Sources/Coordinator/SearchContentView.swift @@ -0,0 +1,68 @@ +// +// SearchContentView.swift +// SearchFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI + +import DSKit + +public struct SearchContentView: View { + @State private var coordinator: SearchCoordinator = SearchCoordinator() + @Binding private var isTabBarHidden: Bool + + public init(isTabBarHidden: Binding) { + self._isTabBarHidden = isTabBarHidden + } + + public var body: some View { + SearchNavigationHost(coordinator: coordinator, isTabBarHidden: $isTabBarHidden) + .ignoresSafeArea() + } +} + +// MARK: - NavigationHost + +private extension SearchContentView { + struct SearchNavigationHost: UIViewControllerRepresentable { + let coordinator: SearchCoordinator + @Binding var isTabBarHidden: Bool + + func makeCoordinator() -> NavDelegate { + NavDelegate(isTabBarHidden: $isTabBarHidden) + } + + final class NavDelegate: NSObject, UINavigationControllerDelegate { + @Binding var isTabBarHidden: Bool + + init(isTabBarHidden: Binding) { + self._isTabBarHidden = isTabBarHidden + } + + func navigationController( + _ navigationController: UINavigationController, + willShow viewController: UIViewController, + animated: Bool + ) { + let stackCount = navigationController.viewControllers.count + Task { @MainActor in + self.isTabBarHidden = stackCount > 1 + } + } + } + + func makeUIViewController(context: Context) -> UINavigationController { + let nav = coordinator.navigationController + nav.delegate = context.coordinator + if nav.viewControllers.isEmpty { + coordinator.start() + } + return nav + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} + } +} diff --git a/Projects/Search/SearchFeature/Sources/Coordinator/SearchCoordinator.swift b/Projects/Search/SearchFeature/Sources/Coordinator/SearchCoordinator.swift new file mode 100644 index 00000000..e9679a95 --- /dev/null +++ b/Projects/Search/SearchFeature/Sources/Coordinator/SearchCoordinator.swift @@ -0,0 +1,37 @@ +// +// SearchCoordinator.swift +// SearchFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import SwiftUI +import UIKit + +import DSKit + +final class SearchCoordinator: Coordinator { + typealias R = SearchRoute + + let navigationController: UINavigationController + + init() { + self.navigationController = UINavigationController() + + self.navigationController.setNavigationBarHidden(true, animated: false) + } + + func start() { + push(to: .explore, animated: false) + } + + func buildViewController(for route: SearchRoute) -> UIViewController { + switch route { + case .explore: + return UIHostingController(rootView: ExploreView().environment(\.searchCoordinator, self)) + case .search: + return UIHostingController(rootView: SearchView(store: .init()).environment(\.searchCoordinator, self)) + } + } +} diff --git a/Projects/Search/SearchFeature/Sources/Coordinator/SearchRoute.swift b/Projects/Search/SearchFeature/Sources/Coordinator/SearchRoute.swift new file mode 100644 index 00000000..ea744604 --- /dev/null +++ b/Projects/Search/SearchFeature/Sources/Coordinator/SearchRoute.swift @@ -0,0 +1,16 @@ +// +// SearchRoute.swift +// SearchFeature +// +// Created by 김진웅 on 12/27/25. +// Copyright © 2025 Livith. All rights reserved. +// + +import Foundation + +import DSKit + +enum SearchRoute: Route { + case explore + case search +} diff --git a/Projects/Search/SearchFeature/Sources/Explore/View/ExploreView.swift b/Projects/Search/SearchFeature/Sources/Explore/View/ExploreView.swift index 389ef1e5..e16219aa 100644 --- a/Projects/Search/SearchFeature/Sources/Explore/View/ExploreView.swift +++ b/Projects/Search/SearchFeature/Sources/Explore/View/ExploreView.swift @@ -12,7 +12,7 @@ import DSKit import SearchDomain struct ExploreView: View { - @EnvironmentObject private var router: SearchRouter + @Environment(\.searchCoordinator) private var coordinator @StateObject private var store: ExploreStore = ExploreStore() @State private var scrollOffset: CGFloat = 0 @@ -21,7 +21,7 @@ struct ExploreView: View { LivithLogoHeaderView() ZStack(alignment: .top) { - searchButton + ExploreSearchButton(onTap: { coordinator?.push(to: .search) }) .zIndex(2) .background( scrollOffset > Constants.bannerHeight - 60 @@ -45,12 +45,6 @@ private extension ExploreView { .scaleEffect(1.2) } - var searchButton: some View { - ExploreSearchButton(onTap: { - // TODO: Router를 이용한 검색 화면 이동 - }) - } - var scrollContent: some View { ScrollView(showsIndicators: false) { VStack(spacing: 0) { diff --git a/Projects/Search/SearchFeature/Sources/Route/SearchRootView.swift b/Projects/Search/SearchFeature/Sources/Route/SearchRootView.swift deleted file mode 100644 index 60ce7d41..00000000 --- a/Projects/Search/SearchFeature/Sources/Route/SearchRootView.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// SearchRootView.swift -// SearchFeature -// -// Created by 김진웅 on 12/18/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -import Router - -public struct SearchRootView: View { - @StateObject private var router: SearchRouter = SearchRouter() - - @Binding private var isTabBarHidden: Bool - - public init(isTabBarHidden: Binding) { - self._isTabBarHidden = isTabBarHidden - } - - public var body: some View { - NavigationStack(path: $router.path) { - router.view(to: .explore, with: .push) - .navigationDestination(for: SearchRoute.self) { route in - router.view(to: route, with: .push) - .navigationBarHidden(true) - } - } - .sheet(item: $router.sheet) { route in - router.view(to: route, with: .sheet) - } - .fullScreenCover(item: $router.fullScreenCover) { route in - router.view(to: route, with: .fullScreen) - } - .environmentObject(router) - .onChange(of: router.path) { oldValue, newValue in - isTabBarHidden = !newValue.isEmpty - } - } -} diff --git a/Projects/Search/SearchFeature/Sources/Route/SearchRoute.swift b/Projects/Search/SearchFeature/Sources/Route/SearchRoute.swift deleted file mode 100644 index 72bba873..00000000 --- a/Projects/Search/SearchFeature/Sources/Route/SearchRoute.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// SearchRoute.swift -// SearchFeature -// -// Created by 김진웅 on 12/18/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import Foundation - -import Router - -enum SearchRoute: Route { - case explore - case search - - var id: String { - switch self { - case .explore: - return "explore" - case .search: - return "search" - } - } -} diff --git a/Projects/Search/SearchFeature/Sources/Route/SearchRouter.swift b/Projects/Search/SearchFeature/Sources/Route/SearchRouter.swift deleted file mode 100644 index 641ab2b3..00000000 --- a/Projects/Search/SearchFeature/Sources/Route/SearchRouter.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// SearchRouter.swift -// SearchFeature -// -// Created by 김진웅 on 12/18/25. -// Copyright © 2025 Livith. All rights reserved. -// - -import SwiftUI - -import Router - -@MainActor -final class SearchRouter: Router { - typealias R = SearchRoute - - @Published var path = NavigationPath() - @Published var sheet: R? - @Published var fullScreenCover: R? - - func view(to route: SearchRoute, with style: PresentationStyle) -> AnyView { - switch route { - case .explore: - return AnyView( - ExploreView() - ) - case .search: - return AnyView( - SearchView(store: .init()) - ) - } - } -} diff --git a/Projects/Search/SearchFeature/Sources/Search/View/SearchView.swift b/Projects/Search/SearchFeature/Sources/Search/View/SearchView.swift index 05622fd5..1b05b232 100644 --- a/Projects/Search/SearchFeature/Sources/Search/View/SearchView.swift +++ b/Projects/Search/SearchFeature/Sources/Search/View/SearchView.swift @@ -16,6 +16,7 @@ struct SearchView: View { // MARK: - Property + @Environment(\.searchCoordinator) private var coordinator @ObservedObject private var store: SearchStore @State private var showError: Bool = false @@ -103,6 +104,9 @@ private extension SearchView { get: { store.state.searchMessage }, set: { store.send(.updateSearchMessage($0)) } ), + onBack: { + coordinator?.pop() + }, onChange: { if isCompleteKorean() { performSearch() } }, diff --git a/Tuist/ProjectDescriptionHelpers/Module/Module+Constant.swift b/Tuist/ProjectDescriptionHelpers/Module/Module+Constant.swift index 201c8bba..58d3a92d 100644 --- a/Tuist/ProjectDescriptionHelpers/Module/Module+Constant.swift +++ b/Tuist/ProjectDescriptionHelpers/Module/Module+Constant.swift @@ -12,7 +12,6 @@ import ProjectDescription public enum CoreModule: String { case diContainer = "DIContainer" case performanceMonitor = "PerformanceMonitor" - case router = "Router" case persistence = "Persistence" case livithNetwork = "LivithNetwork" case livithConcurrency = "LivithConcurrency"