Publisher.assign(toπŸ”›), ReferenceWritableKeyPath, and optional chaining

Say I have a publisher AnyPublisher<String, Never>, and I wish to assign it to a label's text: String? property. I'm able to do this like so:

class Foo {
    
    var label: UILabel = UILabel()
    private var cancellables: Set<AnyCancellable> = []
    
    init(stringPublisher: AnyPublisher<String, Never>) {
        stringPublisher
            .map { $0 as String? }
            .assign(to: \Foo.label.text, on: self)
            .store(in: &cancellables)
    }
    
}

Is there a way I can do the same when my label is optional? e.g. because it gets set at a later time? I would like to do something like this:

stringPublisher
    .map { $0 as String? }
    .assign(to: \Foo.label?.text, on: self)
    .store(in: &cancellables)

But I get the error Cannot convert value of type 'KeyPath<Foo, String?>' to expected argument type 'ReferenceWritableKeyPath<Foo, String?>'.

It seems the optional chaining stops the key-path from being writeable. Is there a way to accomplish this, or should I just fall back to using .sink(receiveValue:)?

I think the solution here might depend on when the Foo.label property is set up. If it's only optional because of two-phase initialisation, I'd recommend using a force-unwrap:

stringPublisher.assign(to: \.text, on: label!)

If you can't initialise label early enough, or if Foo.label can refer to different instances, I think you need to use sink(receiveValue:). Not only to workaround the keypath writability issue, but also to fix a memory leak: assign(to:on:) strongly captures its argument, so your code as written creates a strong reference cycle with the self.cancellables collection. I'd go with this code:

stringPublisher
  .sink { [weak self] in self?.label?.text = $0 }
  .store(in: &cancellables)

(Also, hi @commscheck!)

I just added an overload for assign that takes a path to an optional value.

    /// Assigns a `Publisher`'s `Output` to a property of an object.
    ///
    /// - Note: This is an overload of the existing `assign(to:on:)` which adds an `AnyObject` constraint on `Root` in
    /// order to capture the value weakly. Without it, using this method would leak memory. Additionally, it writes to
    /// an `Optional` value, preventing type issues between `Optional` and non-`Optional` values.
    ///
    /// - Parameters:
    ///   - path:  `ReferenceWritableKeyPath` indicating the property to assign.
    ///   - root:  Instance that contains the property.
    /// - Returns: `AnyCancellable` instance.
    func assign<Root: AnyObject>(to path: ReferenceWritableKeyPath<Root, Output?>, on root: Root) -> AnyCancellable {
        sink { [weak root] in root?[keyPath: path] = $0 }
    }

As a bonus, it avoids the reference cycle as well.

1 Like