Drawing graphics with Canvas

Drawing graphics with Canvas

Learn how to use the Canvas view to render graphics within a SwiftUI app.

With iOS 15, Apple introduced Canvas, a view type that can draw dynamic graphics in a SwiftUI app. When it comes to creating custom graphics and animations, this view allows for the handling of the rendering of several complex shapes more efficiently than composing multiple SwiftUI views, offering the flexibility needed for customization without neglecting the performance.

To render the content, Canvas mainly relies on a GraphicsContext object used to address the drawing commands and enable precise control over this kind of operation, whether paths, images, or text are being used.

In this reference article, we are going to explore how to integrateCanvas to draw primitive figures by using the init(opaque:colorMode:rendersAsynchronously:renderer:) constructor.

Canvas(
    // 1. Opacity
    opaque: false,
    // 2. ColorMode
    colorMode: .nonLinear,
    // 3. Rendering mode
    rendersAsynchronously: false
    // 4. Render Closure
) { context, size in
    // a. A Path
    let rect = Path(roundedRect: CGRect(x: 0, y: 0, width: size.width - 20, height: size.height - 300), cornerRadius: 20)
    // b. Fill the context with the path
    context.fill(rect, with: .color(.red))
}
.border(.yellow)

Overview of the Canvas view constructor and a basic example

init(opaque:colorMode:rendersAsynchronously:renderer:) takes the following parameters:

  1. opaque - A boolean value set to false by default, which makes the canvas fully opaque. When set to true, it becomes a non-opaque image, which can produce undefined results.
The opaque parameter set first as false and then as true.

2. colorMode - defines how colors are processed and displayed on the screen. By default it's set as ColorRenderingMode.nonLinearColorRenderingMode comes with three built-in modes:

    1. nonLinear - It uses the standard sRGB color space with gamma correction, which adjusts the brightness of colors to align with human visual perception, making sure that colors appear natural and consistent across different devices.
    2. linear - It uses the standard sRGB color space without gamma correction, so that the colors are handled in their raw form, which results in more accuracy during operations like blending or applying lighting effects, but when displayed directly, they might appear darker than expected because the human eye perceives brightness non-linearly.
    3. extendedLinear - It extends the capabilities of the linear sRGB color space by allowing color component values beyond the standard range [0, 1] enabling representation of a broader spectrum of colors, including those that are more vivid or intense than what standard sRGB can depict.
Keep in mind that the standard sRGB color space range is [0, 1] and all values outside this range can produce undefined results.
  1. rendersAsynchronously - A boolean value - set to false by default - that allows presenting its contents to its parent view asynchronously.
  2. renderer - A closure called whenever the content needs to be redrawn, which allows access to:
      • GraphicsContext, representing the context to draw in, by using its methods;
      • CGSize, representing the current size of the canvas.

In this closure of the example, a path representing a red rectangle is being instantiated and inserted into the context and then being filled with the red color.

Canvas implementation in a SwiftUI view

To learn how to integrate the Canvas view in a SwiftUI view, we will create one where it is possible to manually draw graphics line by line using different colors.

First, let’s define a custom type called Line. It will represent the line drawn on the canvas.

struct Line {
    var points: [CGPoint]
    var color: Color
}

This type will hold all the needed information to display the lines we want to draw on the canvas. It stores two properties:

  1. A collection of CGPoint - representing all the points composing the drawn line;
  2. Color - The color with which the line will be drawn.

Now, create a view representing the color selection bar that allows you to choose and change the color used while drawing.

struct ColorBarView: View {
    // Binding to the selected color in the parent view
    @Binding var selectedColor: Color
    
    // Binding to the current line being drawn in the parent view
    @Binding var currentLine: Line
    
    // Predefined set of colors
    var colors = [Color.red, Color.orange, Color.yellow, Color.green, Color.blue, Color.purple, Color.black]
    
    var body: some View {
        HStack {
            // Iterate over the colors to create color selection circles
            ForEach(colors, id: \\.self) { color in
                Circle()
                    .fill(color)
                    .frame(width: 30, height: 30)
                    .padding(5)
                    
                    // 1. Detect the tap gesture
                    .onTapGesture {
                        // 2. Update the selected color and the current line's color when a circle is tapped
                        selectedColor = color
                        currentLine.color = color
                    }
                    
                    // Add a gray stroke around the circle if it is the currently selected color
                    .overlay(
                        Circle()
                            .stroke(Color.gray, lineWidth: selectedColor == color ? 2 : 0)
                    )
            }
        }
    }
}

The view binds two variables to its parent view:

  1. The selected color chosen by the user;
  2. The current line being drawn.

It works as follows:

  1. It detects the tap gesture.
  2. And, it updates the variables when one of the colors is being tapped.

In our ContentView , we need a way to detect and collect where the user is moving on the screen while drawing; this information will be used for displaying each line on the canvas.

struct ContentView: View {

    // State variable to store all the lines drawn by the user
    @State private var lines: [Line] = []
    
    // State variable to keep track of the current line being drawn
    @State private var currentLine = Line(points: [], color: .black)
    
    // State variable to store the currently selected color for drawing
    @State private var selectedColor: Color = .black

    var body: some View {
        VStack {
            Canvas { context, size in
                // To be implemented
            }
            
            .gesture(
                // 1. Detect the DragGesture on the screen
                DragGesture()
                    .onChanged { value in
                        // 2. Append the current drag location to the current line's points
                        currentLine.points.append(value.location)
                        // 3. Add the updated current line to the lines array
                        lines.append(currentLine)
                    }
                    .onEnded { _ in
                        // 4. Reset the current line with the selected color after the drag ends
                        currentLine = Line(points: [], color: selectedColor)
                    }
            )
            
            // Color selection bar view
            ColorBarView(selectedColor: $selectedColor, currentLine: $currentLine)
                .padding()
        }
        .background(.ultraThinMaterial)
    }
}
  1. Create a DragGesture to detect and store the dragging movement on the screen when the user is drawing the lines.
  2. Whenever the user drags on the screen, touch locations are captured by DragGesture and appended to the currentLine.points , a state variable that keeps track of the current line being drawn.
  3. This line is then added to the lines array, which stores all lines to be rendered to display the out-coming drawing.
  4. When the dragging is over, the currentLine is reset with the selected color, ready for the next drawing.

Now that we have all the information needed to display the drawings, we are ready to use them to implement the Canvaslogic.

struct ContentView: View {

    // State variable to store all the lines drawn by the user
    @State private var lines: [Line] = []
    
    ...

    var body: some View {
        VStack {
            // The canvas
            Canvas { context, size in
                // 1. Iterate over all the lines drawn and render them on the canvas
                for line in lines {
                    // 2. Create an instance of a path
                    var path = Path()
                    // 3. Adding the points of each line collected
                    path.addLines(line.points)
                    // 4. Stroke each line with its corresponding color and a line width of 5
                    context.stroke(path, with: .color(line.color), lineWidth: 5)
                }
            }
       
       ...
       
    }
}
  1. Iterate over all the lines drawn and collected in lines since all of them need to be displayed.
  2. Create a new instances of Path for each line stored.
  3. Use the addLines(_:) method to add all the collected points of each line to the path just created.
  4. Use the stroke(_:) method to create a stroke for each line with its corresponding color and a line width of 5.
0:00
/0:14