Reputation: 41
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.
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)
}
}
}
}
}
}
struct DetailView: View {
@Binding var detailObject : DetailObject
var body: some View {
Text("Object Title: \(detailObject.title ?? "none")")
}
}
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
}
}
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
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.
Alter the DetailView to accept and display the Selection in a hidden element:
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()
}
}
}
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.
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.
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 ).
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
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
Reputation: 185
Passing it as @EnvironmentObject
instead of @Binding
will fix the problem.
struct DetailView: View {
@EnvironmentObject var detailObject: DetailObject // ✅
var body: some View {
Text("Object Title: \(detailObject.title ?? "none")")
}
}
...
...
} 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