swift - Unexpected NavigationStack behavior - Stack Overflow

I’ve noticed some unexpected behavior in SwiftUI when switching between navigation stacks.Below is a s

I’ve noticed some unexpected behavior in SwiftUI when switching between navigation stacks.

Below is a simplified example that mirrors the flow in my real app:

1️⃣ The user starts on the onboarding screen.

2️⃣ They proceed to the authorization screen.

3️⃣ After successful authentication, they transition to the main app flow.

Behavior Flow:

  1. Initially, there are two navigation stacks, and the user sees the first one.

✅ [NavigationStack1] → [View1]

[NavigationStack2] (inactive)

  1. A child view is pushed onto the first navigation stack.

✅ [NavigationStack1] → [View1] → [View2]

[NavigationStack2] (inactive)

  1. The user switches to the second navigation stack.

[NavigationStack1] → [View1] → [View2] (inactive)

✅ [NavigationStack2] → [View3]

  1. Observe the appear and disappear methods, along with the initialization of state variables inside View2.
  • View 1 appears and disappears a second time unexpectedly
  • View 2 ViewModel is initialized twice
  • View 2 disappears and is deinitialized twice

Does anyone know why this happens and how to prevent it?

struct MainView: View {
    enum Destination: Hashable {
        case second
    }
    @State var isThirdPresented: Bool = false
    @State var path: NavigationPath = .init()
    var body: some View {
        if isThirdPresented {
            NavigationStack {
                ContentView(identifier: "3️⃣") { }
            }
        } else {
            NavigationStack(path: $path) {
                ContentView(identifier: "1️⃣") {
                    path.append(Destination.second)
                }
                .navigationDestination(for: Destination.self) { destination in
                    switch destination {
                    case .second:
                        ContentView(identifier: "2️⃣") {
                            isThirdPresented.toggle()
                        }
                    }
                }
            }
        }
    }
}
struct ContentView: View {
    @Observable class ViewModel {
        let identifier: String
        init(identifier: String) {
            self.identifier = identifier
            print("\(identifier) view model initialized")
        }
        deinit {
            print("\(identifier) view model deinitialized")
        }
    }
    @State var viewModel: ViewModel
    let action: () -> Void
    init(identifier: String, action: @escaping () -> Void) {
        viewModel = .init(identifier: identifier)
        self.action = action
    }

    var body: some View {
        VStack {
            Text("View \(viewModel.identifier)")
            Button("Action", action: action)
        }
        .onAppear { print("\(viewModel.identifier) appeared") }
        .onDisappear { print("\(viewModel.identifier) disappeared") }
    }
}

I’ve noticed some unexpected behavior in SwiftUI when switching between navigation stacks.

Below is a simplified example that mirrors the flow in my real app:

1️⃣ The user starts on the onboarding screen.

2️⃣ They proceed to the authorization screen.

3️⃣ After successful authentication, they transition to the main app flow.

Behavior Flow:

  1. Initially, there are two navigation stacks, and the user sees the first one.

✅ [NavigationStack1] → [View1]

[NavigationStack2] (inactive)

  1. A child view is pushed onto the first navigation stack.

✅ [NavigationStack1] → [View1] → [View2]

[NavigationStack2] (inactive)

  1. The user switches to the second navigation stack.

[NavigationStack1] → [View1] → [View2] (inactive)

✅ [NavigationStack2] → [View3]

  1. Observe the appear and disappear methods, along with the initialization of state variables inside View2.
  • View 1 appears and disappears a second time unexpectedly
  • View 2 ViewModel is initialized twice
  • View 2 disappears and is deinitialized twice

Does anyone know why this happens and how to prevent it?

struct MainView: View {
    enum Destination: Hashable {
        case second
    }
    @State var isThirdPresented: Bool = false
    @State var path: NavigationPath = .init()
    var body: some View {
        if isThirdPresented {
            NavigationStack {
                ContentView(identifier: "3️⃣") { }
            }
        } else {
            NavigationStack(path: $path) {
                ContentView(identifier: "1️⃣") {
                    path.append(Destination.second)
                }
                .navigationDestination(for: Destination.self) { destination in
                    switch destination {
                    case .second:
                        ContentView(identifier: "2️⃣") {
                            isThirdPresented.toggle()
                        }
                    }
                }
            }
        }
    }
}
struct ContentView: View {
    @Observable class ViewModel {
        let identifier: String
        init(identifier: String) {
            self.identifier = identifier
            print("\(identifier) view model initialized")
        }
        deinit {
            print("\(identifier) view model deinitialized")
        }
    }
    @State var viewModel: ViewModel
    let action: () -> Void
    init(identifier: String, action: @escaping () -> Void) {
        viewModel = .init(identifier: identifier)
        self.action = action
    }

    var body: some View {
        VStack {
            Text("View \(viewModel.identifier)")
            Button("Action", action: action)
        }
        .onAppear { print("\(viewModel.identifier) appeared") }
        .onDisappear { print("\(viewModel.identifier) disappeared") }
    }
}

