I think I have a general misunderstanding of how to work with dependencies in SwiftUI considering frequent view redraws, not just in view body but also in navigation methods.
The code below seems like a logical way to navigate to a new module with its dependencies:
.navigationDestination(item: self.$viewModel.selectedUserId) { selectedUserId in
ProfileView(viewModel: ProfileViewModel(userId: selectedUserId))
}
But, the .navigationDestination
method is triggering multiple times here (3 times to be precise), creating duplicate dependencies. The init(viewModel: ProfileViewModel) {self._viewModel = StateObject(wrappedValue: viewModel)}
pattern doesn't help because we're creating a new view instance every time. And to my understanding this is by design, even though other navigation methods and older API might trigger only once, you shouldn't really count on it because it's a part of SwiftUI views internal behavior. I know there're many solutions to this specific problem, I've made different work arounds by referring to other navigation methods that only trigger once, tried all kinds of boolean checks and dependency caching, environment injection, factory/assembly patterns etc., but I still fail to understand the general principle of working with dependencies in swiftUI, to make it all clean and separated. Is view supposed to store all the child and navigated views? Or just their dependencies?
What's the recommended way of working with external dependencies for navigated/child views inside SwiftUI to avoid duplicate instances on view redraws?
EDIT: changed the example code below to show what I'm trying to achieve.
struct ChatsListContainerView<ViewModel: ChatsListViewModelProtocol, CommunityView: View>: View {
@ObservedObject var viewModel: ViewModel
let communityViewBuilder: (CommunityInputData) -> CommunityView
var body: some View {
NavigationStack {
ChatsListView(viewState: self.viewModel.viewState, actionHandler: self.viewModel.handleViewActions)
.navigationTitle("Chats")
.navigationDestination(item: self.$viewModel.communityDetailsInputData) { communityDetailsData in
// This is just any DI method that returns a view with configured dependencies
self.communityViewBuilder(communityDetailsData)
}
}
}
}
// This is our assembly essentially
enum CommunityAssembly {
static func makeModule(inputData: CommunityInputData) -> some View {
let vm = CommunityViewModel(viewState: .loading, communityService: ServiceLocator.shared.communityService, chatsService: ServiceLocator.shared.chatsService, communityId: inputData.id)
let view = CommunityContainerView(viewModel: vm)
return view
}
}
My question is where do I create these external dependencies (view model with its services) after navigation triggers to guarantee they always get instantiated once?
(Note I had to slightly change the names of the structs since I already had structs with the same names in my demo project).
Is view supposed to store all the child and navigated views?
View
should describe what to render, not do things (like create ProfileViewModel
inline). So you should avoid introducing side effects in the body (any observable changes that happen outside the local scope or affect something beyond just returning a value - like printing to the console or instantiating models).
A .navigationDestination
can evaluate its closure multiple times during navigation prep:
When the closure value changes
For any view diffing and/or layout purposes
Possibly during animation/transition
... and maybe other scenarios.
In this case, the closure seems to be evaluated three times, which is why you see the output .navigationDestination trigger
.
Furthermore, calling ProfileViewModel(userId: selectedUserId)
inside the .navigationDestination
closure basically creates multiple instances (one instance every time the closure is evaluated) before one is even used for the actual navigation. Actually, this would also apply if the destination View created ProfileViewModel instances in its init
.
Unless I am missing something, I don't see why you need to create a ProfileViewModel
at all when you already have access to the selected user object. Instead of setting a selectedUserID
, you could set a selectedUser
and pass the user to NavigationProfileView
.
Here's a full example with this approach:
import SwiftUI
struct NavigationUser: Identifiable, Hashable {
let id: String
let name: String
}
class UsersListViewModel: ObservableObject {
// @Published var selectedUserId: String?
@Published var selectedUser: NavigationUser?
let users = [
NavigationUser(id: "1", name: "John"),
NavigationUser(id: "2", name: "Jane"),
NavigationUser(id: "3", name: "Bob"),
NavigationUser(id: "4", name: "Bill"),
NavigationUser(id: "5", name: "Doug")
]
}
class ProfileViewModel: ObservableObject {
@Published var userId: String
deinit {
print("❌ \(String(describing: type(of: self))) destroyed")
}
init(userId: String) {
print("✅ \(String(describing: type(of: self))) created")
self.userId = userId
}
}
struct NavigationContentViewDemo: View {
@StateObject private var viewModel = UsersListViewModel()
var body: some View {
NavigationStack {
List(viewModel.users) { user in
Button(action: {
// viewModel.selectedUserId = user.id
viewModel.selectedUser = user
}) {
HStack {
Image(systemName: "person.circle")
.foregroundColor(.blue)
Text(user.name)
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
.font(.caption)
}
}
}
.navigationDestination(item: $viewModel.selectedUser) { selectedUser in
NavigationProfileView(user: selectedUser )
}
}
}
}
struct NavigationProfileView: View {
let user: NavigationUser
var body: some View {
let _ = print("Display profile for user: \(user.name)")
Text("User: \(user.name)")
}
}
#Preview {
NavigationContentViewDemo()
}
If you really need a ProfileViewModel
, here's an example using @Observable
, where you still pass a user as parameter, but instantiate the view model in the .onAppear
of the NavigationProfileView
:
import SwiftUI
struct NavigationUser: Identifiable, Hashable {
let id: String
let name: String
}
@Observable
class UsersListViewModel {
var selectedUser: NavigationUser?
let users = [
NavigationUser(id: "1", name: "John"),
NavigationUser(id: "2", name: "Jane"),
NavigationUser(id: "3", name: "Bob"),
NavigationUser(id: "4", name: "Bill"),
NavigationUser(id: "5", name: "Doug")
]
}
@Observable
class ProfileViewModel {
var userId: String
deinit {
print("❌ \(String(describing: type(of: self))) for userId: \(userId) destroyed")
}
init(userId: String) {
print("✅ \(String(describing: type(of: self))) for userId: \(userId) created")
self.userId = userId
}
}
struct NavigationContentViewDemo: View {
@State private var viewModel = UsersListViewModel()
var body: some View {
NavigationStack {
List(viewModel.users) { user in
Button(action: {
viewModel.selectedUser = user
}) {
HStack {
Image(systemName: "person.circle")
.foregroundColor(.blue)
Text(user.name)
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
.font(.caption)
}
}
}
.navigationDestination(item: $viewModel.selectedUser) { selectedUser in
NavigationProfileView(user: selectedUser )
}
}
}
}
struct NavigationProfileView: View {
//Parameters
let user: NavigationUser
//View model
@State private var viewModel: ProfileViewModel?
//Body
var body: some View {
// let _ = print("Display profile for user: \(user.name)") // <- If you uncomment this, it will print twice in this case, because the body will have to render twice due to the unwrapping of the optional state/viewModel.
VStack {
//Safely unwrap the optional viewModel
if let viewModel = viewModel {
Text("User ID: \(viewModel.userId)")
}
}
.onAppear {
viewModel = ProfileViewModel(userId: user.id)
print("Display profile for user: \(user.name)") // <- This will print only once
}
}
}
#Preview {
NavigationContentViewDemo()
}
Based on the discussion in the comments, here's another example, with a two tabs setup and an additional navigation layer that navigates away from the selected profile.
The .onAppear
of the profile view now checks if the viewModel is nil before creating a viewModel instance.
If you change tabs and change back, the navigation state is maintained, as well as the viewModel state.
import SwiftUI
struct NavigationUser: Identifiable, Hashable {
let id: String
let name: String
}
@Observable
class UsersListViewModel {
var selectedUser: NavigationUser?
let users = [
NavigationUser(id: "1", name: "John"),
NavigationUser(id: "2", name: "Jane"),
NavigationUser(id: "3", name: "Bob"),
NavigationUser(id: "4", name: "Bill"),
NavigationUser(id: "5", name: "Doug")
]
}
@Observable
class ProfileViewModel {
var userId: String
deinit {
print("❌ \(String(describing: type(of: self))) for userId: \(userId) destroyed")
}
init(userId: String) {
print("✅ \(String(describing: type(of: self))) for userId: \(userId) created")
self.userId = userId
}
}
struct NavigationContentViewDemo: View {
@State private var viewModel = UsersListViewModel()
@State private var selectedProfileViewModel: ProfileViewModel?
var body: some View {
TabView {
//Home tab
Text("Home Tab")
.tabItem {
Label("Home", systemImage: "house")
}
//Users tab
NavigationStack {
List(viewModel.users) { user in
Button(action: {
viewModel.selectedUser = user
}) {
HStack {
Image(systemName: "person.circle")
.foregroundColor(.blue)
Text(user.name)
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
.font(.caption)
}
}
}
.navigationDestination(item: $viewModel.selectedUser) { selectedUser in
NavigationProfileView(user: selectedUser )
}
}
.tabItem {
Label("Users", systemImage: "person.3.fill")
}
}
}
}
struct NavigationProfileView: View {
//Parameters
let user: NavigationUser
//View model
@State private var viewModel: ProfileViewModel?
//Body
var body: some View {
VStack {
//Safely unwrap the optional viewModel
Text("Profile for: \(user.name)")
if let viewModel = viewModel {
Text("User ID: \(viewModel.userId)")
NavigationLink {
Text("Some other view for userID: \(viewModel.userId)")
} label: {
Text("Navigate to some other view")
}
}
}
.onAppear {
if viewModel == nil {
viewModel = ProfileViewModel(userId: user.id)
print("Created profile view model for user: \(user.name)") // <- This will print only once
}
else {
print("viewModel is not nil")
}
}
}
}
#Preview {
NavigationContentViewDemo()
}
As a follow-up to your edited question...
If you want to create the external dependencies AFTER the navigation triggers, the only options I am aware of are in .onAppear
or .task
of the view. This ensures that the navigation was successful and is probably most flexible from a UI/UX standpoint, since it allows showing a progress indicator as needed. So user taps to navigate, navigation happens right away, and if the models are expensive, progress views can be shown as needed.
Otherwise, the alternative is to prepare the ProfileViewModel
BEFORE navigation, as part of the navigation button action and then trigger navigation if the viewModel is created. This is in order to avoid side effects inside the `.a
As mentioned above, this could introduce delays between the navigation button tap and the actual navigation, even if a progress view is shown (an example of this can be seen in iOS if you go to Settings > Apple account > Personal Information).
Below is a working example of preparing the profile viewModel before navigation. This allows NavigationProfileView
to be passed the viewModel as parameter, without using .onAppear
and without causing multiple instantiations.
I also included a profile child view as an additional navigation layer, that can also accept the same view model.
Note, however, that this approach does NOT guarantee navigation, since the navigation can fail after the viewModel is created, for various reasons, like a misconfiguration in .navigationDestination
or even a completely missing navigation destination altogether.
import SwiftUI
struct NavigationUser: Identifiable, Hashable {
let id: String
let name: String
}
enum NavigationProfileRoute: Hashable, Equatable {
case profile
}
@Observable
class UsersListViewModel {
var navigationPath: [NavigationProfileRoute] = []
var selectedProfileViewModel: ProfileViewModel?
let users = [
NavigationUser(id: "1", name: "John"),
NavigationUser(id: "2", name: "Jane"),
NavigationUser(id: "3", name: "Bob"),
NavigationUser(id: "4", name: "Bill"),
NavigationUser(id: "5", name: "Doug")
]
}
@Observable
class ProfileViewModel {
var userId: String
deinit {
print("❌ \(String(describing: type(of: self))) for userId: \(userId) destroyed")
}
init(userId: String) {
print("✅ \(String(describing: type(of: self))) for userId: \(userId) created")
self.userId = userId
}
}
struct NavigationContentViewDemo: View {
@State private var viewModel = UsersListViewModel()
var body: some View {
TabView {
//Home tab
Text("Home Tab")
.tabItem {
Label("Home", systemImage: "house")
}
//Users tab
NavigationStack(path: $viewModel.navigationPath) {
List(viewModel.users) { user in
Button(action: {
viewModel.selectedProfileViewModel = ProfileViewModel(userId: user.id)
if let _ = viewModel.selectedProfileViewModel {
viewModel.navigationPath.append(.profile)
}
}) {
HStack {
Image(systemName: "person.circle")
.foregroundColor(.blue)
Text(user.name)
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
.font(.caption)
}
}
}
.navigationDestination(for: NavigationProfileRoute.self) { route in
switch route {
case .profile:
if let profileViewModel = viewModel.selectedProfileViewModel {
NavigationProfileView(viewModel: profileViewModel )
}
}
}
}
.tabItem {
Label("Users", systemImage: "person.3.fill")
}
}
}
}
struct NavigationProfileView: View {
//Parameters
@Bindable var viewModel: ProfileViewModel
//Body
var body: some View {
VStack {
//Safely unwrap the optional viewModel
Text("Profile for: \(viewModel.userId)")
NavigationLink {
ProfileChildView(viewModel: viewModel)
} label: {
Text("Additional details")
}
}
}
}
struct ProfileChildView: View {
//Parameters
@Bindable var viewModel: ProfileViewModel
//Body
var body: some View {
Text("Additional details for: \(viewModel.userId)")
}
}
#Preview {
NavigationContentViewDemo()
}