Using MainActor.assumeIsolated to Solve Legacy API Compatibility Issues with Swift 6

Published on

While Swift has offered strict concurrency checking for some time, many of Apple’s official APIs have yet to be fully adapted, and this situation may persist for quite a while. As Swift 6 gradually gains adoption, this problem becomes increasingly prominent: developers want to benefit from the concurrency safety guarantees provided by the Swift compiler, while struggling with how to make their code meet compilation requirements. This article will demonstrate the clever use of MainActor.assumeIsolated in specific scenarios through an implementation case with NSTextAttachmentViewProvider.

An Email

A few days ago, I received an email from a fellow developer, Lucas. He encountered a problem where legacy APIs couldn’t meet Swift 6 compilation requirements. He wanted to implement custom view insertion in UITextView using NSTextAttachment + NSTextAttachmentViewProvider. To achieve this, he tried loading a SwiftUI view in the loadView method of NSTextAttachmentViewProvider:

Swift
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 above code produced the following errors/warnings:

image-20250826075726095

To solve this compilation issue, Lucas tried various approaches:

  • Adding @MainActor to the loadView method

image-20250826075854962

  • Adding @MainActor to the CustomAttachmentViewProvider class

image-20250826075953957

  • Wrapping the code in loadView within a Task

image-20250826080105350

However, none of these methods satisfied the Swift compiler’s requirements.

In Xcode 26 beta 5, the original code produces errors (compilation fails), while in beta 6 it generates warnings (but compiles).

Does this mean such legacy APIs cannot achieve perfect compilation in Swift 6 targets (without warnings or errors)?

Analyzing the Problem

The Swift 6 compiler rejects these approaches for the following reasons:

  • UIHostingController is annotated with @MainActor in its declaration, meaning it must be created in a MainActor context
  • NSTextAttachmentViewProvider’s original declaration has no explicit isolation
  • Adding @MainActor solely to loadView doesn’t match the parent class requirements
  • If we build a MainActor async context within loadView, we cannot safely pass self

We seem to be caught in a dilemma: we need to construct UIHostingController in MainActor, yet we cannot assign the constructed view (UIView) to self.view within MainActor.

Is there a way to satisfy both requirements?

MainActor.assumeIsolated: Providing MainActor Context in Synchronous Methods

Among Swift’s concurrency APIs, there’s a rather peculiar one: MainActor.assumeIsolated. Unlike MainActor.run, it can only run in a synchronous context, and if the current context isn’t MainActor, the app will crash immediately.

When I first encountered this method, I was puzzled about its purpose. For a long time, I only used it like MainActor.assertIsolated, as a debugging tool to determine whether the current context was MainActor. It wasn’t until I encountered the problem described in this article that I truly understood this API’s design intent.

Looking at MainActor.assumeIsolated’s signature, we can see that this API provides a MainActor context for its trailing closure. This means we can “synchronously” run code that can only execute in a MainActor context within a non-MainActor synchronous context, without creating an async environment, and return a Sendable result.

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

The Solution

Understanding how MainActor.assumeIsolated works, we can rewrite loadView as:

Swift
class CustomAttachmentViewProvider: NSTextAttachmentViewProvider {
    override func loadView() {
        let view = MainActor.assumeIsolated { // Running in synchronous context
            // assumeIsolated closure provides MainActor environment, allowing safe UIHostingController creation
            let hosting = UIHostingController(rootView: InlineSwiftUIButton {
                print("SwiftUI Button tapped!")
            })

            hosting.view.backgroundColor = .clear
            return hosting.view // view is UIView, annotated with MainActor, satisfies Sendable
        }
        self.view = view
    }
}

In the code above:

  • We successfully execute MainActor.assumeIsolated within loadView
  • The MainActor.assumeIsolated closure provides a MainActor context, allowing us to safely create a UIHostingController instance
  • hosting.view is of type UIView (already annotated with @MainActor in its declaration), satisfying the Sendable requirement, and can be returned from the closure
  • In the synchronous context of loadView, we assign the return value of MainActor.assumeIsolated to self.view, ensuring isolation consistency

Considering that loadView doesn’t always execute in MainActor, here’s the complete final code:

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

    // Switch to main thread if `loadView` isn't running on main thread
    func getView() -> UIView {
        if Thread.isMainThread {
            return Self.createHostingViewOnMain()
        } else {
            return DispatchQueue.main.sync {
                Self.createHostingViewOnMain()
            }
        }
    }

    // Using static method to avoid capturing self
    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, we’ve implemented a solution that fully satisfies Swift 6 compiler requirements and is compatible with legacy APIs.

Perhaps Not the Most Elegant, but Its Existence Is Justified

To catch concurrency issues at compile time, Swift has added numerous related keywords and methods in recent years, which has increased developers’ learning costs and usage difficulty to some extent. While I’m not well-versed in programming language design, intuitively, this approach of piling on features doesn’t seem elegant and adds to comprehension difficulty.

However, considering that Swift doesn’t exist in isolation—in practice, we need to work with numerous legacy APIs and even Objective-C code—these seemingly “cumbersome” designs do have their justification.

I still hope we can move past this somewhat “chaotic” transition period soon. Perhaps in a few years, when numerous official and third-party frameworks have completed their Swift 6 migration, we’ll finally enjoy a more relaxed safe concurrent programming experience.

If this article helped you, feel free to buy me a coffee ☕️ . For sponsorship inquiries, please check out the details here.

Weekly Swift & SwiftUI highlights!