Navigator 패턴 이용해보기
👋 들어가기 전
SwiftUI에서 네비게이션을 쓸 때, 생각보다 번거로운 점이 있다.
어떻게 관리해야하고, 어디서 넣어줘여하는 지 고민됐다.
그래서 한번 추상화를 통해 개선해봤다.
1️⃣ 네비게이션은 뷰모델을 넘김으로써, 시작된다.
NavigationStack<Data, Root> : View where Root : View {
...
@MainActor @preconcurrency public init(
path: Binding<NavigationPath>,
@ViewBuilder root: () -> Root) where Data == NavigationPath
}
네비게이션 Stack의 생성자를 보면 NavigationPath가 있다.
저 path 파라미터에 값이 append될 때, 네비게이션은 다음 스택으로 들어간다.
이 때 중요한점은 path에 들어값 값은 반드시 Hashable 해야한다.

이후, 값을 넣어주면, navigationDestination함수에서, 넘겨준 값과 함께, 이동될 하면을 설정한다
아래는 Int타입을 넘겨주면, 해당 숫자를 Text뷰에 표시할 화면으로 넘어가는 것

위 내용응 정리하면, Hashable한 ViewModel을 path에 넘겨주면, 원하는 어떨까???

바로 이런식으로 사용할 수 있게된다.
2️⃣ 같은 Navigation Stack을 공유하면, path를 알고 있어야한다.
예를들어 다음과 같이 이동한다고 가정해보자. AView(RootView) -> BView -> CView
1번 규칙에 따르면, 이동하기위해 path에 다음뷰의 뷰모델을 넣어줘야하는데
새로운 path를 각 화면에서 생성하면, 전혀 이어지지 앟는 구조다.
또한 viewModel이 Path를 관리하는게 맞을까?? 라는 고민이 든다.
그래서 나는 Navigation을 담당하는 객체 Navigator를 만들어 각 ViewModel은 Navigation할 때
Navigator에게 요청하면되는 구조로 만들려한다.
정리하면 다음과 같은 구조가 나올 것 같다.

3️⃣ 구현
📎 HashableObject 프로토콜
import Foundation
public protocol HashableObject: AnyObject, Hashable, Identifiable {}
public extension HashableObject {
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
lhs === rhs
}
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(self.id)
}
}
🙏 NavigatableViewModel 수퍼클래스
import Foundation
@MainActor
open class NavigatableViewModel: HashableObject {
public weak var navigator: (any Navigatable)?
public func setNavigator(_ navigator: Navigatable?) {
self.navigator = navigator
}
public func navigate(_ viewModel: any HashableObject) {
navigator?.navigate(viewModel)
}
public func navigate(_ value: any Hashable) {
navigator?.navigate(value)
}
public func pop() {
navigator?.pop()
}
}
뷰모델 내부적으로 navigator를 갖고있는 뷰모델을 의미하며, navigator에 요청할 api들이 제공된다.
다음 ViewModel을 전달하는 경우와, 단순 value로 보여질 때 모두를 고려하고 있다.
✈️ Navigator
import SwiftUI
@MainActor
public protocol Navigatable: AnyObject {
var path: NavigationPath { get set }
func navigate(_ viewModel: any HashableObject)
func navigate(_ value: any Hashable)
func pop()
}
@Observable
public final class Navigator: Navigatable {
public var path: NavigationPath = .init()
public func navigate(_ viewModel: any HashableObject) {
path.append(viewModel)
}
public func navigate(_ value: any Hashable) {
path.append(value)
}
public func pop() {
if !path.isEmpty {
let _ = path.removeLast()
}
}
}
viewModel로 부터 다음 viewModel 또는 value를 받아 path에 넘겨준다.
👔 실제 사용
@Observable
final class AViewModel: NavigatableViewModel {
func navigate() {
let viewModel = BViewModel()
viewModel.setNavigator(navigator)
navigator?.navigate(viewModel)
}
}
struct ContentView: View {
@State var navigator = Navigator()
@State var viewModel: AViewModel
init(viewModel: AViewModel) {
self._viewModel = State(initialValue: viewModel)
}
var body: some View {
NavigationStack(path: $navigator.path) {
Button {
viewModel.navigate()
} label: {
Text("Navigate to B")
}
.navigationDestination(for: BViewModel.self) { viewModel in
BView(viewModel: viewModel)
}
.navigationDestination(for: Int.self) { number in
Text("\(number)")
}
.navigationTitle("A View")
}
.onAppear {
viewModel.setNavigator(navigator)
}
}
}
// ============================== AView ====================================
@Observable
final class BViewModel: NavigatableViewModel {
override init() {
print("🍯 init \(Self.self)")
}
deinit{
print("🍯 Deinit \(Self.self)")
}
func navigate() {
let viewModel = CViewModel()
viewModel.setNavigator(navigator)
navigator?.navigate(viewModel)
}
}
struct BView: View {
@State var viewModel: BViewModel
init(viewModel: BViewModel) {
self._viewModel = .init(initialValue: viewModel)
}
var body: some View {
VStack {
Button {
viewModel.navigate()
} label: {
Text("Navigate to C")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.green)
.navigationTitle("B View")
.navigationDestination(for: CViewModel.self) { viewModel in
CView(viewModel: viewModel)
}
}
}
// ============================== BView ====================================
@Observable
final class CViewModel: NavigatableViewModel{
override init() {
print("🍯 init \(Self.self)")
}
deinit{
print("🍯 Deinit \(Self.self)")
}
}
struct CView: View {
@State var viewModel: CViewModel
init(viewModel: CViewModel) {
self._viewModel = .init(initialValue: viewModel)
}
var body: some View {
VStack {
Button {
viewModel.pop()
} label: {
Text("Back")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.yellow)
.navigationTitle("C View")
}
}
🔬 결과

출처