SwiftUI @Published property not updating views

Viewed 2626

In the code below, the detail view UI does not update when the FavoriteButton is tapped. We know the binding is connected to the ObservableObject because the didSet is called and prints the updated state of the foos array. Oddly, I found that adding a VStack in the FooList's NavigationView resolves the bug in this instance, but did not for the more complicated UI where I encountered this issue.

Am I missing something about how this should be connected? Another workaround is to toggle a State bool in the FavoriteButton, but that seems like it goes against the point of Bindings.

import SwiftUI

struct Foo: Identifiable {
    var id = UUID()
    var name: String
    var isFavorite: Bool
}

class FooData: ObservableObject {
    @Published var foos = [Foo(name: "Test", isFavorite: true)] {
        didSet {
            print(foos)
        }
    }
}

struct FooList: View {
    @ObservedObject var fooData = FooData()
    
    var body: some View {
        NavigationView {
// Adding a VStack makes the FavoriteButton update correctly, but this doesn't work for more complicated UIs
//            VStack {
                List {
                    ForEach(fooData.foos, id: \.id) { foo in
                        let index = fooData.foos.firstIndex(where: { $0.id == foo.id })!
                        NavigationLink(foo.name, destination: FooDetailView(foo: $fooData.foos[index]))
                    }
                }
//            }
        }
    }
}

struct FooDetailView: View {
    @Binding var foo: Foo
    
    var body: some View {
        FavoriteButton(isFavorite: $foo.isFavorite)
    }
}

struct FavoriteButton: View {
    @Binding var isFavorite: Bool
    
    var body: some View {
        Button(action: {
            isFavorite.toggle()
        }, label: {
            if isFavorite {
                Image(systemName: "heart.fill")
            } else {
                Image(systemName: "heart")
            }
        })
    }
}

UPDATE:

Passing FooData as an EnvironmentObject to the List and declaring the EnvironmentObject in the FavoriteButton resolves the issue. This must be sufficient to tell SwiftUI that this View cares about any updates to the FooData, even if no code references the EnvironmentObject directly. This feels a little like magic, definitely not the most intuitive behavior for me. Hopefully, this can help someone else.

Full working code:

import SwiftUI

struct Foo: Identifiable {
    var id = UUID()
    var name: String
    var isFavorite: Bool
}

class FooData: ObservableObject {
    @Published var foos = [Foo(name: "Test", isFavorite: true)] {
        didSet {
            print(foos)
        }
    }
}

struct FooList: View {
    @ObservedObject var fooData = FooData()
    
    var body: some View {
        NavigationView {
            List {
                ForEach(fooData.foos, id: \.id) { foo in
                    let index = fooData.foos.firstIndex(where: { $0.id == foo.id })!
                    NavigationLink(foo.name, destination: FooDetailView(foo: $fooData.foos[index]))
                }
            }
        }
        .environmentObject(fooData)
    }
}

struct FooDetailView: View {
    @Binding var foo: Foo
    
    var body: some View {
        FavoriteButton(isFavorite: $foo.isFavorite)
    }
}

struct FavoriteButton: View {
    @EnvironmentObject var fooData: FooData
    @Binding var isFavorite: Bool
    
    var body: some View {
        Button(action: {
            isFavorite.toggle()
        }, label: {
            if isFavorite {
                Image(systemName: "heart.fill")
            } else {
                Image(systemName: "heart")
            }
        })
    }
}
2 Answers

When you have a viewModel Publishing an array the only thing that can trigger a view update is the array count. So you need to make the objects in the array a class, extend it to ObservableObject, and make isFavorite an @Published var it will work.

class Foo: Identifiable, ObservableObject {
  var id = UUID()
  var name: String
  @Published var isFavorite: Bool

  init(name: String, isFavorite: Bool) {
    self.name = name
    self.isFavorite = isFavorite
  }
}

Also, you should make your FooData an @StateObject as this view owns it.

struct ContentView: View {
  @StateObject var fooData = FooData()

  var body: some View {
    NavigationView {
      List {
        ForEach(fooData.foos) { foo in
          NavigationLink(foo.name, destination: FooDetailView(foo: foo))
        }
      }
    }
  }
}

Pass the foo down as an @ObservedObject

struct FooDetailView: View {
  @ObservedObject var foo: Foo

  var body: some View {
    Button(action: {
        foo.isFavorite.toggle()
    }, label: {
        if foo.isFavorite {
            Image(systemName: "heart.fill")
        } else {
            Image(systemName: "heart")
        }
    })
  }
}

You can solve this using an intermediate "wrapper" for the NavigationLink view that still has access to the ObservableObject:


struct FooList: View {
    @ObservedObject var fooData = FooData()
    
    var body: some View {
        NavigationView {
                List {
                    ForEach(fooData.foos, id: \.id) { foo in
                        NavigationLink(foo.name, destination: FooWrapper(id: foo.id, fooData: fooData))
                    }
                }
        }
    }
}

struct FooWrapper : View {
    var id : UUID
    @ObservedObject var fooData : FooData
    
    var fooBinding : Binding<Foo> {
        .init { () -> Foo in
            let index = fooData.foos.firstIndex(where: { $0.id == id })!
            return fooData.foos[index]
        } set: { (newValue) in
            let index = fooData.foos.firstIndex(where: { $0.id == id })!
            fooData.foos[index] = newValue
        }
    }
    
    var body: some View {
        FooDetailView(foo: fooBinding)
    }
}

I reused your code for finding the index of foo -- personally, I'd probably write a solution without the force unwrap in the event of a crash, but that's an issue not completely related to the question.

Related