Share Improve this question edited Mar 7 at 10:53 Mark Osypenko asked Mar 6 at 19:01 Mark OsypenkoMark Osypenko 861 silver badge4 bronze badges 6
  • This is expected, there is a whole section on it in the State documentation – lorem ipsum Commented Mar 6 at 20:52
  • Note, every time you navigate to a ContentView you create a newViewModel that has no relations to any others. In other words, it is completely useless to have this ViewModel. Note also, typically @State private var ... should be initialised in init()like this: self._viewModel = State(initialValue: ViewModel(identifier: identifier)). – workingdog support Ukraine Commented Mar 6 at 23:38
  • What's unexpected is this entire flow. If the user start on onboarding, proceeds to authorization and then to main app flow, why the circling to ContentView so many times? And why the need for all the init and deinit? – Andrei G. Commented Mar 7 at 0:24
  • @workingdogsupportUkraine according to Apple’s documentation, .init(initialValue: ...) behaves the same as init(wrappedValue: ...). However, init(wrappedValue: ...) should not be called directly, as SwiftUI manages this internally. – Mark Osypenko Commented Mar 7 at 10:56
  • What I meant to say is, typically for State, your ContentView init should be of the form self._viewModel = State(initialValue: ViewModel(identifier: identifier), not viewModel = .init(identifier: identifier) which only initialises a ViewModel not a State. In any case, it does not matter since initialising an @Observable class in init(...) is not recommended at all, use .onAppear{...} instead. – workingdog support Ukraine Commented Mar 7 at 11:34
 |  Show 1 more comment

1 Answer 1

Reset to default 0

I had never seen an observable class inside a View until now, which is maybe why I find this entire setup very twisted.

If the "observable" ViewModel is inside a View struct, who/what is going to observe it?

Then, there's the issue of all the init and deinit and looping around to the "same" view passing different string "identifiers". As others mentioned comments, you're never actually coming back to the same view, since you're creating a new instance of ContentView every time.

If you have an established flow, you should have models that support it.

For example, instead of string identifiers, you could have an Auhorization enum for the various possible auth cases, and for each case an associated route. So if the user status is expired, the logic for where to go/navigate next is defined therein:

enum Authorization: String {
    case authorized, denied, locked, expired, guest
    
    var route: DestinationRoute {
        switch self {
            case .authorized: .home
            case .denied: .error
            case .locked: .error
            case .expired: .error
            case .guest: .authorization
        }
    }
}

The same enum can be further enhanced with various options like a color for each case;

var color: Color {
    switch self {
        case .authorized: .green
        case .denied: .red
        case .locked: .pink
        case .expired: .gray
        case .guest: .orange
    }
}

Instead of forcing the same ContentView to support all these cases, it would be simpler and more flexible to have dedicated views. Maybe not for all cases, but for example, auth errors could go to an ErrorView. Otherwise, your ContentView will get messy quickly.

For observation, you can use singleton @Observable classes that can be accessed from any view, or go with non-singletons that would have to be passed via environment. In this example, I used singletons for simplicity.

//Authorization observable singleton
@Observable class UserAuthorization {
    
    var status: Authorization = .guest
    var navigateOnStatusChange = false
    
    static let service = UserAuthorization() // <- the singleton
    
    private init() {}
    
    func setAuthStatus(_ status: Authorization) {
        self.status = status
        
        if navigateOnStatusChange {
            Navigator.nav.path.append(self.status.route)
        }
    }
}

Like this, you can access or modify the user status from any view using UserAuthorization.service.status. Or, assign it to a view property:

//Observables
let userAuth = UserAuthorization.service

Same idea for a navigation observable that makes the navigation path accessible and modifiable from any view, or from any function, including the UserAuthorization class, which has an option for triggering navigation when the user status changes.

//Navigator observable singleton
@Observable class Navigator {
    
var path = NavigationPath()
    
static let nav = Navigator()
    
private init() {}
}

Here's the complete code that puts all this together:

import SwiftUI

enum DestinationRoute: Hashable, View {
    case authorization, home, error
    
    var body: some View {
        switch self {
            case .authorization:
                AuthorizationView()
            case .home:
                MainAppFlowView()
            case .error:
                ErrorView()
        }
    }
}

enum Authorization: String {
    case authorized, denied, locked, expired, guest
    
    var color: Color {
        switch self {
            case .authorized: .green
            case .denied: .red
            case .locked: .pink
            case .expired: .gray
            case .guest: .orange
        }
    }
    
    var icon: String {
        switch self {
            case .authorized: "lock.open"
            case .denied: "lock"
            case .locked: "lock"
            case .expired: "lock.badge.clock"
            case .guest: "person.fill.questionmark"
        }
    }
    
    var route: DestinationRoute {
        switch self {
            case .authorized: .home
            case .denied: .error
            case .locked: .error
            case .expired: .error
            case .guest: .authorization
        }
    }
    
    var description: String {
        switch self {
            case .authorized: "All good, you may proceed."
            case .denied: "Your access has been denied"
            case .locked: "Your account is locked."
            case .expired: "Your access has expired."
            case .guest: "You are not logged in."
        }
    }
}

struct OnboardingView: View {
    
    //State values
    @Bindable var navigator = Navigator.nav
    
    //Observable binding
    @Bindable var userAuth = UserAuthorization.service

    //Body
    var body: some View {
        VStack {
            NavigationStack(path: $navigator.path) {
                VStack {
                    Label("Your authorization status is: \(userAuth.status)", systemImage: userAuth.status.icon)
                        .fontWeight(.bold)
                        .foregroundStyle(userAuth.status.color)
                    Text("Click below to continue.")
                        .foregroundStyle(.secondary)
                        .font(.subheadline)
                    
                    Button {
                        navigator.path.append(userAuth.status.route)
                    } label: {
                        Text("Continue")
                    }
                    .buttonStyle(.borderedProminent)
                    .padding()
                    .tint(userAuth.status.color)
                }
                .navigationDestination(for: DestinationRoute.self) { destination in
                    destination
                }
                .navigationTitle("Welcome")
            }
            
            //Demo controls
            VStack {
                Text("Simulate authorization status:")
                    .font(.caption)
                
                //Buttons
                HStack {
                    Group {
                        Button {
                            userAuth.setAuthStatus(.guest)
                        } label: {
                            Text("Guest")
                        }
                        .tint(Authorization.guest.color)
                        
                        Button {
                            userAuth.setAuthStatus(.authorized)
                        } label: {
                            Text("Authorized")
                        }
                        .tint(Authorization.authorized.color)
                        
                        Button {
                            userAuth.setAuthStatus(.locked)
                        } label: {
                            Text("Locked")
                        }
                        .tint(Authorization.locked.color)
                        
                        Button {
                            userAuth.setAuthStatus(.expired)
                        } label: {
                            Text("Expired")
                        }
                        .tint(Authorization.expired.color)
                    }
                    .buttonStyle(.bordered)
                }
                
                //Toggle
                Toggle("Navigate on status change", isOn: $userAuth.navigateOnStatusChange)
                    .foregroundStyle(.secondary)
                    .scaleEffect(0.8)
                    .fixedSize()
            }
            .padding(.horizontal)
        }
    }
}

//Navigator observable singleton
@Observable class Navigator {
    
    var path = NavigationPath()
    
    static let nav = Navigator()
    
    private init() {}
}

//Authorization observable singleton
@Observable class UserAuthorization {
    
    var status: Authorization = .guest
    var navigateOnStatusChange = false
    
    static let service = UserAuthorization()
    
    private init() {}
    
    func setAuthStatus(_ status: Authorization) {
        self.status = status
        
        if navigateOnStatusChange {
            Navigator.nav.path.append(self.status.route)
        }
    }
}

struct AuthorizationView: View {
    
    //Observables
    let userAuth = UserAuthorization.service
    let navigator = Navigator.nav
    
    //Body
    var body: some View {
        VStack {
            Text("You must log in:")
        
            Button {
                userAuth.status = .authorized
                navigator.path.append(userAuth.status.route)
            } label: {
                Text("Proceed with authorization")
            }
            .tint(Authorization.authorized.color)
            
            Button {
                userAuth.status = .expired
                navigator.path.append(userAuth.status.route)
            } label: {
                Text("Proceed without authorization")
            }
            .tint(Authorization.expired.color)
        }
        .navigationTitle("Authorization")
        .buttonStyle(.borderedProminent)

    }
}

struct MainAppFlowView: View {
    
    //Body
    var body: some View {
        
        VStack(spacing: 10) {
            Image(systemName: "party.popper.fill")
                .font(.system(size: 60))
                .foregroundStyle(.pink)
            Text("Congratulations!")
                .fontWeight(.bold)
                .font(.title)
            Text("You made it to the main screen.")
        }
        .navigationTitle("Main")
        
    }
}

struct ErrorView: View {
    
    //Observables
    let userAuth = UserAuthorization.service
    
    //Body
    var body: some View {
        
        VStack {
            ContentUnavailableView {
                Label("\(userAuth.status.rawValue.capitalized)", systemImage: userAuth.status.icon)
            } description: {
                Text(userAuth.status.description)
            } actions: {
                //..
            }
            .foregroundStyle(userAuth.status.color)
        }
    }
}


//Preview
#Preview("Onboarding") {
    OnboardingView()
}

#Preview("Main App") {
    NavigationStack {
        MainAppFlowView()
    }
}

发布者:admin,转转请注明出处:http://www.yc00.com/questions/1744955352a4603159.html

相关推荐

  • swift - Unexpected NavigationStack behavior - Stack Overflow

    I’ve noticed some unexpected behavior in SwiftUI when switching between navigation stacks.Below is a s

    1天前
    40

发表回复

评论列表(0条)

  • 暂无评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信