AsyncCanvas in SwiftUI

Durymanov Andrei
6 min readJun 27, 2023

--

Most of the text in this article was generated with ChatGPT. You can use it as a reference or as wrappers for code snippets and measurements, which form the core value of the article.

Repository with materials using in this article

Repository with AsyncCanvas

Introduction

The advent of SwiftUI brought a refreshing approach to UI development on Apple platforms. It’s declarative, it’s concise, and it’s powerful. However, when it comes to intensive graphical tasks, it can sometimes meet its limits. Traditional methods of rendering on the SwiftUI Canvas can lead to less-than-optimal performance and hinder the smoothness of the user interface.

The aim of this article is to illustrate an alternative approach to SwiftUI Canvas rendering—AsyncCanvas. This method allows for asynchronous rendering, which can enhance performance and provide a more fluid user experience, especially when handling demanding graphics tasks.

Test Environment

For our testing setup, we use Xcode version 13.0 or later and target iOS 15.0 or later to leverage the full potential of SwiftUI and Apple’s new concurrency features. Our test environment is encapsulated in a View called Environment.

Our Environment renders a number of lines based on the value state variable. The lines, drawn within a render method, have random start and end points and random colors. This approach allows us to test the performance impact of rendering a large number of complex graphical objects in SwiftUI.

struct Environment<R: View>: View {

@State var value = 1.0

let benchmark: Double = 50_000

let render: (Double) -> R

var body: some View {
VStack {
render(value)
Button("Show lines") {
value = benchmark
}

Slider(value: $value, in: 1...100_000)
}
.padding()
}

}

The Environment struct is designed with two main components for performance testing:

  1. A Slider: This allows for a smooth transition between different Double values, which dynamically update the rendered view. It is used to test UI smoothness and observe the impact of varying graphical intensity on the user interface's performance.
  2. A Button: The “Show lines” button is used to instantly set the value to the pre-determined benchmark value. This simulates a high-intensity rendering scenario and allows us to benchmark how different rendering techniques handle such conditions.

Basic ways to implement Canvas

Shapes

Shapes are the building blocks of SwiftUI’s drawing system. They can be stroked, filled, and used in other drawing operations.

struct RenderShapes: View {

let value: Double

var body: some View {
ZStack {
ForEach(0...Int(value), id: \\.self) { _ in
Path { path in
path.move(to: .random)
path.addLine(to: .random)
}
.stroke(
Color.random,
style: StrokeStyle(
lineWidth: 1,
lineCap: .round,
lineJoin: .round
)
)
}
}
.drawingGroup()
}

}

This is implementation of render view using shapes.

Canvas

Canvas is a SwiftUI view that you can use to draw custom graphics, charts, or other visualisations directly with a flexible interface.

struct RenderCanvas: View {

let value: Double

var body: some View {
Canvas(rendersAsynchronously: true) { context, _ in
for _ in 0...Int(value) {
let path = Path {
$0.move(to: .random)
$0.addLine(to: .random)
}
context.stroke(
path,
with: .color(.random),
style: StrokeStyle(lineWidth: 1)
)
}
}
}
}

This is implementation of render view using Canvas.

Measurements of Basic Implementation

Shapes render of 50000 lines
Shapes render of 50000 lines

As we have seen, rendering complex objects with shapes can cause significant hang-ups in the user interface. This is expected as these components need to synchronise with other views, necessitating their rendering on the main thread.

Canvas render of 50000 lines

Canvas provides a more efficient solution for this kind of rendering. It uses far fewer resources to render the final image, but it can still cause minor hang-ups. The reason for these slowdowns is that the render method runs synchronously with the main thread. This method execution is labelled as "canvas_render" in the "Points of Interests" section.

As result, the basic implementation works well for simple and moderate complexity tasks, providing high-quality rendering with reasonable performance. However, performance might degrade for more complex tasks due to the synchronous nature of the rendering.

AsyncCanvas Solution

The key difference in the AsyncCanvas solution lies in the render method running asynchronously. This means the rendering of the canvas does not block the main thread, and the user interface remains responsive even during complex rendering tasks.

public struct AsyncCanvas<Processor: RenderProcessor>: View {

typealias ProcessorTask = RenderTask<Processor.Context>

@State var image: Image?

@State var renderTask: Task<Void, Never>?

private let render: ProcessorTask

private let processor: Processor

public var body: some View {
GeometryReader { proxy in
ZStack {
if let image { image }
}
.onChange(of: render) { newRender in
render(size: proxy.size, task: render)
}
.task {
render(size: proxy.size, task: render)
}
}
}

func render(size: CGSize, task: ProcessorTask) {
renderTask?.cancel()

renderTask = Task.detached {
await render(size: size, task: task)
}
}

func render(size: CGSize, task: ProcessorTask) async {
guard let image = await processor.render(size: size, render: task)
else {
return
}
guard !Task.isCancelled else {
return
}
await MainActor.run {
self.image = image
}
}

}

AsyncCanvas is a custom SwiftUI view that mimics the Canvas view but with a twist — it handles the rendering asynchronously. This way, it avoids blocking the main thread, improving the user interface’s overall responsiveness.

Processor could be implemented using Core Graphics framework.

public final actor CGRenderProcessor: RenderProcessor {

public typealias Context = CGContext

public func render(size: CGSize, render: RenderTask<Context>) async -> Image? {
try? await Self.render(size: size, render: render)
}

}

private extension CGRenderProcessor {

enum RenderError: Error {
case taskCanceled
case uiImageRenderFiled
}

static func render(size: CGSize, render: RenderTask<CGContext>) async throws -> Image {
defer {
UIGraphicsEndImageContext()
}

try checkCancelation()

UIGraphicsBeginImageContextWithOptions(size, false, .zero)

try checkCancelation()

let context = UIGraphicsGetCurrentContext()!

try checkCancelation()

await render.task(context, size)

try checkCancelation()

guard let uiImage = UIGraphicsGetImageFromCurrentImageContext() else {
throw RenderError.uiImageRenderFiled
}

try checkCancelation()

return Image(uiImage: uiImage)
}

static func checkCancelation() throws {
if Task.isCancelled {
throw RenderError.taskCanceled
}
}

}

The render method in CGRenderProcessor starts by beginning a new CGContext. It then executes the render task within this context. Once the task is complete, the resulting image is rendered into a UIImage and returned as an Image view.

Example of usage

struct RenderAsyncCanvas: View {

let value: Double

var body: some View {
AsyncCanvas { context, _ in
for _ in 0...Int(value) {
guard !Task.isCancelled else {
return
}
context.move(to: .random)
context.addLine(to: .random)
context.setStrokeColor(.random)
context.strokePath()
}
}
}

}

The usage of AsyncCanvas is similar to standard SwiftUI Canvas, but there are two key differences. First, the render method in AsyncCanvas is asynchronous, and second, the context used is CGContext.

Measurements of AsyncCanvas

AsyncCanvas render 50000 lines

In the “Points of Interests” section, the “cg_render” label highlights the execution of the render method. The AsyncCanvassolution does require more time to handle resource-intensive rendering tasks. However, all these calculations are processed on background threads, thereby preventing any potential interface freezes or hangs.

Conclusion

While the default SwiftUI solutions offer an excellent starting point for most tasks, they have certain limitations when it comes to complex, resource-intensive operations. But fear not, these limitations can be bypassed with custom solutions such as AsyncCanvas.

Remember, SwiftUI is a powerful tool, but like any tool, it’s most effective when used in the right way. Happy coding!

--

--