SwiftUi view responding to model changes

I developed a model and a view that responded to published changes in one of the model's data. As a button is clicked, the data changes and the button image also changed in response to that data change. This worked great in early testing since the model was wrapped by @ObservedObject but the ultimate goal was that the model object would be part of a containing object, and the view would be part of a view hierarchy so I changed @ObservedObject to @Binding and put the parent data object in a parent view using @State to make it the source of truth. That's when the button stopped changing its label in response to the data change, even though I knew the data was changing via debugging print statements.

To investigate, I shorted up the hierarchy to just the initial content view and am using a single instance of my data and a simple button designed to highlight the (in)activity of the button label changes. Somehow, the model is no longer being observed correctly. Here's what I believe is the pertinent code...

import Foundation

enum CheckMark: Codable { case undone case unsure case done case skipped }

// Created by Brian on 1/3/24. //

import Foundation

class CheckItem: Identifiable, ObservableObject, Codable { let id: UUID var title: String @Published var checkmark: CheckMark var instructions: String var instructionLinks: [String:String]
var skippable: Bool = false

// Initialize with values
init(id: UUID, title: String, checkmark: CheckMark, instructions: String, instructionLinks: [String:String], skippable: Bool) {
    self.id = id
    self.title = title
    self.checkmark = checkmark
    self.instructions = instructions
    self.instructionLinks = instructionLinks
    self.skippable = skippable
}

// Initialize a fresh minimal item
convenience init(id: UUID = UUID(), title: String) {
    self.init(id: id, title: title, checkmark: .undone, instructions: "", instructionLinks: [:], skippable: false)
}


// to help determine if completed for Completable protocol
func isCompleted() -> Bool {
    return isDone() || isSkipped()
}


public func noInstructions() -> Bool {
    return instructions.isEmpty
}

public func isDone() -> Bool {
    return (checkmark == .done)
}

public func isUndone() -> Bool {
    return (checkmark == .undone)
}

public func isSkipped() -> Bool {
    return (checkmark == .skipped)
}

public func isUnsure() -> Bool {
    return (checkmark == .unsure)
}

public func isSkippable() -> Bool {
    return skippable
}



public func setDone() {
    checkmark = .done
    print("setting to ", checkmark)
}

public func setUndone() {
    checkmark = .undone
    print("setting to ", checkmark)
}

public func setSkipped() {
    checkmark = .skipped
    print("setting to ", checkmark)
}

public func setUnsure() {
    checkmark = .unsure
    print("setting to ", checkmark)

}

// CodingKeys enum for Codable
enum CodingKeys: CodingKey {
    case id, title, checkmark, instructions, instructionLinks, skippable, locked
}

// Encode the CheckItem to JSON
func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(id, forKey: .id)
    try container.encode(title, forKey: .title)
    try container.encode(checkmark, forKey: .checkmark)
    try container.encode(instructions, forKey: .instructions)
    try container.encode(instructionLinks, forKey: .instructionLinks)
    try container.encode(skippable, forKey: .skippable)
}

// Initialize from JSON
required init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    id = try container.decode(UUID.self, forKey: .id)
    title = try container.decode(String.self, forKey: .title)
    checkmark = try container.decode(CheckMark.self, forKey: .checkmark)
    instructions = try container.decode(String.self, forKey: .instructions)
    instructionLinks = try container.decode(Dictionary.self, forKey: .instructionLinks)
    skippable = try container.decode(Bool.self, forKey: .skippable)
}

}

import SwiftUI

struct ContentView: View { @State var checkItem = PreviewData.makeCheckItem(title: "Test Item")

var body: some View {
    VStack {
        Button {
            switch checkItem.checkmark {
            case .undone:
                checkItem.setDone()
                print("undone toggled to ", checkItem.checkmark)
            case .done:
                checkItem.setUndone()
                print("done toggled to ", checkItem.checkmark)
            default:
                checkItem.setDone()
                print("unsure toggled to ", checkItem.checkmark)
            }
        } label: {
            switch checkItem.checkmark {
            case .undone:
                Image(systemName: "square")
            case .done:
                Image(systemName: "checkmark.square")
            case .unsure:
                Image(systemName: "square")
                    .background(Color(UIColor.yellow))
                    .foregroundColor(Color(.red))
            default:
                Image(systemName: "square").opacity(0.0)
            }
        }
        .onReceive(checkItem.$checkmark) { newCheckmark in
            print("Checkmark is \(newCheckmark)")
        }
        
        Text(checkItem.title)
    }
    .padding()
}

}

The switch statement in the "label" is not executing, but the "onReceive" is working (for debugging)

PreviewData.makeCheckItem looks like this...

static func makeCheckItem(title: String,
                          checkmark: CheckMark? = .undone,
                          instructions: String? = "",
                          instructionLinks: [String:String]? = [:],
                          skippable: Bool? = false) -> CheckItem {
    let checkItem = CheckItem(title: title)
    checkItem.checkmark = checkmark ?? .undone
    checkItem.instructions = instructions ?? ""
    checkItem.instructionLinks = instructionLinks ?? [:]
    checkItem.skippable = skippable ?? false
    return checkItem
}

How can I make this simple button labeling respond to data changes as expected? (And is working when it is @ObservedObject, not @State?). If I understand this, I think I can expand upon the technique to get my full hierarchy working.

