Better performance with canvas in SwiftUI

Better performance with canvas in SwiftUI

It is said that use of Canvas to create complex shapes can provide better performance in SwiftUI. This article compares performance of scrolling through multiple instances of the same card pattern created using shape, canvas or image.



Create a card pattern using a Shape

All of the patterns are build using the Diamond shape created in Create patterns from basic shapes in SwiftUI. The DiamondShape conforms to the Shape protocol and implements the path function to create a diamond shape inside the containing rectangle. There is one correction to the original diamond shape, in the offsetPoint function, to adjust for the origin of the containing rectangle not being at (0,0).

 1struct DiamondShape: Shape {
 2    func path(in rect: CGRect) -> Path {
 3        let size = min(rect.width, rect.height)
 4        let xOffset = (rect.width > rect.height) ? (rect.width - rect.height) / 2.0 : 0.0
 5        let yOffset = (rect.height > rect.width) ? (rect.height - rect.width) / 2.0 : 0.0
 6        
 7        func offsetPoint(p: CGPoint) -> CGPoint {
 8            return CGPoint(x: p.x + xOffset + rect.origin.x, y: p.y+yOffset + rect.origin.y)
 9        }
10
11        let path = Path { path in
12            path.move(to: offsetPoint(p: CGPoint(x: 0, y: (size * 0.50))))
13            path.addQuadCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50), y: 0)),
14                              control: offsetPoint(p: CGPoint(x: (size * 0.40), y: (size * 0.40))))
15            path.addQuadCurve(to: offsetPoint(p: CGPoint(x: size, y: (size * 0.50))),
16                              control: offsetPoint(p: CGPoint(x: (size * 0.60), y: (size * 0.40))))
17            path.addQuadCurve(to: offsetPoint(p: CGPoint(x: (size * 0.50), y: size)),
18                              control: offsetPoint(p: CGPoint(x: (size * 0.60), y: (size * 0.60))))
19            path.addQuadCurve(to: offsetPoint(p: CGPoint(x: 0, y: (size * 0.50))),
20                              control: offsetPoint(p: CGPoint(x: (size * 0.40), y: (size * 0.60))))
21            path.closeSubpath()
22        }
23        return path
24    }
25}

The DiamondCardView is created by arranging rows and columns of the diamond shapes using vertical and horizontal stacks.

 1struct DiamondCardView: View {
 2    var rows :Int
 3    var cols: Int
 4    
 5    var body: some View {
 6        GeometryReader { gr in
 7            let width = gr.size.width / CGFloat(cols)
 8            let height = gr.size.height / CGFloat(rows)
 9            
10            VStack(spacing:0) {
11                ForEach(0..<rows) { i in
12                    let rowCols = (i%2==0) ? cols : cols - 1
13                    HStack(spacing:0) {
14                        Group {
15                            ForEach(0..<rowCols) { _ in
16                                DiamondShape()
17                                    .fill(Color(red: 250/255, green: 100/255, blue: 90/255))
18                                    .frame(width: width, height: height)
19                            }
20                        }
21                    }
22                }
23            }
24        }
25    }
26}

The card pattern is further enhanced by adding borders and rounding the corners.

 1struct SingleShapeView: View {
 2    var body: some View {
 3        ZStack {
 4            Color(red: 214/255, green: 232/255, blue: 248/255)
 5                .edgesIgnoringSafeArea(.all)
 6            
 7            VStack {
 8                Text("Card from Shape")
 9                    .font(.title)
10                    .fontWeight(.bold)
11                    .padding(.vertical, 30)
12                
13                Group {
14                    DiamondCardView(rows: 21, cols: 7)
15                        .padding(40)
16                        .frame(width: 250, height: 430)
17                        .background(Color(red: 30/255, green: 40/255, blue: 60/255))
18                        .cornerRadius(30)
19                        .overlay(RoundedRectangle(cornerRadius: 40)
20                                    .stroke(Color(red: 0.93, green: 0.94, blue: 0.77), lineWidth: 50)
21                                    .cornerRadius(30))
22                        .overlay(RoundedRectangle(cornerRadius: 20)
23                                    .stroke(Color(red: 250/255, green: 100/255, blue: 90/255), lineWidth: 20)
24                                    .cornerRadius(15)
25                                    .padding(25))
26                        .overlay(RoundedRectangle(cornerRadius: 30)
27                                    .stroke(.black, lineWidth: 2)
28                                .cornerRadius(30))
29                }
30                .rotationEffect(.degrees(10))
31                
32                Spacer()
33            }
34        }
35    }
36}

