swiftswiftuicanvasclosuresgraphicscontext

How can I extract a value from inside a SwiftUI Canvas closure?


I need the 'size' value provided by Canvas' GraphicsContext closure, outside of the closure, but no matter what I do, whatever happens in Vegas... I mean whatever happens in Canvas, stays in Canvas - I can't mutate (?) external values no matter what I've tried. I can read/use all external values, I just can’t change/write to any of them. Here is a sample (and, yes, my .frame() is deciding the size in the example, but the View is destined to be a subView in other Views where the user or UI decides the actual/final size):

import SwiftUI

struct ContentView3: View {
    @State var var1 = 0.25
    @State var var2 = 0.75
    @State var someStr = "Hey"
    var someVar = 0.5
    
    var body: some View {
        
        Canvas { context, size in
            
            var1 = size.width //var1 never changes.
            var2 += 37 //var2 never changes.
            someStr = String("\(size.width)") //someStr never changes.

            //the value of someVar is allowed to pass in.               
            context.fill(Ellipse().path(in: CGRect(origin: .zero, size: CGSize(width: someVar*size.width, height: size.height))), with: .color(Color.blue))
        }
        .frame(width: 400, height: 300)
        
        //The values never changed:
        HStack {
            Spacer()
            Text("var1: \(var1)")
            Text(someStr)
            Text("var2: \(var2)")
            Spacer()
        }
    }
}

#Preview { ContentView3() }

Solution

  • The CGSize parameter of the Canvas closure is just the size of the Canvas. If you want to store that in a @State, you can separately use onGeometryChange to get the size of the Canvas:

    @State var size = CGSize.zero
    
    var body: some View {
        Canvas { gc, size in
            // ...
        }
        .onGeometryChange(for: CGSize.self, of: \.size) { newValue in
            size = newValue
        }
    }
    

    If you need to support older OS versions, you can wrap a GeometryReader like this too:

    @State var size = CGSize.zero
    
    var body: some View {
        GeometryReader { geo in
            Canvas { gc, size in
                // ...
            }
            .onAppear { size = geo.size }
            .onChange(of: geo.size) { size = $0 }
        }
    }
    

    Since GeometryReader and Canvas both honour size proposals (aka "taking up all available space"), they will necessarily have the same size in the above code.


    The Canvas closure is called during a view update. This is when you cannot modify any @States. If you delay the line that sets the @State so that it runs after the view update has completed, it works:

    @State var size = CGSize.zero
    
    var body: some View {
        Canvas { gc, size in
            DispatchQueue.main.async {
                self.size = size
            }
        }
    }
    

    Instead of updating a @State, you can also set a property in an @Observable class. This is allowed even during a view update.

    @Observable
    class SizeWrapper {
        var size = CGSize.zero
    }
    
    @State var sizeWrapper = SizeWrapper()
    
    var body: some View {
        Canvas { gc, size in
            sizeWrapper.size = size
        }
    }