iOS/SwiftUI

Navigator 패턴 이용해보기

Hamp 2025. 9. 28. 15:54
반응형

👋 들어가기 전

 

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")
  }
}

🔬 결과


출처

 

반응형