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())!
}
}