New in SwiftUI 3: Canvas

DevTechie Inc
Apr 6, 2022

SwiftUI 3 introduced a brand spanking new view called ✨ Canvas ✨ for rich and dynamic 2D graphics drawing.

Canvas view passes GraphicsContext and Size values to it closure which can be used to perform immediate mode drawing. Canvas can draw path, image, or complete SwiftUI views inside it.

Let’s take a look at Path drawing inside canvas:

struct SimpleCanvasExample: View {
    
    var body: some View {
        ZStack {
            Canvas { context, size in
                context.stroke(
                    Path(ellipseIn: CGRect(origin: .zero, size: size).insetBy(dx: 5, dy: 5)),
                    with: .color(.orange),
                    lineWidth: 4)
            }
            .frame(width: 300, height: 200)
            .border(Color.blue)
            
            Text("DevTechie")
                .font(.largeTitle)
                .foregroundColor(.orange)
        }
    }
    
}
Note use of context and size inside canvas closure in the code 👆

Here we are using Canvas’s closure to draw ellipse inside a CGRect. Note use of GraphicsContext to draw path for ellipse. We are also using size passed in closure to make sure that our ellipse fits well inside the rect.

Closure in Canvas is not a ViewBuilder like other closures in SwiftUI views, this one is a Swift closure so we can do Swift related operations directly inside the closure and draw on the GraphicsContext.

Lets create an example to perform additional Swifty 😃 operations inside Canvas closure.

struct SimpleCanvasExample: View {
    
    var body: some View {
        ZStack {
            Canvas { context, size in
                
                let gradient = Gradient(colors: [.blue, .pink, .orange])
                let rect = CGRect(origin: .zero, size: size).insetBy(dx: 5, dy: 5)
                let path = Path(ellipseIn: rect)
                
                context.stroke(
                    path,
                    with: .color(.orange),
                    lineWidth: 10)
                
                context.fill(path, with: .linearGradient(gradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0)))
            }
            .frame(width: 300, height: 200)
            
            
            Text("DevTechie")
                .font(.largeTitle)
                .foregroundColor(.white)
        }
    }
    
}
Notice canvas area and use of context.stroke and context.fill functions☝️

So far we have been drawing path and shapes in canvas but canvas can support drawing of text as well.

If you notice, you will realize that “DevTechie” text has been put on Zstack to add text on the top of canvas but what if you want to draw text inside the canvas as well. Lets add following lines to our code:

let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            
let text = Text("DevTechie")
    .font(.largeTitle)
    .fontWeight(.bold)
    .foregroundColor(.white)context.draw(text, at: midPoint, anchor: .center)
Here we are computing midpoint for text to be drawn and then creating TextView with SwiftUI modifiers chained together to format our text. We will store this text view inside a constant and pass it to draw function of GraphicsContext . Our final code will look like this:

struct SimpleCanvasExample: View {
    
    var body: some View {
        Canvas { context, size in
            
            let gradient = Gradient(colors: [.blue, .pink, .orange])
            let rect = CGRect(origin: .zero, size: size).insetBy(dx: 5, dy: 5)
            let path = Path(ellipseIn: rect)
            
            context.stroke(
                path,
                with: .color(.orange),
                lineWidth: 10)
            
            context.fill(path, with: .linearGradient(gradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0)))
            
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            
            let text = Text("DevTechie")
                .font(.largeTitle)
                .fontWeight(.bold)
                .foregroundColor(.white)
            
            context.draw(text, at: midPoint, anchor: .center)
        }
        .frame(width: 300, height: 200)
    }
    
}
We are not using Zstack anymore. 🆒

GraphicsContext’s ResolvedText struct works with text views as well and can be used to resolves a text view. This struct prepares the text to be drawn into the context. ResolveText has size property so any custom drawn text’s size can be computed dynamically using this struct. ResolvedText takes environment values into consideration while drawing, which includes values like display resolution, color scheme etc.

We will also use GraphicsContext’s LinearGradient Shading to add linear gradient to our text view.

struct SimpleCanvasExample: View {
    
    var body: some View {
        ZStack {
            Canvas { context, size in
                
                let gradient = Gradient(colors: [.blue, .pink, .orange])
                let rect = CGRect(origin: .zero, size: size).insetBy(dx: 5, dy: 5)
                let linearGradient = GraphicsContext.Shading.linearGradient(gradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0))
                
                let midPoint = CGPoint(x: size.width/2, y: size.height/2)
                let font = Font.custom("Chalkduster", size: 54)
                
                var resolvedText = context.resolve(Text("DevTechie").font(font))
                
                resolvedText.shading = linearGradient
                
                context.draw(resolvedText, at: midPoint, anchor: .center)
            }
            .frame(width: 300, height: 200)
            
        }
    }
    
}
Just like text, images can be drawn on canvas as well. Images can be added by drawing directly on the canvas as shown below:

