Peter Schorn
Peter Schorn

Reputation: 987

SwiftUI - @State property not updating

I'm experiencing this really weird issue/bug with SwiftUI. In the setupSubscription method, I'm creating a subscription to subject and inserting it into the cancellables Set. And yet, when I print the count of cancellables, I get zero. How can the set be empty if I just inserted an element into it? This is presumably why the handleValue method is not called when I tap on the button. Here's the full output from the console:

init
begin setupSubscription
setupSubscription subject sink: receive subscription: (CurrentValueSubject)
setupSubscription subject sink: request unlimited
setupSubscription subject sink: receive value: (initial value)
handleValue: 'initial value'
setupSubscription: cancellables.count: 0
setupSubscription subject sink: receive cancel
sent value: 'value 38'
cancellables.count: 0
sent value: 'value 73'
cancellables.count: 0
sent value: 'value 30'
cancellables.count: 0

What am I doing wrong? why Is my subscription to subject getting cancelled? Why is handleValue not getting called when I tap the button?

import SwiftUI
import Combine

struct Test: View {
    
    @State private var cancellables: Set<AnyCancellable> = []
    
    let subject = CurrentValueSubject<String, Never>("initial value")
    
    init() {
        print("init")
        self.setupSubscription()
    }
    
    var body: some View {
        VStack {
            Button(action: {
                let newValue = "value \(Int.random(in: 0...100))"
                self.subject.send(newValue)
                print("sent value: '\(newValue)'")
                print("cancellables.count:", cancellables.count)
            }, label: {
                Text("Tap Me")
            })
        }
    }
    
    func setupSubscription() {
        print("begin setupSubscription")
        
        let cancellable = self.subject
            .print("setupSubscription subject sink")
            .sink(receiveValue: handleValue(_:))
        
        self.cancellables.insert(cancellable)
        
        print("setupSubscription: cancellables.count:", cancellables.count) 
        // prints "setupSubscription: cancellables.count: 0"
    
    }
    
    
    func handleValue(_ value: String) {
        print("handleValue: '\(value)'")
    }
    
    
}

Upvotes: 5

Views: 4640

Answers (2)

Xaxxus
Xaxxus

Reputation: 1809

a few things you are doing wrong here.

Never try to store things in swiftUI structs. They get invalidated and reloaded every time your view changes. This is likely why your subscription is getting canceled.

For something like this, you should use an ObservableObject or StateObject with published properties. When ObservableObjects or StateObjects change. The views that contain them reload just like with @State or @Binding:

// ObservedObjects have an implied objectWillChange publisher that causes swiftUI views to reload any time a published property changes. In essence they act like State or Binding variables.
class ViewModel: ObservableObject {
    // Published properties ARE combine publishers
    @Published var subject: String = "initial value"
}

then in your view:

@ObservedObject var viewModel: ViewModel = ViewModel()

If you do need to use a publisher. Or if you need to do something when an observable object property changes. You don't need to use .sink. That is mostly used for UIKit apps using combine. SwiftUI has an .onReceive viewmodifier that does the same thing.

Here are my above suggestions put into practice:

struct Test: View {

    class ViewModel: ObservedObject {
        @Published var subject: String = "initial value"
    }

    @ObservedObject var viewModel: Self.ViewModel

    var body: some View {
        VStack {
            Text("\(viewModel.subject)")

            Button {
                viewModel.subject = "value \(Int.random(in: 0...100))"
            } label: {
                Text("Tap Me")
            }
        }
        .onReceive(viewModel.$subject) { [self] newValue in
            handleValue(newValue)
        }
    }

    func handleValue(_ value: String) {
        print("handleValue: '\(value)'")
    }
}

Upvotes: 7

Asperi
Asperi

Reputation: 257493

You just incorrectly use state - it is view related and it becomes available (prepared back-store) only when view is rendered (ie. in body context). In init there is not yet state back-storage, so your cancellable just gone.

Here is possible working approach (however I'd recommend to move everything subject related into separated view model)

Tested with Xcode 12 / iOS 14

struct Test: View {

    private var cancellable: AnyCancellable?
    private let subject = CurrentValueSubject<String, Never>("initial value")

    init() {
        cancellable = self.subject
            .print("setupSubscription subject sink")
            .sink(receiveValue: handleValue(_:))
    }

    var body: some View {
        VStack {
            Button(action: {
                let newValue = "value \(Int.random(in: 0...100))"
                self.subject.send(newValue)
                print("sent value: '\(newValue)'")
            }, label: {
                Text("Tap Me")
            })
        }
    }

    func handleValue(_ value: String) {
        print("handleValue: '\(value)'")
    }
}

Upvotes: 3

Related Questions