Search code examples
swiftswiftuistatecombinepublisher

Can't bind a value of `AnyPublisher<Bool, Never>` to a `@State` variable in SwiftUI and Combine


For simplicity I have modified my project and created a reproducible code.

Consider I have the following structure for Item data:

struct Item: Identifiable {
    let id = UUID()
    let isChecked: AnyPublisher<Bool, Never>
    let name: String
}

And I hae the following ItemCell which should be updated when the isChecked value publishes any new value.

struct ItemCell: View {
    var item: Item
    @State var imgState: Bool = false
    
    var cancellables = Set<AnyCancellable>()
    
    init(_ item: Item) {
        self.item = item
        item.isChecked
            .print("State for item \(item.name): ") // --> Check this print
            .assign(to: \.imgState, on: self)
            .store(in: &cancellables)
    }
    
    var body: some View {
        HStack {
            HStack {
                Image(systemName: imgState ? "checkmark.circle.fill" : "checkmark.circle")
                    .foregroundStyle(.blue)
                Text(item.name)
            }
        }
    }
}

I have checked in the debug console that the isChecked always emiting values but the view is not updated.

Anyways, to test the code I have created this dummy code, you can just copy and paste to test:

Full code:

import SwiftUI
import Combine

struct Item: Identifiable {
    let id = UUID()
    let isChecked: AnyPublisher<Bool, Never>
    let name: String
}


struct ItemCell: View {
    var item: Item
    @State var imgState: Bool = false
    
    var cancellables = Set<AnyCancellable>()
    
    init(_ item: Item) {
        self.item = item
        item.isChecked
            .print("State for item \(item.name): ")
            .assign(to: \.imgState, on: self)
            .store(in: &cancellables)
    }
    
    var body: some View {
        HStack {
            HStack {
                Image(systemName: imgState ? "checkmark.circle.fill" : "checkmark.circle")
                    .foregroundStyle(.blue)
                Text(item.name)
            }
        }
    }
}

struct ContentView: View {
    var viewModel = ContentViewModel()
    
    var body: some View {
        List(viewModel.items) { item in
            ItemCell(item)
        }
    }
}

class ContentViewModel: ObservableObject {
    var itemNames = ["A", "B", "C", "D"]
    @Published var checkList = [false, false, false, false]
    
    init() {
        updateChecklist()
    }
    
    // Dummy function to update data
    func updateChecklist() {
        Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
            
            for index in 0...3 {
                self?.checkList[index] = Bool.random()
            }
        }
    }
    
    // Dummy items whose isChecked data are continuously updating with publisher
    var items: [Item] {
        (0...3).map { index in
            Item(
                isChecked: $checkList
                    .map { $0[index] == true }
                    .eraseToAnyPublisher(),
                name: itemNames[index]
            )
        }
    }
}

#Preview {
    ContentView()
}

I expect when the isChecked publisher is emiting values the SwiftUI view ItemCell should also be updated. But it is not updated.


Solution

  • In general, you should use onReceive to listen for values published from a publisher:

    struct ItemCell: View {
        var item: Item
        @State var imgState: Bool = false
        
        init(_ item: Item) {
            self.item = item
        }
        
        var body: some View {
            HStack {
                HStack {
                    Image(systemName: imgState ? "checkmark.circle.fill" : "checkmark.circle")
                        .foregroundStyle(.blue)
                    Text(item.name)
                }
            }
            .onReceive(item.isChecked.print("State for item \(item.name): ")) {
                imgState = $0
            }
        }
    }
    

    This works correctly for your toy example.

    Going away from your contrived example, and suppose the published values come from some external source, instead of a @Published property (which is very contrived). You should have a @Published property of type [Item] in your ContentViewModel. sink the publishers to assign to Items in this array.

    Then ItemView doesn't need any code to observe the changes. ContentView observes ContentViewModel.items, and automatically propagates the changes.

    struct Item: Identifiable {
        let name: String
        var isChecked: Bool
        
        // suppose Item is identified by its name
        var id: String { name }
    }
    
    
    struct ItemCell: View {
        let item: Item
        
        init(_ item: Item) {
            self.item = item
        }
        
        var body: some View {
            HStack {
                HStack {
                    Image(systemName: item.isChecked ? "checkmark.circle.fill" : "checkmark.circle")
                        .foregroundStyle(.blue)
                    Text(item.name)
                }
            }
        }
    }
    
    struct ContentView: View {
        @StateObject var viewModel = ContentViewModel()
        
        var body: some View {
            List(viewModel.items) { item in
                ItemCell(item)
            }
        }
    }
    
    class ContentViewModel: ObservableObject {
        @Published var items: [Item] = []
        
        private var cancellables: Set<AnyCancellable> = []
        
        init() {
            makeItem(name: "Foo")
            makeItem(name: "Bar")
            makeItem(name: "Baz")
        }
        
        func makeItem(name: String) {
            items.append(Item(name: name, isChecked: false))
            
            let someExternalPublisher = // get a publisher from somewhere...
            // for example let's just say this is a timer publisher
            Timer.publish(every: 2, on: .main, in: .default)
                .autoconnect()
                .map { _ in Bool.random() }
            
            someExternalPublisher
                .sink { bool in
                    if let index = self.items.firstIndex(where: { $0.name == name }) {
                        self.items[index].isChecked = bool
                    }
                }
                .store(in: &cancellables)
        }
    }
    

    On iOS 17+, you can make Item an @Observable class, and have each item manage its own publisher:

    @Observable
    class Item: Identifiable {
        let name: String
        var isChecked: Bool = false
        
        @ObservationIgnored
        var cancellables = Set<AnyCancellable>()
        
        init(name: String) {
            self.name = name
            let someExternalPublisher = // get a publisher from somewhere...
            // for example let's just say this is a timer publisher
            Timer.publish(every: 2, on: .main, in: .default)
                .autoconnect()
                .map { _ in Bool.random() }
            
            someExternalPublisher.sink {
                self.isChecked = $0
            }
            .store(in: &cancellables)
        }
    }
    

    This doesn't really work with ObservableObjects because SwiftUI can't observe changes on ObservableObjects that have been put into an array.