Search code examples
swiftswiftuiswiftui-navigationview

SwiftUI: NavigationView detail view pops backstack when previous's view List changes


I have a List of ids and scores in my first screen.

In the detail screen I click and call a callback that adds to the score and resorts the List by the score.

When I do this with an item at the top of the list, nothing happens. (Good)

When I do this with an item at the bottom of the list, the navigation view pops the backstack and lands me back on the first page. (Bad)

import SwiftUI

class IdAndScoreItem {
    var id: Int
    var score: Int
    init(id: Int, score: Int) {
        self.id = id
        self.score = score
    }
}

@main
struct CrazyBackStackProblemApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationView {
                ListView()

            }
            .navigationViewStyle(.stack)
        }
    }
}

struct ListView: View {
    @State var items = (1...50).map { IdAndScoreItem(id: $0, score: 0) }
    func addScoreAndSort(item: IdAndScoreItem) {
        items = items
            .map {
                if($0.id == item.id) { $0.score += 1 }
                return $0
            }
            .sorted {
                $0.score > $1.score
            }
    }
    var body: some View {
        List(items, id: \.id) { item in
            NavigationLink {
                ScoreClickerView(
                    onClick: { addScoreAndSort(item: item) }
                )
            } label: {
                Text("id: \(item.id) score:\(item.score)")
            }
        }
    }
}

struct ScoreClickerView: View {
    var onClick: () -> Void
    var body: some View {
        Text("tap me to increase the score")
            .onTapGesture {
                onClick()
            }
    }
}

How can I make it so I reorder the list on the detail page, and that's reflected on the list page, but the navigation stack isn't popped (when I'm doing it on a list item at the bottom of the list). I tried added navigationStyle(.stack) to no avail.

Thanks for any and all help!


Solution

  • Resort changes order of IDs making list recreate content that leads to current NavigationLinks destroying, so navigating back.

    A possible solution is to separate link from content - it can be done with introducing something like selection (tapped row) and one navigation link activated with that selection.

    Tested with Xcode 14 / iOS 16

    demo

    @State private var selectedItem: IdAndScoreItem?  // selection !!
    
    var isNavigate: Binding<Bool> {   // link activator !!
        Binding(get: { selectedItem != nil}, set: { _ in selectedItem = nil })
    }
    
    var body: some View {
        List(items, id: \.id) { item in
            Text("id: \(item.id) score:\(item.score)")   // tappable row
                .frame(maxWidth: .infinity, alignment: .leading)
                .contentShape(Rectangle())
                .onTapGesture {
                    selectedItem = item
                }
        }
        .background(
            NavigationLink(isActive: isNavigate) {  // one link !!
                ScoreClickerView {
                    if let item = selectedItem {
                        addScoreAndSort(item: item)
                    }
                }
            } label: {
                EmptyView()
            }
        )
    }