2

I am fairly new to iOS development and have been experimenting with SwiftUI for a bit.

I am stuck for a while with list navigation and can't seem to fix the issue in any way.

Here is what I've done:

struct BlogNavigationGraph: View {
   
    @State private var path = [PostUiModel]()

    var body: some View {
        NavigationStack(path: $path) {
            PostListView(viewModel: /* pass view model here */)
                .navigationDestination(for: PostUiModel.self) { post in
                    PostDetailsView(post: post, viewModel: /* pass view model here */)
                }
        }
    }
}

struct PostListView: View {

    @ObservedObject var viewModel: PostListViewModel

    var body: some View {
        Group {
            switch viewModel.state {
                case .loading: LoadingView()
                case .error: ErrorView()
                case .hasPosts(let data): PostList(data)
            }
        }.onAppear { viewModel.onViewDidAppear() }
    }

}

struct PostList: View {
    let data: [PostUiModel]

    var body: some View {
        List(data, id: \.id) { item in
            PostItem(post: item)
        }
    }
}

struct PostItem: View {
    
    let post: PostUiModel

    var body: some View {
        NavigationLink(value: post) {
            PostItemContent(post: post)
        }
    }

}

struct PostItemContent: View {
 
    var body: some View { 
        // complex content here
    }

}

The problem is that when I first tap on a list item in PostListView, the PostDetailsView is instantiated twice for the same post and this breaks my logic. When I tap on a list item again, the PostDetailsView is instantiated only once and all works as expected.

If I use a NavigationLink with destination and label in the PostListView, then it works as expected, but I need to be able to navigate to nested PostDetailsViews from the PostDetailsView (if there are link to other posts in the same blog) and I can't really do this without the programmatic navigation using path and NavigationStack.

Any ideas what I might be doing wrong?

EDIT - I'm adding a simple view with hardcoded data that can be used to reproduce the issue.

EDIT 2 - I've extended the example to demonstrate the full extent of what I'm doing

import SwiftUI

struct UiModel: Identifiable, Hashable {
    let id: Int
    let title: String
}

struct TestView: View {
    
    @State private var path = [UiModel]()
    
    var body: some View {
        
        NavigationStack(path: $path) {
            TestList()
                .navigationDestination(for: UiModel.self) { item in
                    TestItemDetails(viewModel: TestItemDetailsViewModel(),
                                    item: item) { target in
                        path.append(target)
                    }
                }
        }
    }
}

struct TestList: View {
    
    let data = [
        UiModel(id: 1, title: "Title 1"),
        UiModel(id: 2, title: "Title 2"),
        UiModel(id: 3, title: "Title 3")
    ]
    
    var body: some View {
        List(data) { item in
            TestItem(item: item)
        }
    }
}

struct TestItem: View {
    
    let item: UiModel
    
    
    init(item: UiModel) {
        self.item = item
    }
    
    var body: some View {
        NavigationLink(value: item) {
            TestItemContent(item: item)
        }
    }
    
}

struct TestItemContent: View {
    
    let item: UiModel
    
    init(item: UiModel) {
        self.item = item
    }
    
    var body: some View {
        Text(item.title)
    }
    
}

struct TestItemDetails: View {
    
    @ObservedObject var viewModel: TestItemDetailsViewModel
    
    let item: UiModel
    let onRedirect: (UiModel) -> Void
    
    var body: some View {
        VStack {
            Text("Details for: {\(item.id), \(item.title)}")
            
            switch viewModel.state {
            case .normal: Text("Normal")
            case .error: Text("Error")
            }
            
            Button("Go to next") {
                onRedirect(
                    UiModel(
                        id: 5,
                        title: "Redirected item title"
                    )
                )
            }
        }.onAppear { viewModel.onViewDidAppear() }
    }
}

enum TestItemDetailsState {
    case normal
    case error
}

class TestItemDetailsViewModel: ObservableObject {
    
    @Published var state: TestItemDetailsState = .normal
    
    func onViewDidAppear() {
        state = .error
    }
    
}

struct TestView_Previews: PreviewProvider {
    static var previews: some View {
        TestView()
    }
}

TestItem.init is called twice for each item. TestItemContent.init is called once for each item. TestItemDetails.init is called twice for each item you tap on.

When the TestItemDetails.onAppear gets called, the TestItemDetailsViewModel.onViewDidAppear gets called, but the change in state is not propagated to the TestItemDetails view. If I don't use the .navigationDestination modifier, it all works, but then pushing another TestItemDetails view on top of the current one is something I can't figure out how to do.

Danail Alexiev
  • 7,624
  • 3
  • 20
  • 28
  • 1
    To make it easier to answer your question, can you please create a [mcve] – Ashley Mills Jan 18 '23 at 09:19
  • 1
    @AshleyMills example added – Danail Alexiev Jan 18 '23 at 10:36
  • I assume from your question that you're doing work in the `TestItemDetails.init`? Could this be moved to, say `.task` or `.onAppear`? Or could this work be done in `TestView` and the result passed to `TestItemDetails`? – Ashley Mills Jan 18 '23 at 11:12
  • @AshleyMills I'm not doing any work in `TestItemDetails.init`. All the logic (sinking publishers) has been implemented in `.onAppear`. This leads to loosing all published events and being stuck on a loading screen for the second instance of `TestItemDetails`. If I sink in `TestItemDetails.init` then it all works, but I'm not sure this is the correct thing to do. – Danail Alexiev Jan 18 '23 at 11:53
  • @AshleyMills even if I don't do any background operations in `.onAppear` and just modify the `@Published` var in my view model, the change is not propagated to the `TestItemdDetails` view – Danail Alexiev Jan 18 '23 at 12:32
  • @AshleyMills I've extended the example so it shows what I mean – Danail Alexiev Jan 20 '23 at 11:12

1 Answers1

1

The .navigationDestination is called multiple times when an object is added to path - this doesn't have anything specifically to do with your code.

You can see this by changing your code to:

struct TestView: View {
    var body: some View {
        NavigationStack {
            TestList()
                .navigationDestination(for: UiModel.self, destination: destination)
        }
    }
    
    func destination(_ item: UiModel) -> some View {
        print("Destination", item)
        return TestItemDetails(item: item)
    }
}

If you're doing work in your TestItemDetails.init, then you could consider moving it to a .task or .onAppear modifier on TestItemDetails.

Alternatively, consider doing the work in TestView before appending to path

Ashley Mills
  • 50,474
  • 16
  • 129
  • 160
  • I have actually found that onAppear is called on the wrong view, so when SwiftUI creates two views, the one which has onAppear is not the actual view which is visible. Absolutely unbelievable – Peter Suwara Aug 24 '23 at 01:58