From d820d57d2fcb76860267f9efa2b0210c1e8b966e Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 11 Aug 2025 16:42:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8[feat]:=20=EA=B3=A7=ED=86=B5=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=93=B8=20=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 공통으로 사용할 화면 전환 로직 코디네이터 구현 * navigationStack 방식으로 구현 --- .../Application/TeamIntroduceApp.swift | 16 +---- .../Navigation/NavigationControlling.swift | 69 +++++++++++++++++++ .../Flow/IntroduceCoordinator.swift | 58 ++++++++++++++++ .../Coordinator/Flow/IntroduceRoute.swift | 45 ++++++++++++ .../View/IntorduceCoordinatorView.swift | 50 ++++++++++++++ .../IntroduceMain/View/ContentView.swift | 62 +++++++---------- .../Sources/Presnetaion/Root/RootView.swift | 20 ++++++ 7 files changed, 266 insertions(+), 54 deletions(-) create mode 100644 TeamIntroduce/TeamIntroduce/Sources/Common/Navigation/NavigationControlling.swift create mode 100644 TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceCoordinator.swift create mode 100644 TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceRoute.swift create mode 100644 TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/View/IntorduceCoordinatorView.swift create mode 100644 TeamIntroduce/TeamIntroduce/Sources/Presnetaion/Root/RootView.swift diff --git a/TeamIntroduce/TeamIntroduce/Sources/Application/TeamIntroduceApp.swift b/TeamIntroduce/TeamIntroduce/Sources/Application/TeamIntroduceApp.swift index 3cabac3..0adc934 100644 --- a/TeamIntroduce/TeamIntroduce/Sources/Application/TeamIntroduceApp.swift +++ b/TeamIntroduce/TeamIntroduce/Sources/Application/TeamIntroduceApp.swift @@ -10,23 +10,9 @@ import SwiftData @main struct TeamIntroduceApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() - var body: some Scene { WindowGroup { - ContentView() + RootView() } - .modelContainer(sharedModelContainer) } } diff --git a/TeamIntroduce/TeamIntroduce/Sources/Common/Navigation/NavigationControlling.swift b/TeamIntroduce/TeamIntroduce/Sources/Common/Navigation/NavigationControlling.swift new file mode 100644 index 0000000..f430184 --- /dev/null +++ b/TeamIntroduce/TeamIntroduce/Sources/Common/Navigation/NavigationControlling.swift @@ -0,0 +1,69 @@ +// +// NavigationControlling.swift +// TeamIntroduce +// +// Created by Wonji Suh on 8/11/25. +// + +import Foundation + +import Foundation +import SwiftUI + +// MARK: - Navigation 제어 프로토콜 + +/// SwiftUI `NavigationStack` 기반 화면 전환을 제어하는 공통 프로토콜입니다. +/// +/// 각 탭 또는 플로우 별로 코디네이터가 이 프로토콜을 채택하면 +/// 공통적인 `goBack`, `reset` 동작을 재사용할 수 있습니다. +protocol NavigationControlling: AnyObject { + + /// 현재 네비게이션 경로 상태입니다. + /// + /// `NavigationStack(path:)`에 바인딩되어 화면 이동을 제어합니다. + var path: NavigationPath { get set } + + /// 초기 진입 지점을 설정하는 메서드입니다. + /// + /// 각 코디네이터에서 구현되어야 하며, + /// 앱 시작 또는 탭 변경 시 루트 화면을 정의합니다. + func start() + + /// 마지막 화면을 스택에서 제거하여 이전 화면으로 이동합니다. + func goBack() + + /// 스택의 모든 경로를 제거하고 루트 화면으로 돌아갑니다. + func reset() +} + +// MARK: - NavigationControlling 기본 구현 + +extension NavigationControlling { + + /// 현재 화면을 스택에서 팝(뒤로 가기)합니다. + /// + /// - 동작: + /// - `path`가 비어있지 않은 경우, 마지막 항목을 제거하여 이전 화면으로 이동합니다. + /// + /// - 예시: + /// ```swift + /// coordinator.goBack() + /// ``` + func goBack() { + guard !path.isEmpty else { return } + path.removeLast() + } + + /// 네비게이션 스택을 초기 상태(루트)로 리셋합니다. + /// + /// - 동작: + /// - 현재 경로 배열에서 모든 화면을 제거하여, 루트 화면만 유지합니다. + /// + /// - 예시: + /// ```swift + /// coordinator.reset() + /// ``` + func reset() { + path.removeLast(path.count) + } +} diff --git a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceCoordinator.swift b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceCoordinator.swift new file mode 100644 index 0000000..596b935 --- /dev/null +++ b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceCoordinator.swift @@ -0,0 +1,58 @@ +// +// IntroduceCoordinator.swift +// TeamIntroduce +// +// Created by Wonji Suh on 8/11/25. +// + +import Combine +import SwiftUI + +import SwiftUI +import Combine + +final class IntroduceCoordinator: NavigationControlling, ObservableObject { + + @Published var path = NavigationPath() + + // 액션 기반 네비게이션 + enum Action { + case start + case presentMain + case pop + case popToRoot + case presntDetail + } + + func send(_ action: Action) { + switch action { + case .start: + start() + + case .presentMain: + path.append(IntroduceRoute(route: .introduceMain)) + + case .pop: + if !path.isEmpty { path.removeLast() } + + case .popToRoot: + path = .init() + + case .presntDetail: + path.append(IntroduceRoute(route: .teamAgreement)) + + } + } + + // MARK: - NavigationControlling 요구 구현 + + func start() { + reset() + send(.presentMain) + } + + // ⚠️ 프로토콜이 요구한다면 private 붙이면 안 됨 + func reset() { + path = .init() + } +} diff --git a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceRoute.swift b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceRoute.swift new file mode 100644 index 0000000..8bc635b --- /dev/null +++ b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceRoute.swift @@ -0,0 +1,45 @@ +// +// IntroduceRoute.swift +// TeamIntroduce +// +// Created by Wonji Suh on 8/11/25. +// + +enum IntroduceRoute: Hashable { + + // 소개 페이지 메인 화면 + case introduceMain + // 팀약속 + case teamAgreement + // 팀소개 + case teamIntroduce + // 팀 블로그 + case temBlog + + + // MARK: - 내부 전용 초기화 + + /// 내부 Route 값을 기반으로 IntroduceRoute를 생성합니다. + /// + /// 외부에서는 직접 case를 생성하지 않고, 내부에서만 변환을 허용합니다. + /// + /// - Parameter route: 내부용 Route enum 값 + init(route: Route) { + switch route { + case .introduceMain: self = .introduceMain + case .teamAgreement : self = .teamAgreement + case .teamIntroduce: self = .teamIntroduce + case .temBlog: self = .temBlog + } + } + + // MARK: - 내부 전용 라우트 Enum + + /// 외부 접근은 가능하지만 직접 IntroduceRoute를 생성할 수 없도록 제어하기 위한 내부 enum입니다. + enum Route { + case introduceMain + case teamAgreement + case teamIntroduce + case temBlog + } +} diff --git a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/View/IntorduceCoordinatorView.swift b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/View/IntorduceCoordinatorView.swift new file mode 100644 index 0000000..4fd9797 --- /dev/null +++ b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/View/IntorduceCoordinatorView.swift @@ -0,0 +1,50 @@ +// +// IntorduceCoordinatorView.swift +// TeamIntroduce +// +// Created by Wonji Suh on 8/11/25. +// + +import SwiftUI +import SwiftData + +struct IntorduceCoordinatorView : View { + @EnvironmentObject private var coordinator: IntroduceCoordinator + var sharedModelContainer: ModelContainer = { + let schema = Schema([ + Item.self, + ]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + + var body: some View { + NavigationStack(path: $coordinator.path) { + ContentView() + .navigationDestination(for: IntroduceRoute.self, destination: makeDestination) + } + .modelContainer(sharedModelContainer) + } +} + + +extension IntorduceCoordinatorView { + @ViewBuilder + private func makeDestination(for route: IntroduceRoute) -> some View { + switch route { + case .introduceMain: + ContentView() + case .teamAgreement: + ContentView() + case .teamIntroduce: + EmptyView() + case .temBlog: + EmptyView() + } + } +} diff --git a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/View/ContentView.swift b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/View/ContentView.swift index 1770e51..7d5a2a6 100644 --- a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/View/ContentView.swift +++ b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/View/ContentView.swift @@ -9,53 +9,37 @@ import SwiftUI import SwiftData struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] + @Environment(\.modelContext) private var modelContext + @Query private var items: [Item] + @EnvironmentObject private var coordinator: IntroduceCoordinator - var body: some View { - NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - Text("Select an item") + + var body: some View { + VStack { + Text("main") + .onTapGesture { + coordinator.send(.presntDetail) } } + } - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } + private func addItem() { + withAnimation { + let newItem = Item(timestamp: Date()) + modelContext.insert(newItem) } + } - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) - } - } + private func deleteItems(offsets: IndexSet) { + withAnimation { + for index in offsets { + modelContext.delete(items[index]) + } } + } } #Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) + ContentView() + .modelContainer(for: Item.self, inMemory: true) } diff --git a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/Root/RootView.swift b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/Root/RootView.swift new file mode 100644 index 0000000..f1ce0c8 --- /dev/null +++ b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/Root/RootView.swift @@ -0,0 +1,20 @@ +// +// RootView.swift +// TeamIntroduce +// +// Created by Wonji Suh on 8/11/25. +// + +import SwiftUI + +struct RootView: View { + @StateObject var coordinator = IntroduceCoordinator() + var body: some View { + IntorduceCoordinatorView() + .environmentObject(coordinator) + } +} + +#Preview { + RootView() +} From d0427ec9348fd3c0d4741405226af8421a717b38 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 11 Aug 2025 17:59:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=AA=9B[chore]:=20=20PR=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Flow/IntroduceCoordinator.swift | 41 +++++++++++++------ .../Coordinator/Flow/IntroduceRoute.swift | 30 ++------------ .../View/IntorduceCoordinatorView.swift | 2 +- .../IntroduceMain/View/ContentView.swift | 2 +- 4 files changed, 34 insertions(+), 41 deletions(-) diff --git a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceCoordinator.swift b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceCoordinator.swift index 596b935..4a69d93 100644 --- a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceCoordinator.swift +++ b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceCoordinator.swift @@ -8,8 +8,6 @@ import Combine import SwiftUI -import SwiftUI -import Combine final class IntroduceCoordinator: NavigationControlling, ObservableObject { @@ -18,10 +16,10 @@ final class IntroduceCoordinator: NavigationControlling, ObservableObject { // 액션 기반 네비게이션 enum Action { case start - case presentMain case pop case popToRoot - case presntDetail + case present(_ route: IntroduceRoute) + case replaceStack(_ routes: [IntroduceRoute]) } func send(_ action: Action) { @@ -29,30 +27,49 @@ final class IntroduceCoordinator: NavigationControlling, ObservableObject { case .start: start() - case .presentMain: - path.append(IntroduceRoute(route: .introduceMain)) - case .pop: if !path.isEmpty { path.removeLast() } case .popToRoot: path = .init() - case .presntDetail: - path.append(IntroduceRoute(route: .teamAgreement)) - + + case .present(let route): + path.append(route) + + case .replaceStack(let routes): + replaceStack(routes) + } } // MARK: - NavigationControlling 요구 구현 - func start() { reset() - send(.presentMain) + send(.present(.introduceMain)) } // ⚠️ 프로토콜이 요구한다면 private 붙이면 안 됨 func reset() { path = .init() } + + // 스택 교체 + func replaceStack(_ routes: [IntroduceRoute], animated: Bool = true) { + let apply = { + self.path = .init() + routes.forEach { self.path.append($0) } + } + + if animated { + withAnimation(.default) { apply() } + } else { + apply() + } + } + + // 편의 오버로드 + func replaceStack(_ route: IntroduceRoute, animated: Bool = true) { + replaceStack([route], animated: animated) + } } diff --git a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceRoute.swift b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceRoute.swift index 8bc635b..6a67d16 100644 --- a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceRoute.swift +++ b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/Flow/IntroduceRoute.swift @@ -5,6 +5,8 @@ // Created by Wonji Suh on 8/11/25. // +import Foundation + enum IntroduceRoute: Hashable { // 소개 페이지 메인 화면 @@ -14,32 +16,6 @@ enum IntroduceRoute: Hashable { // 팀소개 case teamIntroduce // 팀 블로그 - case temBlog - - - // MARK: - 내부 전용 초기화 - - /// 내부 Route 값을 기반으로 IntroduceRoute를 생성합니다. - /// - /// 외부에서는 직접 case를 생성하지 않고, 내부에서만 변환을 허용합니다. - /// - /// - Parameter route: 내부용 Route enum 값 - init(route: Route) { - switch route { - case .introduceMain: self = .introduceMain - case .teamAgreement : self = .teamAgreement - case .teamIntroduce: self = .teamIntroduce - case .temBlog: self = .temBlog - } - } - - // MARK: - 내부 전용 라우트 Enum + case teamBlog - /// 외부 접근은 가능하지만 직접 IntroduceRoute를 생성할 수 없도록 제어하기 위한 내부 enum입니다. - enum Route { - case introduceMain - case teamAgreement - case teamIntroduce - case temBlog - } } diff --git a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/View/IntorduceCoordinatorView.swift b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/View/IntorduceCoordinatorView.swift index 4fd9797..d7d94b9 100644 --- a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/View/IntorduceCoordinatorView.swift +++ b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/Coordinator/View/IntorduceCoordinatorView.swift @@ -43,7 +43,7 @@ extension IntorduceCoordinatorView { ContentView() case .teamIntroduce: EmptyView() - case .temBlog: + case .teamBlog: EmptyView() } } diff --git a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/View/ContentView.swift b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/View/ContentView.swift index 7d5a2a6..6b8e037 100644 --- a/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/View/ContentView.swift +++ b/TeamIntroduce/TeamIntroduce/Sources/Presnetaion/IntroduceMain/View/ContentView.swift @@ -18,7 +18,7 @@ struct ContentView: View { VStack { Text("main") .onTapGesture { - coordinator.send(.presntDetail) + coordinator.send(.present(.teamAgreement)) } } }