let image = Image(systemName: "globe")
context.draw(image, in: rect.insetBy(dx: 50, dy: 20))
We will add GraphicsContext blend to the image as well.

struct SimpleCanvasExample: View {
    
    var body: some View {
        Canvas { context, size in
            
            let gradient = Gradient(colors: [.blue, .pink, .orange])
            let textGradient = Gradient(colors: [.white, .yellow, .white])
            let rect = CGRect(origin: .zero, size: size).insetBy(dx: 5, dy: 5)
            let linearGradient = GraphicsContext.Shading.linearGradient(gradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0))
            let textLinearGradient = GraphicsContext.Shading.linearGradient(textGradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0))
            
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            let font = Font.custom("Chalkduster", size: 54)
            
            var resolvedText = context.resolve(Text("DevTechie").font(font))
            resolvedText.shading = textLinearGradient
            
            let path = Path(ellipseIn: rect)
            context.stroke(
                path,
                with: .color(.orange),
                lineWidth: 10)
            context.fill(path, with: linearGradient)
            
            context.draw(resolvedText, at: midPoint, anchor: .center)
            context.blendMode = GraphicsContext.BlendMode.softLight
            
            let image = Image(systemName: "globe")
            context.draw(image, in: rect.insetBy(dx: 50, dy: 20))
        }
        .frame(width: 300, height: 200)
    }
    
}
We can mutate GraphicsContext, just like CGContext. So let’s take a look at few ways to update context.

Lets use addFilter to apply blur to our image:

let image = Image(systemName: "globe")
var filterContext = context
filterContext.addFilter(GraphicsContext.Filter.blur(radius: 2))
filterContext.draw(image, in: rect.insetBy(dx: 50, dy: 20))
Complete code:

struct SimpleCanvasExample: View {
    
    var body: some View {
        Canvas { context, size in
            
            let gradient = Gradient(colors: [.blue, .pink, .orange])
            let textGradient = Gradient(colors: [.white, .yellow, .white])
            let rect = CGRect(origin: .zero, size: size).insetBy(dx: 5, dy: 5)
            let linearGradient = GraphicsContext.Shading.linearGradient(gradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0))
            let textLinearGradient = GraphicsContext.Shading.linearGradient(textGradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0))
            
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            let font = Font.custom("Chalkduster", size: 54)
            
            var resolvedText = context.resolve(Text("DevTechie").font(font))
            resolvedText.shading = textLinearGradient
            
            let path = Path(ellipseIn: rect)
            context.stroke(
                path,
                with: .color(.orange),
                lineWidth: 10)
            context.fill(path, with: linearGradient)
            
            context.draw(resolvedText, at: midPoint, anchor: .center)
            context.blendMode = GraphicsContext.BlendMode.softLight
            
            
            let image = Image(systemName: "globe")
            var filterContext = context
            filterContext.addFilter(GraphicsContext.Filter.blur(radius: 5))
            filterContext.draw(image, in: rect.insetBy(dx: 50, dy: 20))
        }
        .frame(width: 300, height: 200)
    }
    
}
We can apply clip on context to clip content.

let circlePath = Path(ellipseIn: rect.insetBy(dx: 50, dy: 50))
filterContext.clip(to: circlePath)
Note the ellipse path that we are creating in circlePath constant and using that to clip the globe image.

struct SimpleCanvasExample: View {
    
    var body: some View {
        Canvas { context, size in
            
            let gradient = Gradient(colors: [.blue, .pink, .orange])
            let textGradient = Gradient(colors: [.white, .yellow, .white])
            let rect = CGRect(origin: .zero, size: size).insetBy(dx: 5, dy: 5)
            let linearGradient = GraphicsContext.Shading.linearGradient(gradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0))
            let textLinearGradient = GraphicsContext.Shading.linearGradient(textGradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0))
            
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            let font = Font.custom("Chalkduster", size: 54)
            
            var resolvedText = context.resolve(Text("DevTechie").font(font))
            resolvedText.shading = textLinearGradient
            
            let path = Path(ellipseIn: rect)
            context.stroke(
                path,
                with: .color(.orange),
                lineWidth: 10)
            context.fill(path, with: linearGradient)
            
            context.draw(resolvedText, at: midPoint, anchor: .center)
            context.blendMode = GraphicsContext.BlendMode.softLight
            
            
            let image = Image(systemName: "globe")
            var filterContext = context
            
