Bridging Legacy APIs with MainActor.assumeIsolated

Bridging Legacy APIs with MainActor.assumeIsolated

Despite strict concurrency checking having been available for some time, many official Apple APIs remain inadequately adapted—a situation likely to persist for the foreseeable future. As Swift 6 gains mainstream adoption, this friction has become increasingly apparent: developers want the safety guarantees of the Swift compiler, yet they find themselves struggling to satisfy its rigorous requirements. In this article, I will use an NSTextAttachmentViewProvider implementation as a case study to demonstrate how MainActor.assumeIsolated can be a powerful tool in these specific scenarios.

One typical example

A classic problem where a legacy API fails to meet Swift 6's strict compilation requirements. The goal was to implement custom view insertion within a UITextView using NSTextAttachment and NSTextAttachmentViewProvider. To achieve this, he attempted to load a SwiftUI view inside the loadView method of NSTextAttachmentViewProvider:

class CustomAttachmentViewProvider: NSTextAttachmentViewProvider {
    override func loadView() {
        let hosting = UIHostingController(rootView: InlineSwiftUIButton {
            print("SwiftUI Button tapped!")
        })

        hosting.view.backgroundColor = .clear

        // Assign to the provider's view
        self.view = hosting.view
    }
}

// MARK: - SwiftUI Button View

struct InlineSwiftUIButton: View {
    var action: () -> Void
    var body: some View {
        Button("Click Me") {
            action()
        }
        .padding(6)
        .background(Color.blue.opacity(0.2))
        .cornerRadius(8)
    }
}

After enabling Swift 6 mode in Xcode (with "Default Actor Isolation" set to nonisolated), the code above triggered the following error/warning:

To deal with this issue, we tried a couple of ways:

A. Adding @MainActor to the loadView method

B. Adding @MainActor to the CustomAttachmentViewProvider class

C. Wrapping the code inside loadView with a Task

However, none of them can silient the error.

Analyzing the Problem

The Swift 6 compiler rejects the previous approaches primarily for the following reasons:

  • UIHostingController is annotated with @MainActor in its declaration, meaning it must be instantiated within a MainActor context.
  • The original declaration of NSTextAttachmentViewProvider lacks an explicit isolation domain.
  • Adding @MainActor to loadView alone creates a mismatch with the superclass/protocol requirements.
  • Creating an asynchronous MainActor context within loadView prevents the safe passing of self.

We seem to be caught in a dilemma: we must be on the MainActor to construct the UIHostingController, yet we seemingly cannot be on the MainActor to assign the resulting view (UIView) to self.view.

Is there a way to have our cake and eat it too?

MainActor.assumeIsolated: Providing a MainActor Context Within Synchronous Methods

In Swift’s concurrency toolkit, MainActor.assumeIsolated stands out as a unique and somewhat peculiar tool. Unlike MainActor.run, it operates strictly within synchronous contexts; more importantly, it will trigger an immediate crash if the current execution context is not actually the MainActor.

When I first encountered this method, its purpose eluded me. For a long time, I viewed it much like MainActor.assertIsolated—simply a debugging tool to verify whether code was running on the MainActor. It wasn't until I faced the dilemma mentioned earlier that I truly grasped the design intent behind this API.

Examining the signature of MainActor.assumeIsolated, we see that it provides a MainActor context to its trailing closure. This means that within a non-isolated synchronous context, we can "synchronously" execute code that requires a MainActor context and return a Sendable result—all without the need to bridge into an asynchronous environment.

public static func assumeIsolated<T>(_ operation: @MainActor () throws -> T, file: StaticString = #fileID, line: UInt = #line) rethrows -> T where T : Sendable

Here comes the solution

With a clear understanding of MainActor.assumeIsolated, we can now refactor the loadView method as follows:

class CustomAttachmentViewProvider: NSTextAttachmentViewProvider {
    override func loadView() {
        let view = MainActor.assumeIsolated {
            let hosting = UIHostingController(rootView: InlineSwiftUIButton {
                print("SwiftUI Button tapped!")
            })

            hosting.view.backgroundColor = .clear
            return hosting.view
        }
        self.view = view
    }
}

Breaking down the code above:

  • We successfully invoked the MainActor.assumeIsolated method within loadView.
  • The closure provided by MainActor.assumeIsolated grants a MainActor context, allowing us to safely instantiate the UIHostingController.
  • hosting.view (of type UIView) is already annotated with @MainActor in its declaration and satisfies Sendable requirements, making it a valid return value for the closure.
  • Within the synchronous context of loadView, we assigned the return value of MainActor.assumeIsolated back to self.view, ensuring isolation consistency.

Considering that loadView might not always be executed on the MainActor (depending on the environment), the final, production-ready code is as follows:

class CustomAttachmentViewProvider: NSTextAttachmentViewProvider {
    override func loadView() {
        view = getView()
    }


    func getView() -> UIView {
        if Thread.isMainThread {
            return Self.createHostingViewOnMain()
        } else {
            return DispatchQueue.main.asyncAndWait {
                Self.createHostingViewOnMain()
            }
        }
    }

    private static func createHostingViewOnMain() -> UIView {
        MainActor.assumeIsolated {
            let hosting = UIHostingController(rootView: InlineSwiftUIButton {
                print("SwiftUI Button tapped!")
            })

            hosting.view.backgroundColor = .clear
            return hosting.view
        }
    }
}

With this approach, we have achieved a solution that fully satisfies the Swift 6 compiler's requirements while maintaining seamless compatibility with legacy APIs.