The reason the view doesn't respond to data changes when you switch to @State is that CheckItem is a class. The @State property wrapper works with structs, not classes.

There are two ways to fix this. The first way is to make CheckItem a struct and remove @Published from any of the properties in CheckItem where you added them. By making CheckItem a struct, you can use @State and @Binding.

The second way is to use @StateObject in the containing view and @ObservedObject in any other views where you want to use the CheckItem object. @StateObject is the class equivalent of @State, and @ObservedObject is the class equivalent of @Binding.

That helped quite a lot, thank you! Do I still need to use the $ when referring to CheckItem? Not sure if that was purely a struct thing or if it applies to classes as well.

I'm 99% sure you don't need the $ character with @StateObject and @ObservedObject. If you do need it, Xcode will show an error when you build the project.

This has uncovered another issue that I think is related somehow. The CheckItemCardView works fine on it's own but when it is in a list of card views, pressing one of the card buttons acts like all of the card's buttons are activated, making for strange behavior. below are the two views I'm talking about.

import SwiftUI

struct CheckListView: View { @ObservedObject var checkList: CheckList

var body: some View {
    List {
        ForEach(checkList.items.indices, id: \.self) { index in
            CheckItemCardView(checkItem: checkList.items[index], index: index)
        }
    }
}

}

struct CheckListView_Previews: PreviewProvider { static var checkList = PreviewData.makeCheckList() static var previews: some View { CheckListView(checkList: checkList ) } }

import SwiftUI

struct CheckItemCardView: View { @ObservedObject var checkItem: CheckItem let index: Int var completed: Bool = false @State var isPresentingInstructionView = false

var body: some View {
    HStack {
        Button {
            switch checkItem.checkmark {
            case .undone:
                checkItem.setDone()
                print("undone toggled to ", checkItem.checkmark)
            case .done:
                checkItem.setUndone()
                print("done toggled to ", checkItem.checkmark)
            default:
                checkItem.setDone()
                print("unsure toggled to ", checkItem.checkmark)
            }
        } label: {
            switch checkItem.checkmark {
            case .undone:
                Image(systemName: "square")
            case .done:
                Image(systemName: "checkmark.square")
            case .unsure:
                Image(systemName: "square")
                    .background(Color(UIColor.yellow))
                    .foregroundColor(Color(.red))
            default:
                Image(systemName: "square").opacity(0.0)
            }
        }
        .onReceive(checkItem.$checkmark) { newCheckmark in
            print("Checkmark is \(checkItem.checkmark) and new one \(newCheckmark) in card \(index)")
        }

        Text(checkItem.title)
            .strikethrough(checkItem.isSkipped())
        Spacer()
        
        HStack (alignment: .bottom) {
            Button {
                isPresentingInstructionView = true
                print("isPresentingInstructionView set to true")
            } label: {
                Label("how-to", systemImage: "questionmark.bubble")
            }
            .labelStyle(VerticalLabelStyle())
            .hidden(checkItem.noInstructions())
            
            Button {
                if (!checkItem.isSkipped()) {
                    checkItem.setSkipped()
                    print("skipping, checkmark set to ", checkItem.checkmark)
                } else if (checkItem.isSkipped()) {
                    checkItem.setUndone()
                    print("unskipping, checkmark set to ", checkItem.checkmark)
                }
            } label: {
                if (!checkItem.isSkipped()) {
                    Label("skip", systemImage: "arrowshape.bounce.right")
                } else if (checkItem.isSkipped()) {
                    Label("unskip", systemImage: "arrowtriangle.backward")
                }
            }
            .labelStyle(VerticalLabelStyle())
            .hidden(!checkItem.skippable)
        }
        .foregroundColor(Color(.systemBlue))
        
    }
    .font(.title)
    .sheet(isPresented: $isPresentingInstructionView) {
        VStack {
            ScrollView {
                Text(checkItem.instructions)
                    .padding()
                Section(header: Text("External Links")
                    .font(.title3) ) {
                    ForEach(checkItem.instructionLinks.sorted(by: >), id: \.key) { key, value in
                        Link(destination: URL(string: key)!, label: {
                            Text(value)
                        })
                    }
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)

            Button("Done", systemImage: "circle.inset.filled") {
                isPresentingInstructionView = false
                print("I read your dumb instructions")
            }
            .font(.title2)
        }
    }
}

}

// https://medium.com/macoclock/make-more-with-swiftuis-label-94ef56924a9d struct VerticalLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { VStack(alignment: .center, spacing: 2) { configuration.icon configuration.title.font(.callout) } } }

extension View { func hidden(_ shouldHide: Bool) -> some View { opacity(shouldHide ? 0 : 1) } }

struct CheckItemCardView_Previews: PreviewProvider { static var checkItem = PreviewData.makeLoneItem() static var previews: some View { CheckItemCardView(checkItem: checkItem, index: 1 ) } }

so when I click on the first button, it also opens up the sheet and sets the checkmark value to "skipped", not at all what I want. And, again, this doesn't happen if I run just one CheckItemCardView all by itself. Can you explain why the list would make one button press activate all buttons in the card? Thanks in advance.

Hey, I found that if I change "List" in the CheckListView to "VStack" it all behaves as expected. Not sure why, something to research, but at least I can ask a more intelligent question about this now.

SwiftUi view responding to model changes
 
 
Q