Define card pattern using Shapes



Create the card pattern using Canvas

As discussed in Using canvas in SwiftUI, the Canvas view provides a mechanism to draw in SwiftUI in an efficient manner. The canvas uses GraphicsContext and size to draw immediately within the containing rectangle. The same card pattern as above is created using a canvas to layout the individual diamond shapes. In addition, the same DiamondShape is used in the Canvas by accessing the path function of the shape and passing the containing rectangle.

 1struct SingleCanvasView: View {
 2    var body: some View {
 3        ZStack {
 4            Color(red: 214/255, green: 232/255, blue: 248/255)
 5                .edgesIgnoringSafeArea(.all)
 6            
 7            VStack {
 8                Text("Card from Canvas")
 9                    .font(.title)
10                    .fontWeight(.bold)
11                    .padding(.vertical, 30)
12                
13                Canvas { context, size in
14                    let w = size.width
15                    let h = size.height
16                    let rows = 21
17                    let cols = 7
18                    
19                    for r in 0..<rows {
20                        for c in 0..<cols {
21                            let x = w/CGFloat(cols) * CGFloat(c)
22                            let y = h/CGFloat(rows) * CGFloat(r)
23                            context.fill(
24                                DiamondShape().path(in: CGRect(
25                                    x: x,
26                                    y: y,
27                                    width: w/CGFloat(cols),
28                                    height: h/CGFloat(rows))),
29                                with: .color(red: 250/255, green: 100/255, blue: 90/255))
30                        }
31                    }
32                }
33                .padding(40)
34                .background(Color(red: 30/255, green: 40/255, blue: 60/255))
35                .frame(width: 250, height: 430)
36                .cornerRadius(30)
37                .overlay(RoundedRectangle(cornerRadius: 40)
38                            .stroke(Color(red: 0.93, green: 0.94, blue: 0.77), lineWidth: 50)
39                            .cornerRadius(30))
40                .overlay(RoundedRectangle(cornerRadius: 20)
41                            .stroke(Color(red: 250/255, green: 100/255, blue: 90/255), lineWidth: 20)
42                            .cornerRadius(15)
43                            .padding(25))
44                .overlay(RoundedRectangle(cornerRadius: 30)
45                            .stroke(.black, lineWidth: 2)
46                            .cornerRadius(30))
47                .rotationEffect(.degrees(10))
48                
49                Spacer()
50            }
51        }
52    }
53}

Define card pattern using Canvas



ScrollView with cards from Shape

Individually, the above views display OK and there appears to be no performance differences. A ScrollView is created containing 50 of the DiamondCardView to stress out the system and look for performance issues.

 1struct MultipleShapeView: View {
 2    var body: some View {
 3        ZStack {
 4            Color(red: 214/255, green: 232/255, blue: 248/255)
 5                .edgesIgnoringSafeArea(.all)
 6            
 7            ScrollView {
 8                ForEach(1...50, id: \.self) { _ in
 9                    CardShapeView()
10                }
11            }
12            .navigationTitle("50 Shape Cards")
13        }
14    }
15}
16
17
18struct CardShapeView: View {
19    var body: some View {
20        DiamondCardView(rows: 7, cols: 21)
21            .padding(30)
22            .frame(width: 300, height: 200)
23            .background(Color(red: 30/255, green: 40/255, blue: 60/255))
24            .cornerRadius(30)
25            .overlay(RoundedRectangle(cornerRadius: 40)
26                        .stroke(Color(red: 0.93, green: 0.94, blue: 0.77), lineWidth: 50)
27                        .cornerRadius(30))
28            .overlay(RoundedRectangle(cornerRadius: 20)
29                        .stroke(Color(red: 250/255, green: 100/255, blue: 90/255), lineWidth: 10)
30                        .cornerRadius(15)
31                        .padding(20))
32            .overlay(RoundedRectangle(cornerRadius: 30)
33                        .stroke(.black, lineWidth: 2)
34                        .cornerRadius(30))
35    }
36}

