ogre-dru
ogre-dru

Reputation: 41

SwiftUI NavigationSplitView Detail View not getting updated

In a NavigationSplitView, the Navigation split is just an array of NavigationLinks via a for each loop. This works as expected.

The Detail split is intended to be a View that changes based upon the selected content via the link. Oddly, it works with a SwiftUI native view like, 'Text', but fails with a custom View.

For clarity, I have reduced the code to it's bare minimums to demonstrate the issue.

ContentView.swift

struct ContentView: View {
    @StateObject var controller = DetailViewTestingController()
    
    var body: some View {
        NavigationSplitView {
            VStack {
                List(selection: $controller.selection) {
                    ForEach(controller.listOfObjects, id: \.self.id) { _object in
                        NavigationLink(value: _object.id) {
                            Text(_object.title ?? "no title")
                        }
                        .buttonStyle(PlainButtonStyle())
                        .padding(.vertical)
                    }
                }
            }
            .navigationSplitViewColumnWidth(240)
        } detail: {
            ZStack {
                VStack{
                    if (controller.selection ?? -1 >= 0) {
                        Text("Direct: \(controller.listOfObjects[controller.selection!].title ?? "none")")
                        Text("Found: \(controller.detailObject.title ?? "none")")
                        DetailView(detailObject: $controller.detailObject)
                    }
                }
            }
        }
    }
}

DetailView.swift

struct DetailView: View {
    @Binding var detailObject : DetailObject
    
    var body: some View {
        Text("Object Title: \(detailObject.title ?? "none")")
    }
}

DetailObject.swift

public class DetailObject : Identifiable, Hashable, ObservableObject {

    public var id: Int
    public var title : String?
    
    public init(id: Int, title: String? = nil) {
        self.id = id
        self.title = title
    }
    
    // MARK: Hashable Protocol
    
    open func hash(into hasher: inout Hasher) {
        hasher.combine(id)
        hasher.combine(title)
    }
    
    public static func == (lhs: DetailObject, rhs: DetailObject) -> Bool {
        if (lhs.id == rhs.id) { return false }
        if (lhs.title == rhs.title) { return false }
        return true
    }
}

DetailViewTestingController.swift

open class DetailViewTestingController : ObservableObject {
    @Published public var listOfObjects : [DetailObject] = []
    @Published public var detailObject : DetailObject = DetailObject(id: 0, title: "")
    @Published public var selection : Int? {
        didSet {
            detailObject = DetailObject(id: 0, title: "")
            if (selection == nil) { return }
            for d in listOfObjects {
                if (d.id == selection) {
                    detailObject = d
                    return
                }
            }
        }
    }
    
    public init() {
        listOfObjects.append(DetailObject(id: 0, title: "one"))
        listOfObjects.append(DetailObject(id: 1, title: "two"))
        listOfObjects.append(DetailObject(id: 2, title: "three"))
        listOfObjects.append(DetailObject(id: 3, title: "four"))
    }
}

