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

Viewed 69

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!

2 Answers

Do your sorting on onAppear. No need to sort on each click.

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

Note : No need to use map here. since you are using class so it will update with reference.

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()
        }
    )
}
Related