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:
- 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.
- 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:
- 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.
- 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.