0

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?

Adam Carter
  • 4,741
  • 5
  • 42
  • 103
  • 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` 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 Jul 27 '21 at 22:44
  • 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! – ios coder Jul 27 '21 at 23:01
  • @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 Jul 28 '21 at 08:35
  • @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 Jul 28 '21 at 08:36

2 Answers2

0

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)")
        }
    }
}
  • This was my first attempt and it worked nicely! Idea now is that I want my view to be listening to a publisher which AFAIK is still one source of truth? My view listens to an equivalent of `ItemsPublisher` which has a publisher that fires when the array changes... my end goal is to not have this object in my view – Adam Carter Jul 28 '21 at 08:28
  • yes agreed, itemsPublisher is the source of truth, but then you also had the same thing in @State private var items: [ToggleItem] = [], hence my comment. If you don't want this itemsPublisher in your view, maybe you can use some sort of notification system. I obviously did not understand your question, sorry. – workingdog support Ukraine Jul 28 '21 at 09:10
  • No worries at all! Just making sure we're on the same page ✌ In terms of state, I believe this is correct as it will be private to the view so should be the right way of storing the publisher's input... having said that my initial hope was to not store the publisher's values in to a state array and use the publisher directly... it doesn't seem like I'm able to do that though (from the little I know), not sure if you know a way? – Adam Carter Jul 28 '21 at 12:23
0

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)
}
Adam Carter
  • 4,741
  • 5
  • 42
  • 103