Shawn Frank
Shawn Frank

Reputation: 5213

SwiftUI navigation link infinite loop causes app to freeze

I have the following SwiftUI set up

In my main, I have something like this

// singleton
@StateObject var authStatusManager = Factory.shared.authStatusManager

var body: some Scene {
    WindowGroup {
        if authStatusManager.isSignedIn {
            NavigationStack {
                ProjectsView(projectsViewModel: projectViewModel)
            }
        } else {
            LoginView(loginViewModel: loginViewModel)
        }
    }
}

The first time the user will see the LoginView but after logging in, we save the user token in user defaults and subsequently take the user directly to the ProjectsView every time the app launches.

When the user successfully logs in, we also take the user to the ProjectsView and it is in this journey where the problem arises. Here is how the LoginView is set up at a high level:

var body: some View {
    ZStack {
        // different login elements
    }
    .onChange(of: loginViewModel.apiCallState) { currentAPIState in
        if currentAPIState == .success {
            // This causes the Projects view to get loaded
            Factory.shared.authStatusManager.isSignedIn = true
        }
    }

The projects view is a simple List with some rows and works normally, the high level code is something like this:

var body: some View {
    ZStack {
        VStack {
            List {
                ForEach(Array(projectsViewModel.projectRowViewModels.enumerated()),
                        id: \.0) { index, projectViewModel in
                    Section {
                        NavigationLink(destination: CurrentProjectView(currentProjectViewModel: projectsViewModel.selectedProjectViewModel(at: index))) {
                            ProjectRow(projectRowViewModel: projectViewModel)
                        }
                    }
                }
            }
            .id(UUID())
        }
    }
}

Everything is fine up until this point regardless of if we come from the LoginView path or directly to this view if the user is already logged in.

The issue arises when we go to the CurrentProjectView which is also a list

var body: some View {
    content
}

private var content: some View {
    List {
        projectImageView
        
        ForEach(currentProjectViewModel.sections) { section in
            if section == .surveys {
                getSurveySection()
             }
    }
}

private func getSurveySection() -> some View {
    ForEach(currentProjectViewModel.surveys.enumeratedArray(),
            id: \.element) { index, field in
        if index == 0 {
            // create a button
        }
        
        getNavigationLink(index: index, field: field)
    }
}

// This function seems to be called in an infinite loop when I tap on the navigation link
@ViewBuilder
private func getNavigationLink(index: Int, field: String) -> some View {
    AnyView(NavigationLink(field) {
        SurveyView(surveyViewModel: currentProjectViewModel.getSurveyViewModel(selectedSurveyIndex: index))
    })
}

So the last line of code goes into an infinite loop only if we come from this path:

App Launch -> Login Screen -> Projects List Screen -> Current Project Screen

Tapping on the navigation link freezes the app and we have an infinite loop

However, if we come to the same screen after the user has already logged in and skipping the Login screen, everything works fine, that is:

App Launch -> Projects List Screen -> Current Project Screen

Tapping the same navigation link functions and takes the user to the right destination

I feel it could have something to do with how I've set up the navigation stack, however I can't seem to figure out the issue.

Upvotes: 2

Views: 699

Answers (2)

Ussama Irfan
Ussama Irfan

Reputation: 1

When I removed the

@Environment(.dismiss) var dismiss

from the view which contained

Navigation Link

and

Navigation Destination

it worked for me

Upvotes: 0

Adam Zarn
Adam Zarn

Reputation: 2100

I was having this issue and the problem ended up being related to accessing properties outside of what was passed into a .navigationDestination block.

Here's a simple example that had the issue. Notice that string is being accessed but it was not passed into the .navigationDestination block:

struct MainView: View {
    @State var path = NavigationPath()
    @State var string: String = ""
    
    enum Destination {
        case detail
    }
    
    var body: some View {
        NavigationStack(path: $path) {
            Button(action: {
                path.append(Destination.detail)
            }, label: {
                Text("Push Detail view")
            })
            .navigationDestination(for: Destination.self, destination: { _ in
                DetailView(viewModel: DetailViewModel(string: string))
            })
        }
    }
}

class DetailViewModel: ObservableObject {
    var string: String
    init(string: String) {
        self.string = string
    }
}

struct DetailView: View {
    @StateObject var viewModel: DetailViewModel
    
    init(viewModel: DetailViewModel) {
        self._viewModel = StateObject(wrappedValue: viewModel)
    }
    
    var body: some View {
        Text(viewModel.string)
    }
}

A good rule of thumb seems to be to only access data that was passed into the .navigationDestination block. You can achieve that in this simple example by associating the string value to the Destination enum case. Here's the updated MainView struct:

struct MainView: View {
    @State var path = NavigationPath()
    @State var string: String = ""
    
    enum Destination: Hashable {
        case detail(string: String)
    }
    
    var body: some View {
        NavigationStack(path: $path) {
            Button(action: {
                path.append(Destination.detail(string: string))
            }, label: {
                Text("Push Detail view")
            })
            .navigationDestination(for: Destination.self, destination: { destination in
                if case let .detail(passedInString) = destination {
                    DetailView(viewModel: DetailViewModel(string: passedInString))
                }
            })
        }
    }
}

Upvotes: 0

Related Questions