It seems as though removing the bindings is the right way forward!
Looking at the docs this makes sense now https://developer.apple.com/documentation/swiftui/binding
Use a binding to create a two-way connection between a property that stores data, and a view that displays and changes the data.
Although not explicit, this does suggest that the binding "property wrapper" is only to be used as a property in a SwiftUI view rather than a data model.
My changes
I added a closure to my view
let itemDidToggle: (ToggleItem, Bool) -> Void
and this is called in the Toggle binding's set()
function which updates the value outside of the view, keeping the view dumb. This triggers the publisher to get called and update my stack. This coupled with updating the ==
equatable function to include the isOn
property makes everything work...
My code
import UIKit
import PlaygroundSupport
import Combine
import SwiftUI
public struct MyItem {
public let identifier: String
public let description: String
}
public class ItemsManager {
public private(set) var items: [MyItem]
public let itemsPublisher: CurrentValueSubject<[MyItem], Never>
private let userDefaults: UserDefaults
public init(items: [MyItem], userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
self.items = items
self.itemsPublisher = .init(items)
}
public func isItemEnabled(identifier: String) -> Bool {
guard let item = item(for: identifier) else {
return false
}
if let isOnValue = userDefaults.object(forKey: item.identifier) as? NSNumber {
return isOnValue.boolValue
} else {
return false
}
}
public func setEnabled(_ isEnabled: Bool, forIdentifier identifier: String) {
userDefaults.set(isEnabled, forKey: identifier)
itemsPublisher.send(items)
}
func item(for identifier: String) -> MyItem? {
return items.first { $0.identifier == identifier }
}
}
struct MyView: View {
@State private var items: [ToggleItem] = []
let itemsPublisher: AnyPublisher<[ToggleItem], Never>
let itemDidToggle: (ToggleItem, Bool) -> Void
public init(
itemsPublisher: AnyPublisher<[ToggleItem], Never>,
itemDidToggle: @escaping (ToggleItem, Bool) -> Void
) {
self.itemsPublisher = itemsPublisher
self.itemDidToggle = itemDidToggle
}
var body: some View {
List {
Section(header: Text("Items")) {
ForEach(items) { item in
Toggle(
item.name,
isOn: .init(
get: { item.isOn },
set: { itemDidToggle(item, $0) }
)
)
}
}
}
.animation(.default, value: items)
.onReceive(itemsPublisher) { newItems in
print("New items: \(newItems)")
items = newItems
}
}
struct ToggleItem: Swift.Identifiable, Equatable {
let id: String
let name: String
let isOn: Bool
public static func == (lhs: ToggleItem, rhs: ToggleItem) -> Bool {
lhs.id == rhs.id && lhs.isOn == rhs.isOn
}
}
}
let itemsManager = ItemsManager(items: (1...10).map { .init(identifier: UUID().uuidString, description: "item \($0)") })
let publisher = itemsManager.itemsPublisher
.map { myItems in
myItems.map { myItem in
MyView.ToggleItem(id: myItem.identifier, name: myItem.description, isOn: itemsManager.isItemEnabled(identifier: myItem.identifier))
}
}
.eraseToAnyPublisher()
let view = MyView(itemsPublisher: publisher) { item, newValue in
itemsManager.setEnabled(newValue, forIdentifier: item.id)
}