I created a Publisher for UIControl based on the following code:
Source: Medium article
However, when I tried migrating to Swift 6 by enabling strict concurrency checking, I encountered the following context-related warning:
Call to main actor-isolated instance method 'addTarget(_:action:for:)' in a synchronous nonisolated context
To fix this, I made several changes to use MainActor in some parts of the code (marked as ✅ changed). Here's the updated code:
protocol UIControlPublishable: UIControl {}
extension UIControlPublishable {
func publisher(for event: UIControl.Event) -> UIControl.InteractionPublisher<Self> {
return InteractionPublisher(control: self, event: event)
}
}
extension UIControl: UIControlPublishable {
@MainActor // ✅ Changed
class InteractionSubscription<S: Subscriber, C: UIControl>: Subscription where S.Input == C {
private let subscriber: S?
private weak var control: C?
private let event: UIControl.Event
init(subscriber: S,
control: C?,
event: UIControl.Event) {
self.subscriber = subscriber
self.control = control
self.event = event
self.control?.addTarget(self, action: #selector(handleEvent), for: event)
}
@objc func handleEvent(_ sender: UIControl) {
guard let control = self.control else {
return
}
_ = self.subscriber?.receive(control)
}
nonisolated func request(_ demand: Subscribers.Demand) {}
nonisolated func cancel() {
Task { @MainActor in // ✅ Changed
self.control?.removeTarget(self, action: #selector(handleEvent), for: self.event)
self.control = nil
}
}
}
enum InteractionPublisherError: Error {
case objectFoundNil
}
struct InteractionPublisher<C: UIControl>: Publisher {
typealias Output = C
typealias Failure = InteractionPublisherError
private weak var control: C?
private let event: UIControl.Event
init(control: C, event: UIControl.Event) {
self.control = control
self.event = event
}
func receive<S>(subscriber: S) where S : Subscriber, InteractionPublisherError == S.Failure, C == S.Input {
Task { @MainActor in // ✅ Changed
// ❌ Task or actor isolated value cannot be sent; this is an error in the Swift 6 language mode
guard let control = control else {
subscriber.receive(completion: .failure(.objectFoundNil))
return
}
let subscription = InteractionSubscription(
subscriber: subscriber,
control: control,
event: event
)
subscriber.receive(subscription: subscription)
}
}
}
}
After this update, when trying to access subscriber inside the receive function in the MainActor context, I encountered the following warning:
func receive<S>(subscriber: S) where S : Subscriber, InteractionPublisherError == S.Failure, C == S.Input {
Task { @MainActor in
// ❌ Task or actor isolated value cannot be sent; this is an error in the Swift 6 language mode
guard let control = control else {
subscriber.receive(completion: .failure(.objectFoundNil))
return
}
let subscription = InteractionSubscription(
subscriber: subscriber,
control: control,
event: event
)
subscriber.receive(subscription: subscription)
}
}
Even simplifying the code inside the Task to only use the subscriber results in the same issue:
func receive<S>(subscriber: S) where S : Subscriber, InteractionPublisherError == S.Failure, C == S.Input {
Task { @MainActor in
let temp = subscriber
}
}
I tried capturing subscriber with Task { @MainActor [subscriber] in }
, but the issue persists.
How can I migrate this code to Swift 6 without encountering this issue?
Combine itself hasn't really been migrated to Swift 6 yet, and I can't imagine that it ever will be, due to how it handles concurrency. Combine subscribers can request and receive values on any queue they want using subscribe(on:)
and receive(on:)
, so the closure that things like sink
accepts should be @Sendable
, but isn't. Expectedly, you would get a crash if you receive(on: DispatchQueue.global())
and then sink
on the main actor.
The error is basically saying that you should not be sending the subscriber
to the main actor - it should stay where it is, because the subscriber can decide which queue it is subscribing from with subscribe(on:)
.
Since this publisher only makes sense to be used on the main actor, you might as well just use @preconcurrency
conformances:
extension UIControl: UIControlPublishable {
@MainActor
class InteractionSubscription<S: Subscriber, C: UIControl>: @preconcurrency Subscription, CustomCombineIdentifierConvertible where S.Input == C {
private let subscriber: S?
private weak var control: C?
private let event: UIControl.Event
init(subscriber: S,
control: C?,
event: UIControl.Event) {
self.subscriber = subscriber
self.control = control
self.event = event
self.control?.addTarget(self, action: #selector(handleEvent), for: event)
}
@objc func handleEvent(_ sender: UIControl) {
guard let control = self.control else {
return
}
_ = self.subscriber?.receive(control)
}
func request(_ demand: Subscribers.Demand) {}
func cancel() {
self.control?.removeTarget(self, action: #selector(handleEvent), for: self.event)
self.control = nil
}
}
enum InteractionPublisherError: Error {
case objectFoundNil
}
@MainActor
struct InteractionPublisher<C: UIControl>: @preconcurrency Publisher {
typealias Output = C
typealias Failure = InteractionPublisherError
private weak var control: C?
private let event: UIControl.Event
init(control: C, event: UIControl.Event) {
self.control = control
self.event = event
}
func receive<S>(subscriber: S) where S : Subscriber, InteractionPublisherError == S.Failure, C == S.Input {
guard let control = control else {
subscriber.receive(completion: .failure(.objectFoundNil))
return
}
let subscription = InteractionSubscription(
subscriber: subscriber,
control: control,
event: event
)
subscriber.receive(subscription: subscription)
}
}
}