Search code examples
macosswiftuiasync-awaitobservedobject

Getting async/await to work with ObservableObject, Published, StateObject


Let me say I'm new to using the UI in SwiftUI and so I apologize if this may have been answered adjacently in various other scenarios but I can't seem to find one that is particular to my issue.

I created a function that generates a CGImage for MacOS. It works wonderfully in the command line tool I originally meant it for. Without going into too much details it is async because it uses withTaskGroup() to cut down the time of the work needed to generate an image.

I want to use it an a @Published var inside an @ObservableObject class. Here is the basic code I started out with non-async version:

struct ContentView: View {
    @StateObject var target = Target()
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Button("Hello, world! \(target.value)")
            {
                target.value += 1
                if (target.value == 3)
                {
                    target.value = 0
                }
                target.update()
            }
            target.image
        }
        .padding()
    }
}

class Target: ObservableObject
{
    @Published var image: Image
    @Published var value: Int
    
    init()
    {
        value = 0
        image = Target.update(input: 0)        
    }
    
    func update()
    {
        image = Target.update(input: value)
    }
    
    static func update(input: Int) -> Image
    {
        var cgimage: CGImage?
        cgimage = drawMyImage(input: input)
        return Image(cgimage!, scale: 1.0, label: Text("target \(input)"))        
    }
}

I put a dummy drawMyImage() function below if anyone is interested in some quick testing.

So the above code isn't async. The problem is when I start to use async, so I async'd the drawMyImage() function which is what my real drawImage() function would be:

func drawMyImage(input: Int) async -> CGImage?

Attempt 1

'async' call in an autoclosure that does not support concurrency

If I go up the stack inserting appropriate async/await:

    init() async
    {
        value = 0
        image = await Target.update(input: 0)
        
    }
    
    func update() async
    {
        image = await Target.update(input: value)
    }
    
    static func update(input: Int) async -> Image
    {
        var cgimage: CGImage?
        cgimage = await drawMyImage(input: input)

I finally end up with this in ContentView that I can't seem to work around:

struct ContentView: View {
    @StateObject var target = Target() //'async' call in an autoclosure that does not support concurrency
    
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Button("Hello, world! \(target.value)")
            {
                target.value += 1
                if (target.value == 3)
                {
                    target.value = 0
                }
                target.update() //'async' call in an autoclosure that does not support concurrency

Attempt 2

Mutation of captured var 'myImage' in concurrently-executing code

So I tried wrapping just the drawMyImage() function in a task and that didn't go so well:

    static func update(input: Int) -> Image
    {
        var myImage: Image
        Task
        {
            var cgimage: CGImage?
            cgimage = await drawMyImage(input: input)
            myImage = Image(cgimage!, scale: 1.0, label: Text("target \(input)")) //Mutation of captured var 'myImage' in concurrently-executing code
        }
        return myImage
    }

Attempt 3

Call to main actor-isolated static method 'update(input:)' in a synchronous nonisolated context

So I tried adding the @MainActor decorator and that gave me a different error:

    init()
    {
        value = 0
        image = Target.update(input: 0) //Call to main actor-isolated static method 'update(input:)' in a synchronous nonisolated context
        
    }
    
    func update()
    {
        image = Target.update(input: value) //Call to main actor-isolated static method 'update(input:)' in a synchronous nonisolated context
    }
    
    @MainActor
    static func update(input: Int) -> Image
    {

I've tried several other things to clear this up, but this seems to be the main ones I come around to over and over again. If someone could lend me a hand, I'd appreciate, it. Thanks in advance.

dummy drawMyImage() function

//dummy test function with no real input, no real async
//remove 'async' for how it's supposed to work
func drawMyImage(input: Int) async -> CGImage? {
    let bounds = CGRect(x: 0, y:0, width: 200, height: 200)
    let intWidth = Int(ceil(bounds.width))
    let intHeight = Int(ceil(bounds.height))
    let bitmapContext = CGContext(data: nil,
                                  width: intWidth, height: intHeight,
                                  bitsPerComponent: 8,
                                  bytesPerRow: (((intWidth * 4) + 15) / 16) * 16,
                                  space: CGColorSpace(name: CGColorSpace.sRGB)!,
                                  bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue)
    if let cgContext = bitmapContext {
        cgContext.saveGState()
        if input == 0
        {
            cgContext.setFillColor(red: 255, green: 0, blue: 0, alpha: 255)
        }
        else if input == 1
        {
            cgContext.setFillColor(red: 0, green: 255, blue: 0, alpha: 255)
        }
        else
        {
            cgContext.setFillColor(red: 0, green: 0, blue: 255, alpha: 255)
        }
        cgContext.fill(bounds)
        cgContext.restoreGState()

        return cgContext.makeImage()
    }

    return nil
}

Solution

  • Just like in your first attempt, you can propagate async up the call chain, but not until init. Because the operation is async, Target is not going to immediately have an Image available as soon as it is initialised. Also as you found out in your attempt, the view requires the ObservableObject to be initialised synchronously.

    You should make image optional:

    class Target: ObservableObject
    {
        @Published var image: Image?
        @Published var value: Int = 0
        
        @MainActor // isolate this to the main actor because @Published vars can only be updated from the main thread
        func update() async
        {
            image = await Target.update(input: value)
        }
        
        static func update(input: Int) async -> Image
        {
            var cgimage: CGImage?
            cgimage = await drawMyImage(input: input)
            return Image(cgimage!, scale: 1.0, label: Text("target \(input)"))
        }
    }
    

    In your view, this is a perfect opportunity to use task(id:). This runs an async operation when the view first appears, as well as when its id argument changes. Just pass target.value as the ID and SwiftUI handles all the task cancellation for you.

    VStack {
        Image(systemName: "globe")
            .imageScale(.large)
            .foregroundStyle(.tint)
        Button("Hello, world! \(target.value)")
        {
            target.value += 1
            if (target.value == 3)
            {
                target.value = 0
            }
        }
        target.image
    }
    .padding()
    .task(id: target.value) {
        await target.update()
    }