            let circlePath = Path(ellipseIn: rect.insetBy(dx: 50, dy: 50))
            filterContext.clip(to: circlePath)
            
            filterContext.addFilter(GraphicsContext.Filter.blur(radius: 5))
            filterContext.draw(image, in: rect.insetBy(dx: 50, dy: 20))
        }
        .frame(width: 300, height: 200)
    }
    
}
Translation is another way to modify context:

filterContext.translateBy(x: 1.0, y: -50.0)
We will use it to move our globe image slightly up in y direction:

struct SimpleCanvasExample: View {
    
    var body: some View {
        Canvas { context, size in
            
            let gradient = Gradient(colors: [.blue, .pink, .orange])
            let textGradient = Gradient(colors: [.white, .yellow, .white])
            let rect = CGRect(origin: .zero, size: size).insetBy(dx: 5, dy: 5)
            let linearGradient = GraphicsContext.Shading.linearGradient(gradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0))
            let textLinearGradient = GraphicsContext.Shading.linearGradient(textGradient, startPoint: rect.origin, endPoint: CGPoint(x: rect.width, y: 0))
            
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            let font = Font.custom("Chalkduster", size: 54)
            
            var resolvedText = context.resolve(Text("DevTechie").font(font))
            resolvedText.shading = textLinearGradient
            
            let path = Path(ellipseIn: rect)
            context.stroke(
                path,
                with: .color(.orange),
                lineWidth: 10)
            context.fill(path, with: linearGradient)
            
            context.draw(resolvedText, at: midPoint, anchor: .center)
            context.blendMode = GraphicsContext.BlendMode.softLight
            
            
            let image = Image(systemName: "globe")
            var filterContext = context
            
            filterContext.translateBy(x: 1.0, y: -50.0)
            
            filterContext.addFilter(GraphicsContext.Filter.blur(radius: 5))
            filterContext.draw(image, in: rect.insetBy(dx: 50, dy: 50))
        }
        .frame(width: 300, height: 200)
    }
    
}
Other mutating factors can be scaleBy(x:y:), rotate(by:), concatenate(_:)

Canvas can use SwiftUI views with the help of symbols(not SF Symbols 😐). We use combination of GraphicsContext’s resolveSymbol and SwiftUI’s tag to reference SwiftUI Views.

Let’s start with a simple view:

struct CanvasSymbolView: View {
    var body: some View {
        HStack {
            Image(systemName: "arrow.forward.to.line")
            VStack {
                Image(systemName: "arrow.down.to.line")
                Text("DevTechie")
                Image(systemName: "arrow.up.to.line")
            }
            Image(systemName: "arrow.backward.to.line")
        }
        .foregroundColor(.indigo)
        .font(.largeTitle)
        .padding()
        .background {
            RoundedRectangle(cornerRadius: 20)
                .fill(LinearGradient(colors: [.blue, .teal, .cyan], startPoint: .top, endPoint: .bottom))
        }
    }
}
Next we will use this with canvas and resolve the view using resolveSymbol

struct SimpleCanvasExample: View {
    
    var body: some View {
        Canvas { context, size in
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            let resolvedView = context.resolveSymbol(id: 0)!
            context.draw(resolvedView, at: midPoint, anchor: .center)
        } symbols: {
            CanvasSymbolView()
                .tag(0)
        }
        .frame(width: 300, height: 200)
    }
    
}
Notice the use of resolveSymbol and tag ☝️

Looks simple but this gives us ability to put animated views inside canvas:

struct SimpleCanvasExample: View {
    
    var body: some View {
        Canvas { context, size in
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            let resolvedView = context.resolveSymbol(id: 0)!
            context.draw(resolvedView, at: midPoint, anchor: .center)
        } symbols: {
            CanvasSymbolView()
                .tag(0)
        }
    }
    
}struct CanvasSymbolView: View {
    
    @State private var animate = true
    
    var body: some View {
        HStack {
            Image(systemName: "arrow.forward.to.line")
            VStack {
                Image(systemName: "arrow.down.to.line")
                Text("DevTechie")
                Image(systemName: "arrow.up.to.line")
            }
            Image(systemName: "arrow.backward.to.line")
        }
        .foregroundColor(.indigo)
        .font(.largeTitle)
        .padding()
        .background {
            RoundedRectangle(cornerRadius: 20)
                .fill(LinearGradient(colors: [.blue, .teal, .cyan], startPoint: .top, endPoint: .bottom))
        }
        .rotationEffect(.degrees(animate ? 0 : 360))
        .onAppear {
            withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: false)) {
                animate.toggle()
            }
        }
    }
}

With that, we have reached the end of this article. Thank you once again for reading, if you liked it, don’t forget to subscribe our newsletter.