The expectation is that the detailObject in passed into the DetailView would update the contents of the DetailView as it does in the TextView ( and moving the entire contents into the main code, it works, but fails when in the subview.

It is my understanding that the @Binding should accomplish this. I have also tried using @ObservedObject, with no change in behavior. I am 100% certain this is simply something stupid that I am not seeing.

Upvotes: 3

Views: 722

Answers (2)

ogre-dru
ogre-dru

Reputation: 41

Alright, after a good bit of poking and testing, and swearing ( and maybe some medicinal craft brew 'tasting' ), I think I fully understand the issue now, and I am not entirely sure that is should not be a Radar..

The heart of the issue is that and @ObservedObject does not detect object replacement, only object changes. If the ObservedObject is replaced by another of the same type, the view never re-renders unless another property triggers it. By adding an indirect/container ObservableObject in the middle, everything works as expected, and the properties on the object ARE reflected when they change. While I have not gone digging under the covers to prove this conclusively, it does strongly support my hypothesis.

The first solution: was to simply pass an additional parameter to the DetailView that DID trigger the change, in this case, the Id of the selection.

Solution 1 - Added Paramter with Hidden Display Element

Alter the DetailView to accept and display the Selection in a hidden element:

DetailView.swift

struct DetailView: View {
    @ObservedObject var reference : DetailObjectReference
    var selection : UUID? = nil
    
    var body: some View {
        VStack {
            Text("Object Title: \(reference.references?.title ?? "none")")
            Text("Hidden Value: \(selection?.uuidString ?? "none")").hidden()
        }
    }
}

ContentView.swift

struct ContentView: View {
    @StateObject var controller = DetailViewTestingController()
    
    var body: some View {
        NavigationSplitView {
            VStack {
                List(selection: $controller.selection) {
                    ForEach(controller.listOfObjects, id: \.self.id) { _object in
                        NavigationLink(value: _object.id) {
                            Text(_object.title ?? "no title")
                        }
                        .buttonStyle(PlainButtonStyle())
                        .padding(.vertical)
                    }
                }
            }
            .navigationSplitViewColumnWidth(240)
        } detail: {
            ZStack {
                VStack{
                    if (controller.selection ?? -1 >= 0) {
                        Text("Direct: \(controller.listOfObjects[controller.selection!].title ?? "none")")
                        Text("Found: \(controller.detailObject.title ?? "none")")
                        DetailView(detailObject: $controller.detailObject, selection: controller.connection)
                    }
                }
            }
        }
    }
}

While this solution works, it is hackish, and left a bad taste in my mouth for when another developer has to support this code in the future.

Solution 2 - An Indirect ObservableObject

A solution that I found more palatable, and more readable in the future was to interpose an ObservableObject in between the changing value and the display object that allowed the property to change while still triggering the ObservedObject on the client.

DetailView.swift

struct DetailView: View {
    @ObservedObject var reference : DetailObjectReference
    
    var body: some View {
        VStack {
            Text("Object Title: \(reference.references?.title ?? "none")")
        }
    }
}

with a modification to the ContentView to handle the setting of the reference ( as well as on the model for other reasons ).

ContentView.swift

struct ContentView: View {
    @StateObject var controller = DetailViewTestingController()
    @StateObject var reference = DetailObjectReference()
    @State var selection : UUID? = nil
    
    var body: some View {
        NavigationSplitView {
            VStack {
                List(selection: $selection) {
                    ForEach(controller.articles, id: \.self.id) { _object in
                        NavigationLink(value: _object.articleId) {
                            Text(_object.title ?? "no title")
                        }
                        .buttonStyle(PlainButtonStyle())
                        .padding(.vertical)
                    }
                }
            }
            .navigationSplitViewColumnWidth(240)
        } detail: {
            ZStack {
                VStack{
                    if (selection != nil) {
                        Text("Found: \(controller.article.title ?? "none")")
                        DetailView(reference: reference)
                    }
                }
            }
        }
        .onChange(of: selection, initial: true) { (before, after) in
            if (selection == nil) { return }
            for d in controller.articles {
                if (d.id == selection) {
                    controller.article = d
                    reference.references = d
                    return
                }
            }
        }
    }
}

and the addition of a DetailObjectReference that exists as a persistent object in them middle

DetailObjectReference.swift

public class DetailObjectReference : ObservableObject {

    @Published public var references : Article?
    @Published public var isEditable : Bool = false
    
    public init() {
    }
    
}

With this in place, everything works, as expected and limits the amount of codependent code and assumptions regarding the environment.

Upvotes: 1

Cheolhyun
Cheolhyun

Reputation: 185

Passing it as @EnvironmentObject instead of @Binding will fix the problem.

DetailView.swift

struct DetailView: View {
    @EnvironmentObject var detailObject: DetailObject  // ✅
    
    var body: some View {
        Text("Object Title: \(detailObject.title ?? "none")")
    }
}

ContentView.swift

...
...

} detail: {
    ZStack {
        VStack{
            if (controller.selection ?? -1 >= 0) {
                Text("Direct: \(controller.listOfObjects[controller.selection!].title ?? "none")")
                Text("Found: \(controller.detailObject.title ?? "none")")
                //DetailView(detailObject: $controller.detailObject)
                DetailView()
                    .environmentObject(controller.detailObject)  // ✅
            }
        }
    }
}

Upvotes: 1

Related Questions