swiftui

Weird behavior with NavigationSplitView and @State


I have a NavigationSplitView in my app, I have an @State variable in my detail view that gets created in init.

When I select something from the sidebar and the detail view renders, at first everything looks ok. But when I select a different item on the sidebar, the contents of the @state variable don't get recreated.

Using the debugger I can see the init of the detail view get called every time I select a new item in the sidebar, and I can see the @State variable get created. But when it actually renders, the @State variable still contains the previous selection's values.

I've reduced this problem to a test case I'll paste below. The top text in the detail view is a variable passed in from the sidebar, and the second line of text is generated by the @State variable. Expected behavior would be, if I select "one" the detail view would display "one" and "The name is one". If I select "two" the detail view would display "two" and "The name is two".

Instead, if I select "one" first, it displays correctly. But when I select "two", it displays "two" and "The name is one".

Note that if I select "two" as the first thing I do after launching the app, it correctly displays "two" and "The name is two", but when I click on "one" next, it will display "one" and "the name is two". So the state variable is being set once, then never changing again,

Here's the sample code and screenshots:

import SwiftUI

struct Item: Hashable, Identifiable {
    let id = UUID()
    let name: String
}

struct ContentView: View {
    
    @State private var selectedItem: Item.ID? = nil
    
    private let items = [Item(name: "one"), Item(name: "two"), Item(name: "three")]
    
    func itemForID(_ id: UUID?) -> Item? {
        guard let itemID = id else { return nil }
        return items.first(where: { item in
            item.id == itemID
        })
    }
    
    var body: some View {
        NavigationSplitView{
            List(selection: $selectedItem) {
                ForEach(items) { item in
                    Text(item.name)
                        .tag(item.id)
                }
            }
        } detail: {
            if let name = itemForID(selectedItem)?.name {
                DetailView(name: name)
            } else {
                Text("Select an item")
            }
        }
    }
}

struct DetailView: View {
    
    @State var detailItem: DetailItem
    
    var name: String
    
    init(name: String) {
        self.name = name
        _detailItem = State(wrappedValue: DetailItem(name: name))
    }
    
    var body: some View {
        VStack {
            Text(name)
            Text(detailItem.computedText)
        }
    }
}

struct DetailItem {
    let name: String
    
    var computedText: String {
        return "The name is \(name)"
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

First selection, expected result Second selection, expected to see "The name is two"


Solution

  • This has nothing to do with NavigationSplitView, but how you initialise @State property.

    According to the Apple document on @State (https://developer.apple.com/documentation/swiftui/state):

    Don’t initialise a state property of a view at the point in the view hierarchy where you instantiate the view, because this can conflict with the storage management that SwiftUI provides.

    As well as the documentation of init(wrappedValue:) (https://developer.apple.com/documentation/swiftui/state/wrappedvalue):

    Don’t call this initializer directly. Instead, declare a property with the State attribute, and provide an initial value:

    @State private var isPlaying: Bool = false
    

    From my understanding, if you force to initialise the state in the view init, it will persist through the lifetime of the view, and subsequence change of it won't take any effect on the view.

    The recommended way in Apple documentation is to create the struct in the parent view and pass it to the child view, and if you need to change the struct in the child view, use @Binding to allow read and write access.

    If you want to ignore the documentation and force it to work, you can give an id to your DetailView, forcing it to refresh the view when the item id has changed:

    var body: some View {
        NavigationSplitView{
            List(selection: $selectedItem) {
                ForEach(items) { item in
                    Text(item.name)
                        .tag(item.id)
                }
            }
        } detail: {
            if let name = itemForID(selectedItem)?.name {
                DetailView(name: name)
                    .id(selectedItem) // Fresh @State for every item
            } else {
                Text("Select an item")
            }
        }
    }