과거 Navigation Architeture는 SwiftUI에서 UIKit ViewController를 끌어오는 방식으로 아키텍쳐를 작성했습니다. 하지만 이것이 TCA와 SwiftUI가 바라는 방식은 아니라고 생각했습니다. 그 이유는 다음과 같습니다.
dependency(\.dismiss) var dismiss
를 활용할 수 있습니다. 사실 위와 같은 문제점들이 있었습니다. TCA가 설계한 그 그대로 StackNavigation을 구현하는데 StackNavigation을 좀 더 가독성 있게 만들어보자 라는 취지에서 Navigation에 대한 설계를 다시 해봤습니다.
SwiftUI에서 Navigation 하기 위해서는 뷰의 구체타입을 알아야 합니다. 이 말은 화면을 push하는 객체는 어떤 뷰에 대한 정확한 타입을 알아야 합니다. 뷰를 전환하는 객체는 두가지가 될 수 있습니다.
.navigationDestination(isPresented:, destination: )
을 통해서 다음 화면으로 전환할 수 있습니다. NavigationLink(destination:, label:)
를 통해서 화면 전환이 가능합니다. Coordinator패턴이 도입된 이유는 ViewController 즉 뷰에서 화면전환 로직을 coordinator로 전환하는 것으로 부터 시작했습니다. 그렇기 떄문에 Root에 하위 뷰에서 생성자를 통해서 View에서 바로 생성하는 것 보다는 가장 상위 NavigationStack이 Destination View를 생성하는 방향으로 진행하자고 생각했습니다.
NavigationLink {
SomeView() //🤔🤔
} label: {
Text("Push Some View")
}
구현 순서는 다음과 같습니다.
Path destination enum
생성(enum Reducer이고, wildCard에 View 의 Reducer가 들어 갑니다.@Reducer(state: .equatable, action: .equatable) // or @Reducer
enum NavigationDestinationPath {
case second(SecondReducer)
case third(ThirdReducer)
}
final class NavigationDestinationPublisher {
static let shared = NavigationDestinationPublisher()
private init() {} // 싱글톤이라 priviate init
private var _publisher: PassthroughSubject<NavigationDestinationPath.State, Never> = .init()
func publisher() -> AnyPublisher<NavigationDestinationPath.State, Never> {
return _publisher.eraseToAnyPublisher()
}
func push(navigationDestination val: NavigationDestinationPath.State) {
_publisher.send(val)
}
}
Top Reducer
에 State
및 Action
에 Path
추가해줌View
가 생성될 시 Publisher
을 sink
함sink
된 값을 통해서 state.path
에 적절한 값을 append
@Reducer
// 첫번째 뷰 리듀서
struct FirstReducer {
@ObservableState
struct State: Equatable {
// ... View State properties ....
var onAppear: Bool = false
var path: StackState<NavigationDestinationPath.State> = .init([])
}
enum Action: Equatable {
//... View Aciton Case ...
case onAppear(Bool)
case path(StackActionOf<NavigationDestinationPath>) // ✅ navigation Stack을 위해 사용됨
case push(NavigationDestinationPath.State) // ✅ sink하는 Publisher를 통해 사용될 예정
}
enum CancelID { // ✅ 만약 onAppear이 두번 불리면 이전 Publisher sink하는 로직을 취소 하기 위해 사용됨
case publisher
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
/// .. body Code ...
case .path(_):
return .none
case .onAppear(_):
return .publisher {
// ✅ view가 보이면 SingleTone Publisher 를 sink합니다.
// 또한 이벤트 발생시 .push(path) Event를 발생시킵니다.
NavigationDestinationPublisher.shared.publisher()
.map{ state in .push(state)}
}.cancellable(id: CancelID.publisher, cancelInFlight: true)
// ✅ state.path 에 방출된 값을 저장합니다.
case let .push(pathState):
state.path.append(pathState)
return .none
}
}
.forEach(\.path, action: \.path)
}
}
// 첫번째 뷰
struct FirstView: View {
@Bindable
var store: StoreOf<FirstReducer>
var body: some View {
NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
VStack(spacing: 0) {
Text("UIHostinhController를 통해 생성된 뷰")
Button {
store.send(.navigationSecondScreen)
} label: {
Text("Go SecondScreen")
}
Button {
store.send(.navigationThirdScreen)
} label: {
Text("Go ThirdScreen")
}
}
.onAppear{
store.send(.onAppear(true))
}
} destination: { store in
switch store.case {
case let .second(store):
SecondView(store: store)
case let .third(store) :
ThirdView(store: store)
}
}
}
}
NaviagtionPublisehr를 통해서 화면을 이동하면 됩니다. NaviagtionPublisher 는 Shared의 singleTone객체가 있기 때문에 이 객체를 통해서 함수를 실행하면 됩니다. push함수의 경우 NavigationDestination의 State을 전달하면 됩니다.
/// ReducerCode
// Reducer code ...
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .navigationSecondScreen:
let secondReducerState = SecondReducer.State()
NavigationDestinationPublisher.shared.push(navigationDestination: .second(secondReducerState))
return .none
// code ...
만약 reducer body내부에서 객체를 생성하는것에 대한 책임을 분리하고 싶다면 다음과 같이 객체 책임을 분리할 수 있습니다.
struct FirstReducerNavigationBuilder: Equatable {
init() {}
func makeSecondReducerState() -> SecondReducer.State {
return .init()
}
}
// reducer code...
case .navigationSecondScreen:
let state = state.builder.makeSecondReducerState()
NavigationDestinationPublisher.shared.push(navigationDestination: .second(state))
최상단 리듀서(NavigationStack, NavigationDestination뷰를 표시하기 위해 사용되는 리듀서)와 Publisher의 관계 입니다.
최상단이 아닌 하위 뷰와 Publisher와 관계 입니다.
둘을 합친 전체 구조도 입니다.
누를 시 깃허브로 이동합니다.