SwiftUI - YAPPNU (Yet Another @Published Property Not Updating)

Viewed 279

In the code below, part of a crossword app, I have created a Grid, which contains squares, an @Published array of Squares, and a GridView to display an instance of Grid, griddy. When I change griddy's squares in the GridView, the GridView gets recreated, as expected, and I see an "!" instead of an "A".

Now I've added one more level -- a Puzzle contains an @Published Grid, griddy. In PuzzleView, I therefore work with puzzle.griddy. This doesn't work, though. The letter doesn't change, and a break point in PuzzleView's body never gets hit.

Why? I'm working on an app where I really need these 3 distinct structures.

import SwiftUI


class Square : ObservableObject {
    @Published var letter: String
    
    init(letter: String){
        self.letter = letter
    }
}

class Grid : ObservableObject {
    @Published var squares:[[Square]] = [
        [Square(letter: "A"), Square(letter: "B"), Square(letter: "C")],
        [Square(letter: "D"), Square(letter: "E"), Square(letter: "F"), ]
    ]
    
}

class Puzzle: ObservableObject {
    @Published var griddy: Grid = Grid()
}

struct GridView: View {
    @EnvironmentObject var griddy: Grid
    
    var body: some View {
        VStack {
            Text("\(griddy.squares[0][0].letter)")
            Button("Change Numbers"){
                griddy.squares[0][0] = Square(letter:"!")
            }
        }
    }
}


struct PuzzleView: View {
    @EnvironmentObject var puzzle: Puzzle
    
    var body: some View {
        VStack {
            Text("\(puzzle.griddy.squares[0][0].letter)")
            Button("Change Numbers"){
                puzzle.griddy.squares[0][0] = Square(letter:"!")
            }
        }
    }
}



struct ContentView_Previews: PreviewProvider {
    
    static var previews: some View {
        // PuzzleView().environmentObject(Puzzle())
        GridView().environmentObject(Grid())
    }
}
3 Answers

Technically you could just add this to your Puzzle object:

var anyCancellable: AnyCancellable? = nil

init() {
    self.anyCancellable = griddy.objectWillChange.sink(receiveValue: {
        self.objectWillChange.send()
    })
}

It's a trick I found in another SO post, it'll cause changes in the child object to trigger the parent's objectWillChange.

Edit:

I would like to note that if you change the child object:

puzzle.griddy = someOtherGriddyObject

then you will still be subscribed to the changes of the old griddy object, and you won't receive new updates.

You can probably get by this by just updating the cancellable after changing the object:

puzzle.griddy = someOtherGriddyObject
puzzle.anyCancellable = puzzle.griddy.objectWillChange.sink(receiveValue: {
        puzzle.objectWillChange.send()
    })

Think in this direction: in MVVM SwiftUI concept every introduced ObservableObject entity must be paired with corresponding view ObservedObject (EnvironmentObject is the same as ObservedObject, just with different injection mechanism), so specific change in ViewModel would refresh corresponding View, there is no other magic/automatic propagations of changes from model to view.

So if Square is ObservableObject, there should be some SquareView, as

struct SquareView {
   @ObservedObject var vm: Square
   ...

so if you Puzzle observable includes Grid observable, then corresponding PuzzleView having observed puzzle should include GridView having observed grid, which should include SqareView having observed square.

Ie.

struct PuzzleView: View {
    @EnvironmentObject var puzzle: Puzzle
    
    var body: some View {
       GridView().environmentObject(puzzle.griddy)
    }
}

// ... and same for GridView, and so on...

This approach result in very optimal update, because modifying one Square makes only one corresponding SquareView refreshed.

But if you accumulate all Squares.objectWillChange in one container update, then modifying one Square result in total UI update, that is bad and for UI fluency and for battery.

I'd say because Grid is reference type, and @Published is triggered when griddy is mutated.

class Puzzle: ObservableObject {
    @Published var griddy: Grid = Grid()
}

Note that griddy does not actually mutate since it is a reference.

But why does it work in @EnvironmentObject var griddy: Grid?

My guess is that SDK implicitly observes publishers in an @ObservableObject.

But maybe someone can provide better detail answer.

I personally would avoid long publisher chain, and just flatten the nested structure.

Related