Multiple card pattern defined using using Shape



ScrollView with cards from Canvas

A similar ScrollView is created containing 50 of the CardCanvasView to stress out the system and look for performance issues.

 1struct MultipleCanvasView: View {
 2    var body: some View {
 3        ZStack {
 4            Color(red: 214/255, green: 232/255, blue: 248/255)
 5                .edgesIgnoringSafeArea(.all)
 6            
 7            ScrollView {
 8                ForEach(1...50, id: \.self) { i in
 9                    CardCanvasView()
10                }
11            }
12            .navigationTitle("50 Canvas Card")
13        }
14    }
15}
16
17
18struct CardCanvasView: View {
19    var body: some View {
20        VStack {
21            Canvas { context, size in
22                let w = size.width
23                let h = size.height
24                let rows = 7
25                let cols = 21
26                
27                for r in 0..<rows {
28                    for c in 0..<cols {
29                        let x = w/CGFloat(cols) * CGFloat(c)
30                        let y = h/CGFloat(rows) * CGFloat(r)
31                        context.fill(
32                            DiamondShape().path(in: CGRect(x: x, y: y, width: w/CGFloat(cols), height: h/CGFloat(rows))),
33                            with: .color(red: 250/255, green: 100/255, blue: 90/255))
34                    }
35                }
36            }
37            .padding(30)
38            .frame(width: 300, height: 200)
39            .background(Color(red: 30/255, green: 40/255, blue: 60/255))
40            .cornerRadius(30)
41            .overlay(RoundedRectangle(cornerRadius: 40)
42                        .stroke(Color(red: 0.93, green: 0.94, blue: 0.77), lineWidth: 50)
43                        .cornerRadius(30))
44            .overlay(RoundedRectangle(cornerRadius: 20)
45                        .stroke(Color(red: 250/255, green: 100/255, blue: 90/255), lineWidth: 10)
46                        .cornerRadius(15)
47                        .padding(20))
48            .overlay(RoundedRectangle(cornerRadius: 30)
49                        .stroke(.black, lineWidth: 2)
50                        .cornerRadius(30))
51        }
52    }
53}

Multiple card pattern defined using Canvas



ScrollView with cards from Image

Finally, a ScrollView is created containing 50 images for the cards to compare performance to that of using the shapes and the canvas. The image is created from a screenshot of one of the cards above and added to the assets.

 1struct MultipleImageView: View {
 2    var body: some View {
 3        ZStack {
 4            Color(red: 214/255, green: 232/255, blue: 248/255)
 5                .edgesIgnoringSafeArea(.all)
 6            
 7            ScrollView {
 8                ForEach(1...50, id: \.self) { _ in
 9                    Image("card-background")
10                        .resizable()
11                        .scaledToFill()
12                        .clipShape(RoundedRectangle(cornerRadius: 40))
13                        .frame(width: 310, height: 200)
14                }
15            }
16            .navigationTitle("50 Image Card")
17        }
18    }
19}

Card pattern image

Multiple card with image as background



Review performance

There are a number of ways of measuring performance, including automated performance tests, which I'll dig into in another article. In this app we'll just run the application and look at the CPU and Memory usage in Xcode. It can be seen that the ScrollView with the 50 cards created using shapes for each diamond and laying them out in Stacks uses significantly more memory to load. It also uses significantly more CPU when scrolling the view up and down. The cards created with canvas use significantly less memory to load and less CPU usage when scrolling up and down. Using an image for the cards performs the best, but only slightly better than use of canvas.

Increased memory and CPU usage with card pattern created with Shape



Increased memory usage with card pattern created with Shape




Conclusion

A ScrollView containing 50 instances of the same card may not be the most realistic scenario, but it illustrates the point of performance differences in creating these views. Using the canvas to layout multiple shapes such as diamonds results in much better performance than laying out multiple shapes in a view. This can be seen in the responsiveness of the scrolling and the memory and CPU usage. If these shape in the app are static, then use of an image provides slightly better performance than use of canvas. The use of canvas performed almost as well as the use of images, so if the content needs to be dynamic, then a canvas maybe the best option.