Repository navigation
Navigation을 어떻게 가져가면 좋을까~ #24
SijongKim93
started this conversation in
General
Replies: 1 comment
// Interface/NavigationInterface/Sources/RouterInterface.swift
import SwiftUI
import Domain
// 앱에서 이동할 수 있는 모든 화면을 정의
public enum AppRoute: Equatable, Hashable, Identifiable {
case home
case calendar
case profile
case eventDetail(Event)
case createEvent
case editEvent(Event)
case editProfile
case settings
case onboarding
public var id: String {
switch self {
case .home: return "home"
case .calendar: return "calendar"
case .profile: return "profile"
case .eventDetail(let event): return "eventDetail_\(event.id)"
case .createEvent: return "createEvent"
case .editEvent(let event): return "editEvent_\(event.id)"
case .editProfile: return "editProfile"
case .settings: return "settings"
case .onboarding: return "onboarding"
}
}
}
// Router의 현재 상태를 나타내는 구조체
// 이 구조체는 앱의 전체 네비게이션 상태를 중앙에서 관리
// @Published와 함께 사용되어 상태 변화가 자동으로 UI에 반영
public struct RouterState: Equatable {
public var navigationPath: [AppRoute] = [] // 네비게이션 스택
public var presentedSheet: AppRoute? = nil // 시트로 표시되는 화면
public var presentedFullScreen: AppRoute? = nil // 풀스크린 모달
public var selectedTab: Int = 0 // 현재 선택된 탭
public init() {}
}
// Router가 처리할 수 있는 모든 액션들
public enum RouterAction: Equatable {
case navigate(to: AppRoute)
case presentSheet(AppRoute)
case presentFullScreen(AppRoute)
case goBack
case goToRoot
case selectTab(Int)
case dismiss
case dismissAll
}
// Router의 프로토콜
// 새로운 Route가 변경되어도 DIContainer에서 Route 생성 부분만 바꿔주면 됨
// 테스트시 MockRouter를 채택하는 식으로 바꿔 끼우기만 하면 됨
// 클래스들이 구체적인 AppRouter에 의존하는 대신, 추상적인 RouterProtocol에 의존하게 함으로써 결합도 낮추기 가능
public protocol RouterProtocol: ObservableObject {
var state: RouterState { get }
func dispatch(_ action: RouterAction)
}2. ViewFactory 인터페이스 추가같은 Interface 프로젝트에 ViewFactory 관련 인터페이스 추가. // Interface/FactoryInterface/Sources/ViewFactoryInterface.swift
import SwiftUI
// 모든 View를 생성할 수 있는 Factory 프로토콜
public protocol ViewFactoryProtocol {
func makeView(for route: AppRoute) -> AnyView
func makeHomeView() -> AnyView
func makeCalendarView() -> AnyView
func makeProfileView() -> AnyView
func makeEventDetailView(event: Event) -> AnyView
func makeCreateEventView() -> AnyView
func makeMainTabView() -> AnyView
}Factory패턴은 여기를 보시면 되고 Router가 "어떤 화면으로 이동할지"를 결정하면, ViewFactory가 "그 화면을 어떻게 만들지"를 담당합니다 3. Core 프로젝트에 Router 구현체 추가AppRouter 구현체 구현 // Core/Navigation/Sources/AppRouter.swift
import SwiftUI
import Combine
import NavigationInterface
import FactoryInterface
@MainActor
public final class AppRouter: RouterProtocol {
@Published public var state = RouterState()
private let viewFactory: ViewFactoryProtocol
public init(viewFactory: ViewFactoryProtocol) {
self.viewFactory = viewFactory
}
public func dispatch(_ action: RouterAction) {
switch action {
case .navigate(to: let route):
handleNavigate(to: route)
case .presentSheet(let route):
state.presentedSheet = route
case .presentFullScreen(let route):
state.presentedFullScreen = route
case .goBack:
if !state.navigationPath.isEmpty {
state.navigationPath.removeLast()
}
case .goToRoot:
state.navigationPath.removeAll()
case .selectTab(let index):
state.selectedTab = index
state.navigationPath.removeAll() // 탭 변경 시 네비게이션 스택 초기화
case .dismiss:
if state.presentedFullScreen != nil {
state.presentedFullScreen = nil
} else if state.presentedSheet != nil {
state.presentedSheet = nil
}
case .dismissAll:
state.presentedSheet = nil
state.presentedFullScreen = nil
}
}
private func handleNavigate(to route: AppRoute) {
// 하단 탭바 네비게이션인지 확인
switch route {
case .home:
state.selectedTab = 0
case .calendar:
state.selectedTab = 1
case .profile:
state.selectedTab = 2
default:
// 일반적인 push 네비게이션
state.navigationPath.append(route)
}
}
// ViewFactory를 통해 화면을 생성하는 메서드
public func makeView(for route: AppRoute) -> AnyView {
return viewFactory.makeView(for: route)
}
}4. DIContainer 확장기존 DIContainer에 Router와 ViewFactory를 추가합니다. // Core/DI/Sources/DIContainer.swift
public class DIContainer {
private let networkService: NetworkServiceProtocol
private let storage: UserStorageProtocol & EventStorageProtocol
private let eventRepository: EventRepositoryProtocol
private let userRepository: UserRepositoryProtocol
public let eventUseCase: EventUseCaseProtocol
public let userUseCase: UserUseCaseProtocol
public let router: RouterProtocol
public let viewFactory: ViewFactoryProtocol
public static func production() -> DIContainer {
return DIContainer(environment: .production)
}
public static func testing() -> DIContainer {
return DIContainer(environment: .testing)
}
}5. ViewFactory 구현App 프로젝트에 ViewFactory를 구현 // App/Sources/Factory/ViewFactory.swift
import SwiftUI
import FactoryInterface
import NavigationInterface
import Domain
import Home
import Calendar
import Profile
import TabBar
public final class ViewFactory: ViewFactoryProtocol {
private let container: DIContainer
public init(container: DIContainer) {
self.container = container
}
public func makeView(for route: AppRoute) -> AnyView {
switch route {
case .home:
return makeHomeView()
case .calendar:
return makeCalendarView()
case .profile:
return makeProfileView()
case .eventDetail(let event):
return makeEventDetailView(event: event)
case .createEvent:
return makeCreateEventView()
case .editEvent(let event):
return makeEditEventView(event: event)
case .editProfile:
return makeEditProfileView()
case .settings:
return makeSettingsView()
case .onboarding:
return makeOnboardingView()
}
}
public func makeHomeView() -> AnyView {
let store = HomeStore(
eventUseCase: container.eventUseCase,
router: container.router
)
return AnyView(HomeView(store: store))
}
public func makeCalendarView() -> AnyView {
let store = CalendarStore(
eventUseCase: container.eventUseCase,
router: container.router
)
return AnyView(CalendarView(store: store))
}
public func makeProfileView() -> AnyView {
let store = ProfileStore(
userUseCase: container.userUseCase,
router: container.router
)
return AnyView(ProfileView(store: store))
}
public func makeEventDetailView(event: Event) -> AnyView {
let store = EventDetailStore(
event: event,
eventUseCase: container.eventUseCase,
router: container.router
)
return AnyView(EventDetailView(store: store))
}
public func makeCreateEventView() -> AnyView {
let store = CreateEventStore(
eventUseCase: container.eventUseCase,
router: container.router
)
return AnyView(CreateEventView(store: store))
}
public func makeEditEventView(event: Event) -> AnyView {
let store = EditEventStore(
event: event,
eventUseCase: container.eventUseCase,
router: container.router
)
return AnyView(EditEventView(store: store))
}
public func makeEditProfileView() -> AnyView {
let store = EditProfileStore(
userUseCase: container.userUseCase,
router: container.router
)
return AnyView(EditProfileView(store: store))
}
public func makeSettingsView() -> AnyView {
let store = SettingsStore(
userUseCase: container.userUseCase,
router: container.router
)
return AnyView(SettingsView(store: store))
}
public func makeOnboardingView() -> AnyView {
let store = OnboardingStore(router: container.router)
return AnyView(OnboardingView(store: store))
}
public func makeMainTabView() -> AnyView {
return AnyView(MainTabView(viewFactory: self))
}
}6. 기존 Feature들을 MVI + RouterHomeView와 HomeStore 변환먼저 HomeInterface에 MVI 관련 프로토콜들을 추가 // Interface/HomeInterface/Sources/HomeInterface.swift
import SwiftUI
import Domain
// Home 화면에서 발생할 수 있는 모든 액션들
public enum HomeAction: Equatable {
case viewDidAppear
case refreshRequested
case eventTapped(Event)
case createEventTapped
case profileButtonTapped
case eventLoadingCompleted([Event])
case eventLoadingFailed(String)
}
// Home 화면의 모든 상태를 나타내는 구조체
public struct HomeState: Equatable {
public var events: [Event] = []
public var isLoading: Bool = false
public var error: String? = nil
public init() {}
}
public protocol HomeStoreProtocol: ObservableObject {
var state: HomeState { get }
func dispatch(_ action: HomeAction)
}그리고 HomeStore를 구현합니다. // MARK: - Feature/Home/Sources/HomeStore.swift
import Foundation
import Combine
import HomeInterface
import NavigationInterface
import Domain
import UseCase
public final class HomeStore: HomeStoreProtocol {
@Published public var state = HomeState()
private let eventUseCase: EventUseCaseProtocol
private let router: RouterProtocol
public init(
eventUseCase: EventUseCaseProtocol,
router: RouterProtocol
) {
self.eventUseCase = eventUseCase
self.router = router
}
public func dispatch(_ action: HomeAction) {
switch action {
case .viewDidAppear:
loadEvents()
case .refreshRequested:
loadEvents()
case .eventTapped(let event):
router.dispatch(.navigate(to: .eventDetail(event)))
case .createEventTapped:
router.dispatch(.presentSheet(.createEvent))
case .profileButtonTapped:
router.dispatch(.navigate(to: .profile))
case .eventLoadingCompleted(let events):
state.events = events
state.isLoading = false
state.error = nil
case .eventLoadingFailed(let error):
state.error = error
state.isLoading = false
}
}
private func loadEvents() {
state.isLoading = true
state.error = nil
Task {
do {
let events = try await eventUseCase.getEvents()
await MainActor.run {
dispatch(.eventLoadingCompleted(events))
}
} catch {
await MainActor.run {
dispatch(.eventLoadingFailed(error.localizedDescription))
}
}
}
}
}기존 HomeView를 완전히 새로운 구조로 변경합니다. import SwiftUI
import HomeInterface
import Domain
import DesignSystem
public struct HomeView: View {
@StateObject private var store: HomeStore
public init(store: HomeStore) {
self._store = StateObject(wrappedValue: store)
}
public var body: some View {
VStack {
Text("나희")
.font(Typography.title)
if store.state.isLoading {
ProgressView("로딩 중...")
} else if let error = store.state.error {
VStack {
Text("오류: \(error)")
Button("다시 시도") {
store.dispatch(.viewDidAppear)
}
}
} else {
List(store.state.events) { event in
Button(event.title) {
store.dispatch(.eventTapped(event))
}
}
}
Button("이벤트 생성") {
store.dispatch(.createEventTapped)
}
.buttonStyle(.borderedProminent)
}
.onAppear {
store.dispatch(.viewDidAppear)
}
}
}
#Preview {
let container = DIContainer.testing()
let store = HomeStore(
eventUseCase: container.eventUseCase,
router: container.router
)
HomeView(store: store)
}7. TabBarView 수정// Feature/TabBar/Sources/TabBarView.swift
import SwiftUI
import FactoryInterface
import DesignSystem
public struct MainTabView: View {
private let viewFactory: ViewFactoryProtocol
public init(viewFactory: ViewFactoryProtocol) {
self.viewFactory = viewFactory
}
public var body: some View {
TabView {
viewFactory.makeHomeView()
.tabItem {
Image(systemName: "house.fill")
Text("홈")
}
.tag(0)
viewFactory.makeCalendarView()
.tabItem {
Image(systemName: "calendar")
Text("캘린더")
}
.tag(1)
viewFactory.makeProfileView()
.tabItem {
Image(systemName: "person.fill")
Text("프로필")
}
.tag(2)
}
.accentColor(Colors.primary)
}
}
#Preview {
let container = DIContainer.testing()
MainTabView(viewFactory: container.viewFactory)
}8. AppMain과 ContentViewRouter 중심으로 변경 import SwiftUI
@main
struct AppMain: App {
private let container: DIContainer
init() {
#if DEBUG
// 디버그 모드에서는 개발 환경 사용
self.container = DIContainer.development()
#else
// 릴리즈 모드에서는 프로덕션 환경 사용
self.container = DIContainer.production()
#endif
}
var body: some Scene {
WindowGroup {
AppRootView(container: container) // 명시적 전달
}
}
}
import NavigationInterface
struct AppRootView: View {
private let container: DIContainer
init(container: DIContainer) {
self.container = container
}
var body: some View {
// Router를 로컬 변수로 캐스팅
let router = container.router as! AppRouter
NavigationStack(path: Binding(
get: { router.state.navigationPath },
set: { router.state.navigationPath = $0 }
)) {
container.viewFactory.makeMainTabView()
.navigationDestination(for: AppRoute.self) { route in
container.viewFactory.makeView(for: route)
}
}
.sheet(item: Binding<AppRoute?>(
get: { router.state.presentedSheet },
set: { _ in router.dispatch(.dismiss) }
)) { route in
NavigationView {
container.viewFactory.makeView(for: route)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("닫기") {
router.dispatch(.dismiss)
}
}
}
}
}
.fullScreenCover(item: Binding<AppRoute?>(
get: { router.state.presentedFullScreen },
set: { _ in router.dispatch(.dismiss) }
)) { route in
container.viewFactory.makeView(for: route)
}
}
}기존 ContentView를 AppRootView로 대체 |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
SwiftUI로 프로젝트를 진행하면서 네비게이션 관리가 점점 어려워 진다고 느껴지는 경우가 많다. 어디서 네비게이션을 처리해야할지 항상 고민이었는데 처음 도입하는데 복잡하다고 느낄 수 있지만 팀원의 추천으로 Router를 활용한 방식을 한번 도입해보려고 한다.
Router Injection은 네비게이션 로직을 완전히 추상화해 의존성으로 주입하는 패턴이다.
일단 기존의 네비게이션을 활용하자면
이렇게 기존의 방식대로 네비게이션을 구현하려면 View에 UI + 네비게이션 + 비즈니스 로직을 모두 처리하게 되면서 테스트가 어려워지고 재사용, 유지보수가 어려운 점이 생긴다.
또한, SwiftUI의 강점은 선언형 UI라는 건데 이전 방식의 경우엔 View가 네비게이션까지 직접 하게 되면 그 이점을 살릴 수 없는 것 같은 느낌이었다.
이 외에 조금 쓰기 어렵다 라고 느끼게 되는 경우가 많은데 그래서 Router Injection을 적용해 보려고 한다.
Router Injection
이 Router Injection은 네비게이션 책임을 별도의 Router 객체로 분리하고, 이를 의존성으로 주입하는 패턴이다.
이러한 흐름으로 진행되는데 코드를 통해 더 알아보자
먼저 첫번째로 Router 인터페이스를 정의한다.
그 다음 Router 구현체를 만들어준다.
그 다음엔 Coordinator를 사용해 Router를 사용하는 방식을 만든다
이렇게 구현된 코디네이터를 사용하게 되면 뷰에는 단순하게 UI만 담당할 수 있는 구조로 이뤄진다
이 구조는 관심사의 분리 원칙을 적용된것으로 각 레이어는 단일 책임 원칙을 지키고 서로 느슨하게 결합되어 있어 교체나 테스트가 용이해진다는 장점이 있다.
예를 들어 RouterProtocol을 테스트 Mock 객체로 교체하면 UI 테스트를 하지 않고도 네비게이션 동작을 검증할 수 있다.
결과적으로 View는 단순히 UI 테스트로 충분하고 Coordinator만 테스트 해서 비즈니스 흐름을 검증할 수 있다.
하지만 내가 느꼈을땐 좋은 방식이라고 생각이 들지만 지금 우리 프로젝트에 이런 방식의 네비게이션 구현이 필요할까? 에 대한 의문이 살짝 들었다. 다소 복잡성이 높아질 수도 있을 것 같다는 느낌..?
Screen Enum + NavigationPath
그렇다면 내가 생각했을때 사용하면 괜찮을 것 같은 방식은 바로 Screen을 enum으로 구분하고 NavigationPath를 사용한 네비게이션 방식은 어떤가! 싶다
먼저 NavigationPath에 어떤 화면을 push할지 타입 안정성을 높히기 위해 enum으로 화면을 추상화 시킨다
이 enum을 NavigationPath에 넣고 꺼내면서 어떤 화면인지 판별하여 대응하는 View를 보여주는 방식이다
NavigationPath 내부적으로 Set 또는 Dictionary 처럼 작동하게 되는데 이 경로는 내부적으로 식별이 가능해야하고, 중복을 피해 화면을 식별하기 위해서 비교 연산이 필요하기 때문에 Hashable을 사용한다.
그럼 다면 이 Screen이 Hashable이라 NavigationPath에 넣을 수 있고, SwiftUI는 이 enum을 바탕으로 어떤 View를 보여줄 지 선택할 수 있다.
네비게이션 매니저를 구성해 push, pop, popToRoot 등 네비게이션의 방식을 설정하고
각 뷰에서 NavigationStack을 설정해 path를 넣어주는 방식으로 진행된다.
만약 네비게이션 push/pop 가 아니라 모달을 활용한 sheet, fullScreen, alert은 별도로 네비게이션과 분리하여 적용해둔다.
이런식으로 진행됐을때의 장점과 단점!!
✅ 장점
❌ 단점
All reactions