SwiftUI Navigation + MVVM

In many cases navigation in our app uses classic NavigationView and we have a task to navigate from a screen to another screen.

I see a lot of not optimal examples how you can implement navigation to another screen (folowing MVVM architecture pattern) using NavigationLink inside NavigationView so I decided to share my way.

That tutorial will cover NavigationView and not a new NavigationStack (available from iOS 16) because a lot of apps still support iOS 14/15, you can easily adjust current approach to the latest NavigationStack.

Let's imagine the case when we have a Main screen and we can navigate from it to Login screen and Profile screen.

How it’s usually done:

Approach 1 (Setup navigation destination directly in View):

// Simple example of view models
class RootViewModel: ObservableObject {
  let navigationTitle = "Navigation example"
  let navigateToLoginTitle = "Navigate to Login"
  let navigateToProfileTitle = "Navigate to Profile"
  
  lazy var loginViewModel: LoginViewModel = LoginViewModel()
  lazy var profileViewModel: ProfileViewModel = ProfileViewModel()
}

class LoginViewModel: ObservableObject {
  let title = "Im a Login view"
  let navigationTitle = "Login"
}

class ProfileViewModel: ObservableObject {
  let title = "Im a Profile view"
  let navigationTitle = "Profile"
}

struct RootView: View {
  @EnvironmentObject var viewModel: RootViewModel
  
  var body: some View {
    NavigationView {
      VStack {
        NavigationLink(destination: {
          LoginView().environmentObject(viewModel.loginViewModel)
        }) {
          Text(viewModel.navigateToLoginTitle).padding()
        }
        NavigationLink(destination: {
          ProfileView().environmentObject(viewModel.profileViewModel)
        }) {
          Text(viewModel.navigateToProfileTitle).padding()
        }
      }
      .navigationTitle(viewModel.navigationTitle)
      .padding()
    }
  }
}

struct LoginView: View {
  @EnvironmentObject var viewModel: LoginViewModel
  
  var body: some View {
    VStack {
      Text(viewModel.title)
    }
    .navigationTitle(viewModel.navigationTitle)
  }
}

struct ProfileView: View {
  @EnvironmentObject var viewModel: ProfileViewModel
  
  var body: some View {
    VStack {
      Text(viewModel.title)
    }
    .navigationTitle(viewModel.navigationTitle)
  }
}

Disadvantages of such approach:

  1. Clearly we see that this approach not follow MVVM architecture pattern, because view does not inform ViewModel about user actions and by itself decide what to present.
  2. You should wrap tappable area in NavigationLink to handle the tap and navigation.

Approach 2 (Route actions through view model):

class RootViewModel: ObservableObject {
  let navigationTitle = "Navigation example"
  let navigateToLoginTitle = "Navigate to Login"
  let navigateToProfileTitle = "Navigate to Profile"
  
  lazy var loginViewModel: LoginViewModel = LoginViewModel()
  lazy var profileViewModel: ProfileViewModel = ProfileViewModel()
  
  @Published var shouldPresentLoginView = false
  @Published var shouldPresentProfileView = false
  
  func onTapNavigateToLogin() {
    shouldPresentLoginView = true
  }
  
  func onTapNavigateToProfile() {
    shouldPresentProfileView = true
  }
}

struct RootView: View {
  @EnvironmentObject var viewModel: RootViewModel
  
  var body: some View {
    NavigationView {
      VStack {
        NavigationLink("", isActive: $viewModel.shouldPresentLoginView) {
          LoginView().environmentObject(viewModel.loginViewModel)
        }
        NavigationLink("", isActive: $viewModel.shouldPresentProfileView) {
          ProfileView().environmentObject(viewModel.profileViewModel)
        }
        Button(viewModel.navigateToLoginTitle) {
          viewModel.onTapNavigateToLogin()
        }
        Button(viewModel.navigateToProfileTitle) {
          viewModel.onTapNavigateToProfile()
        }
      }
      .navigationTitle(viewModel.navigationTitle)
      .padding()
    }
  }
}

Disadvantages of such approach:

  1. We a bit closer to MVVM approach, now we can track all actions in ViewModel and decide what to do. But to add navigation to a new screen we would need to add more properties and copy paste similar code.
  2. View still decide what ViewModel should be used for the presented screen.

My way:

// Lets introduce enum with all possible destinations
enum NavigationDestination: Hashable {
  case login(vm: LoginViewModel)
  case profile(vm: ProfileViewModel)
  case none
  
  func hash(into hasher: inout Hasher) {
    var index = 0
    switch self {
    case .login:
      index = 1
    case .profile:
      index = 2
    case .none:
      index = 0
    }
    hasher.combine(index)
  }
  
  static func == (
    lhs: NavigationDestination,
    rhs: NavigationDestination
  ) -> Bool {
    lhs.hashValue == rhs.hashValue
  }
}

class RootViewModel: ObservableObject {
  let navigationTitle = "Navigation example"
  let navigateToLoginTitle = "Navigate to Login"
  let navigateToProfileTitle = "Navigate to Profile"
  
  private lazy var loginViewModel: LoginViewModel = LoginViewModel()
  private lazy var profileViewModel: ProfileViewModel = ProfileViewModel()
  
  @Published var navDestination: NavigationDestination? = nil
  
  func onTapNavigateToLogin() {
    navDestination = .login(vm: loginViewModel)
  }
  
  func onTapNavigateToProfile() {
    navDestination = .profile(vm: profileViewModel)
  }
}

struct RootView: View {
  @EnvironmentObject var viewModel: RootViewModel
  @State private var navDestination: NavigationDestination = .none
  
  var body: some View {
    NavigationView {
      VStack {
        Button(viewModel.navigateToLoginTitle) {
          viewModel.onTapNavigateToLogin()
        }
        Button(viewModel.navigateToProfileTitle) {
          viewModel.onTapNavigateToProfile()
        }
        NavigationLink(
          destination: buildNextView(),
          tag: navDestination,
          selection: $viewModel.navDestination,
          label: { EmptyView() }
        )
      }
      .onChange(of: viewModel.navDestination) { newValue in
        guard let newNavDestination = newValue else {
          navDestination = .none
          return
        }
        navDestination = newNavDestination
      }
      .navigationTitle(viewModel.navigationTitle)
      .padding()
    }
  }
  
  // Can be moved in a separate factory and injected here like a dependency
  @ViewBuilder
  private func buildNextView() -> some View {
    switch navDestination {
    case .login(let vm):
      LoginView().environmentObject(vm)
    case .profile(let vm):
      ProfileView().environmentObject(vm)
    case .none:
      EmptyView()
    }
  }
}

Navigation will be executed when tag and selection are the same.

In that way we can easily extend our navigation to a new screens without updating the view (only factory for views), ViewModel do not expose any not required information for the View.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章