Search code examples
imagecanvasswiftuiviewcgcontext

Recursive Rendering using a persistent SwiftUI View/Image/Canvas


There are some applications where a programmer desires a persistent image or view to write to. This would obviate the need to re-render old data when a view gets updated. That is, the view's updates would ideally need to render only the new paths, and would not need to wastefully re-render all previous (non-updated) paths. I call this capability "Recursive Rendering" - meaning that the image displayed can be programmatically changed without the wastefulness of re-drawing old data to the screen.

In response to my first StackOverflow question on how to do this, @rob mayoff proposed an innovative solution involving (1) drawing into a Canvas, (2) converting the Canvas to an image (which gets displayed on the screen), and (3) drawing the image back into the Canvas before writing on it again. (See here.) This feedback loop satisfies my requirement, but it produces fuzzy results and has memory leaks.

I reworked Rob's answer and posed fixing it's problems as my second StackOverflow question. (See here.) An Apple Developer Technical Support engineer told me that the problems (fuzzy lines & memory leaks) were inherent in switching back and forth between a Canvas and an Image, and proposed some clean code which fixed the problems. Unfortunately, the code did NOT do Recursive Rendering. It still required rendering all old paths for each data-update (even the paths not effected by the update).

I am still seeking a solution to my desire for a persistent SwiftUI View/Image/Canvas that I can continuously draw new data into without having to re-render old data that I still want visible on the screen.


Solution

  • I have explored this idea further, and finally realized that I can create a Core Graphics CGContext that is mutable and persistent. Printed below is a complete Xcode project in which: (1) a DataGenerator ObservableObject class publishes a 4-element array of random numbers every tenth-of-a-second; (2) a ContentView struct displays an image in it's View, and passes this observed array to a DrawingPane struct; (3) the DrawingPane struct declares a CGContext and draws a random line onto it (with endpoints specified by the received 4-element array); and (4) it creates an image of the CGContext and returns it to the ContentView for display. This process repeats over-and-over (using the same persistent CGContext) without ever having to re-render any old data to the screen.

    I am very proud of this solution, but I would much prefer to use all of the modern SwiftUI tools than the old Core Graphics tools. Can anyone show me how to do this with a SwiftUI Canvas (which creates a GraphicsContext - which is similar to a CGContext)? It appears that SwiftUI won't allow me to simply declare a GraphicsContext (as I declared a CGContext below). A GraphicsContext is only created by a Canvas and is only valid within the closure of that Canvas. I have been unable to code an app similar to the one below using SwiftUI's Canvas view.

    import SwiftUI
    
    @main
    struct RecursiveDrawingApp: App {
        @StateObject var dataGenerator = DataGenerator()
        var body: some Scene {
            WindowGroup {
                ContentView()
                    .environmentObject(dataGenerator)
            }
        }
    }
    
    
    final class DataGenerator: ObservableObject {
        @Published var myArray = [Double](repeating: 0.0, count: 4)
        var tempArray = [Double](repeating: 0.0, count: 4)
        init() {
            Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
                for x in 0 ..< 4 {
                    self.tempArray[x] = Double.random(in: 0.0 ... 1.0)
                }
                self.myArray = self.tempArray
            }
        }
    }
    
    
    struct ContentView: View {
        @EnvironmentObject var dataGenerator: DataGenerator
        @State var drawingPane = DrawingPane()
        @State var myCGImage: CGImage?
        @State var counter: Int = 0
        @State var myColor: CGColor = .white
        @Environment(\.displayScale) var displayScale: CGFloat
        
        var body: some View {
            ZStack {
                if myCGImage != nil {
                    Image(decorative: myCGImage!, scale: displayScale, orientation: .up).resizable()
                }
            }
            .onReceive(dataGenerator.$myArray) { value in       // onReceive subscribes to the "dataGenerator" publisher.
                counter = counter < 400 ? counter + 1 : 0
                myColor = counter < 200 ? .white : .black
                myCGImage = drawingPane.addLine( data: value, color: myColor )
            }
        }
    }
    
    
    struct DrawingPane {
        static let width:  Int = 1_000
        static let height: Int = 1_000
    
        let context = CGContext(data: nil,
                                width: width,
                                height: height,
                                bitsPerComponent: 8,
                                bytesPerRow: width * 4,
                                space: CGColorSpace(name: CGColorSpace.sRGB)!,
                                bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue )
    
        let width  = Double( DrawingPane.width )
        let height = Double( DrawingPane.height )
    
        func addLine( data: [Double], color: CGColor ) -> CGImage {
            context?.move(   to: CGPoint( x: data[0] * width, y: data[1] * height ) )
            context?.addLine(to: CGPoint( x: data[2] * width, y: data[3] * height ) )
            context?.setLineWidth(1)
            context?.setStrokeColor(color)
            context?.strokePath()
            return (context?.makeImage())!
        }
    }