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.