part1: https://velog.io/@seemaster300/SUSU-수수앱에서-Navigation-방식을-정의하기-TCA-With-Navigation
과거 파트에서 다룬 가장 큰 문제는 가장 상위 뷰에서 모든 Navigation로직을 정의해주는 일 이었습니다. 이렇게 가장 상위 뷰에 네비게이션 로직을 정의 하는 것은 개발하는데에 있어서 매우 불편한 상황을 자주 맞딱뜨렸습니다. 불편한 상황은 다음과 같습니다.
과거에 어떤 코드를 어떻게 짰는지 읽기 위해서 Navigation Reducer를 키면 개발하고 싶은 욕구가 사라질 정도로, 코드를 읽는 것이 너무너무 어려웠습니다.
TopReducer가 너무 무겁다는 생각을 했습니다. TopReducer가 ChildReducer에 State를 생성해주고 이를 Path에 넣어주었습니다. 안에서 보면 TopReducer는 거의 절대자 처럼 모든 것을 알고 있게 되버립니다. 이것이 과연 옳은 설계일까에 대해서 고민하였습니다.
이번 파트는 다음과 같은 의문으로 시작합니다. "SwiftUI는 Coordinator패턴을 적용할 수 없나요?"(많이 활용되는 Child Parent구조의 Coordinator) UIKit에서는 NavigationController를 통해서 UIViewController만 있다면 NavigationController를 통해서 화면전환이 가능했습니다. 구체타입이 아닌 UIViewController를 상속한 class를 통해서 View를 푸쉬할 수 있었습니다. 하지만 SwiftUI에서는 AnyView를 활용하지 않는 이상 뷰의 구체타입을 모른다면 Push가 안됩니다.
swiftUI뷰를 UIViewController로 래핑할 수 있습니다. 이는 UIHostingController로 가능합니다. 예를 들어서
FirstView를 만든다고 했을때 다음과 같이 코드를 작성할 수 있습니다.
final class FirstViewController: UIHostingController<FirstView> {
init() {
super.init(rootView: FirstView())
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct FirstView: View {
var body: some View {
VStack {
Text("Im swift UI View")
}
}
}
우리는 일단 ViewController를 만들었습니다. 하지만 ViewController가 View를 보여주지 않고 단순히 NavigationController와 연결하기 위한 Wrapper역할만 합니다. 한단계 추상화를 더 거쳐도 되지만, 뷰를 그리는 책임이 SwiftUI View에 전가된 ViewController를 통해서도 Coordinator패턴을 만들 수 있다고 생각했습니다.(Coordinator가 화면전환의 책임을 가진다고 생각했을 때)
그러면 NavigaitonController에서 다음과 같은 코드를 작성할 수 있습니다.
// 첫번째 ViewController
final class FirstViewController: UIHostingController<FirstView> {
var reducer: FirstReducer
var destinationSubscriber: AnyCancellable? = nil
// SwiftUI View를 만들기 위해서 필요한 인자들을 init함수를 통해서 받습니다.
init(state: FirstReducer.State, reducer: FirstReducer) {
self.reducer = reducer
super.init(rootView: FirstView(store: .init(initialState: state, reducer: {
reducer
})))
}
override func viewDidLoad() {
super.viewDidLoad()
// 초기 인자로 받은 Reudcer내부의 publisher를 통한 화면 전환에 로직입니다.
// Combine을 활용하여 가고싶은 화면을 갑니다.
destinationSubscriber = reducer
.publisher
.sink { [weak self] destination in
let pushViewController: UIViewController
switch destination {
case .secondScreen:
pushViewController = SecondViewController(state: .init(), reducer: .init())
case .thirdScreen:
pushViewController = ThirdViewController(state: .init(), reducer: .init())
}
self?.navigationController?.pushViewController(pushViewController, animated: true)
}
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// 첫번째 뷰가 갈 수 있는 경로
enum FirstViewPushDestinations {
case secondScreen
case thirdScreen
}
그리고 ViewController에 대응되는 Reducer와 SwiftUIView는 다음과 같이 작성할 수 있습니다.
// 첫번째 뷰 리듀서
@Reducer
struct FirstReducer {
struct State {
var onAppear: Bool = false
}
enum Action {
case navigationSecondScreen
case navigationThirdScreen
case push(FirstViewPushDestinations)
}
// 화면전환을 위해 사용되는 Publisher
var publisher: PassthroughSubject<FirstViewPushDestinations, Never> = .init()
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .navigationSecondScreen:
return .send(.push(.secondScreen))
case .navigationThirdScreen:
return .send(.push(.thirdScreen))
case let .push(destination):
publisher.send(destination)
return .none
}
}
}
}
이를 구조도로 나타내면 다음과 같습니다.
누를 시 깃허브로 이동됩니다.