0
votes

Below is my code to draw a SwiftUI view that use a publisher to get its items that need drawing in a list. The items all have boolean values drawn with a Toggle.

My view is dumb so I can use any type of boolean value, perhaps UserDefaults backed, core data backed, or simply a boolean property somewhere... anyway, this doesn't redraw when updating a bool outside of the view when one of the booleans is updated. The onReceive is called and I can see the output change in my console, but binding isn't a part of my struct of ToggleItem and so SwiftUI doesn't redraw.

My code...

I have a struct that looks like this, note the binding type here...

struct ToggleItem: Identifiable, Equatable {
    let id: String
    let name: String
    let isOn: Binding<Bool>

    public static func == (lhs: ToggleItem, rhs: ToggleItem) -> Bool {
        lhs.id == rhs.id
    }
}

And in my SwiftUI I have this...

struct MyView: View {
    @State private var items: [ToggleItem] = []
    
    let itemsPublisher: AnyPublisher<[ToggleItem], Never>

    // ...

    var body: some View {
        List {

        // ...
        
        }
        .onReceive(itemsPublisher) { newItems in
            print("New items: \(newItems)")
            items.removeAll() // hacky redraw
            items = newItems
       }
}

I can see what's going on here, as Binding<Bool> isn't a value, so SwiftUI sees the array of newItems equal to the items it's already drawn, as a result, this doesn't redraw.

Is there something I'm missing, perhaps some ingenious bit of SwiftUI/Combine that redraws this for me?

2
It's not clear what exactly isn't being redrawn. Can you create a minimally reproducible example? Unrelated, but it wouldn't make sense to have a Binding<Bool> property in the data model - it should be just Bool - then you create a binding to it inside the view in order to update it. - New Dev
The issue is because of Equatable function of ToggleItem Type because you just used id for finding the deference! You have to involve all of them as well! then it will work. Or completely delete == function xCode would build an internal == function which would also work in that way, but you have to conform to Equatable at least! - swiftPunk
@swiftPunk This was a nice suggestion but doesn't seem to work when updating to lhs.id == rhs.id && lhs.isOn.wrappedValue == rhs.isOn.wrappedValue, is this what you meant? - Adam Carter
@NewDev I think the binding is the issue too, annoyingly I'm trying to keep my view abstract so it doesn't know about a specific type but want to avoid having two sources of truth by changing it to a simple Bool, my thinking being that the binding would wrap the one source of truth - is there a better way to do this? - Adam Carter

2 Answers

0
votes

how about doing something like this instead to keep one source of truth:

struct ToggleItem: Identifiable, Equatable {
    let id: String
    let name: String
    var isOn: Bool

    public static func == (lhs: ToggleItem, rhs: ToggleItem) -> Bool {
        lhs.id == rhs.id
    }
}

class ItemsPublisher: ObservableObject {
    @Published var items: [ToggleItem] = [ToggleItem(id: "1", name: "name1", isOn: false)]  // for testing
}

struct ContentView: View {
    @ObservedObject var itemsPublisher = ItemsPublisher()  // to be passed in from parent
    
    var body: some View {
        VStack {
            Button("Add item") {
                let randomString = UUID().uuidString
                let randomBool = Bool.random()
                itemsPublisher.items.append(ToggleItem(id: randomString, name: randomString, isOn: randomBool))
            }
            List ($itemsPublisher.items) { $item in
                Toggle(isOn: $item.isOn) {
                    Text(item.name)
                }
            }
            Spacer()
        }.padding(.top, 50)
        .onReceive(itemsPublisher.items.publisher) { newItem in
            print("----> new item: \(newItem)")
        }
    }
}
0
votes

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)
}