13.
Drawing & Custom Graphics
Written by Bill Morefield
As you begin to develop more complex apps, you’ll find that you need more flexibility or flash than the built-in controls of SwiftUI offer. Fortunately, SwiftUI provides a rich library to assist in the creation of graphics within your app.
Graphics convey information to the user efficiently and understandably; for instance, you can augment text that takes time to read with graphics that summarizes the same information.
In this chapter, you’ll explore the use of graphics in SwiftUI by creating several award graphics for the airport app.
Creating shapes
Open the starter project for this chapter; build and run the project in Xcode and you’ll see an early, in-progress app for a small airport, showing flight boards for arrivals and departures. These function as the in-app equivalent to the large-screen displays that show flights arriving and leaving from the airport.
You’ll also see a page to display a user’s award badges. In this chapter, you’ll create three initial awards. The first badge you’ll create is awarded the first time someone comes to the airport, and will look like this when you’re done:
First up, create a new SwiftUI View named FirstVisitAward.swift. Then, open the new file and, if the preview doesn’t show, select Editor ▸ Editor and Canvas to show it. The preview view will make the iterative process of creating drawings and animations much easier.
Open AirportAwards.swift and replace the view code with the following to add it to the view:
VStack {
ScrollView {
FirstVisitAward()
.frame(width: 250, height: 250)
Text("First Visit")
}
}
.navigationBarTitle("Your Awards")
One of the basic drawing structures in SwiftUI is the Shape, which is a set of simple primitives you use to build up more complex drawings.
First, you’ll need to add a rectangle shape to the SwiftUI view. To do this, replace the default Text from the view template’s body with the following code:
Rectangle()
The preview is a little underwhelming since all you have is a black rectangle that fills the screen. By default, a shape in SwiftUI fills the entirety of its container, but you can specify a smaller container for the preview.
Add the following line below Rectangle() in the view to set its size:
.frame(width: 200, height: 200)
You will now see a black square, 200 points on each side. in the middle of the view.
This view demonstrates a few defaults that apply when drawing in SwiftUI. If you don’t make an explicit fill or stroke call, the shape will fill with the current foreground color, which is Color.primary. You’ll get one color for Color.primary when your app runs in light mode, and a different color for that same variable when running in dark mode. Although that looks good in light mode, it’s a good idea to always consider how your drawings will appear under dark mode.
Below the frame method in the preview, add the following line:
.environment(\.colorScheme, .dark)
In dark mode, Color.primary is white, so now you should see a white square against the black background on the Canvas. But you won’t. The square turns white, but because of a bug still present in Xcode 11.4, the background doesn’t change color. As a workaround, you need to wrap the preview inside a NavigationView. Change the code for the preview so you can preview both light and dark mode as follows:
struct FirstVisitAward_Previews: PreviewProvider {
static var previews: some View {
Group {
FirstVisitAward()
.environment(\.colorScheme, .light)
NavigationView {
FirstVisitAward()
.environment(\.colorScheme, .dark)
}
}
}
}
Then, remove .environment(\.colorScheme, .dark) from the body again. You should now see two previews; one with light mode and one with dark mode.
It’s easy to change the color of the fill. Back in your view, add the following between the Rectangle() and frame(width:height:) lines:
.fill(Color.blue)
Build
Providing a color overrides the default: The square fills with blue in both light and dark modes. Note that order matters here, as you must call fill before the frame. You could also use the border(_:width:) to outline the shape instead of filling it.
Using gradients
A solid color fill works well for many cases, but for this badge, you’ll use a gradient fill instead to provide a smooth transition between two or more colors.
Replace the current solid color fill(_) with:
.fill(
LinearGradient(gradient: .init(colors: [Color.green, Color.blue]),
startPoint: .bottomLeading,
endPoint: .topTrailing
))
A linear gradient provides a smooth transition between colors along a straight line through the object. The values for startPoint and endPoint use a UnitPoint struct. This struct scales a range of values into a zero to one range, which makes it easier to define a range without needing to worry about the exact values.
UnitPoints origin coordinate is at (0, 0) in the top-left corner and increases to the right and downward. You define the start point of the transition to be the bottom left corner, and the endpoint of the transition to be at the top right corner.
A linear gradient does not limit you to a zero to one range, nor do you have to set the endpoints of a gradient to zero or one. You can define the start and endpoints anywhere you wish, even outside of the view, and the gradient will adjust. Note that these points signify not the end of the color, but instead the end of the transition between the colors. The colors continue past these points, carrying on with the corresponding end color.
For this badge, you’ll also need to apply a rotation. If you look at the original shape, you’ll see the background consists of three squares, each rotated 60 degrees counterclockwise from the preceding one.
Rotating shapes
You could repeat the code to draw the square three times, and rotate two of the shapes. However, SwiftUI provides a more general way to do this — the ForEach() method.
Replace the body of the view with:
// 1
ZStack {
// 2
ForEach(0..<3) { i in
Rectangle()
.fill(
LinearGradient(gradient: .init(colors: [Color.green,
Color.blue]),
startPoint: .bottomLeading,
endPoint: .topTrailing)
)
.frame(width: 200, height: 200)
// 3
.rotationEffect(.degrees(Double(i) * 60.0))
}
}
Here’s what the new code does:
- You first create a
ZStackto hold the three squares. AZStackoverlays its contents and aligns them on both axes. Here, it will make the squares appear stacked. - You use
ForEachto loop through a set. The set consists of the numbers zero, one and two. Each time through the loop, the variableigets the current loop value. - The rectangle code doesn’t change; you simply apply a rotation effect to the shape using the
.degreesspecifier for the angle. Each time through the loop, the rotation increases by 60 degrees. Note that the effects to the rectangle — a fill, a frame and a rotation — will be applied in the order specified.
The next step is to add the airplane.
Adding images
Mixing prebuilt images with your drawings can save a lot of time and work. The airplane image for this award is from the new set of SF Symbols in iOS 13. Add the following code after the ForEach loop:
Image(systemName: "airplane")
There are a few things to fix here. First, you applied the frame to only the rectangle, so it doesn’t affect the size of the image. Instead, the image shows at its default size.
Creating the rectangle with a specified size makes it more difficult to work with your image. A better option would be to adapt the view for any size by filling the frame it’s displayed in. This means you can use the view anywhere in your app and it will stay responsive.
Remove the frame modifier from the rectangle. Then, move the frame down to previews and it will place a frame on the preview. Change your preview code to:
Group {
FirstVisitAward()
.environment(\.colorScheme, .light)
.frame(width: 200, height: 200)
FirstVisitAward()
.environment(\.colorScheme, .dark)
.frame(width: 200, height: 200)
}
The rectangle still looks good, but you still need to fix the airplane image. Add the following modifier at the end of the image:
.resizable()
This call tells SwiftUI to resize the image to fill the frame. Finally, add the following code to the image to match the award’s design:
.rotationEffect(.degrees(-90))
.opacity(0.5)
This rotates the airplane to point upward and fades it out so that some of the background shows through. Beautiful!
Scaling drawings in views
The badge looks pretty good right now, but there’s a subtle bug you might not have noticed. To see it, you’ll need to add the award to a view.
Build and run, go to Awards and you’ll see the problem. The rotated squares bleed outside of the frame and into the title above and text below. The rotation you applied doesn’t scale to remain inside the frame; instead, the view part of the award clips and a part of the view bleeds into the text. To fix this, you need to size the squares in the award so the rotated shapes fit inside the frame.
Go back to FirstVisitAward.swift. Replace the contents of the view with:
// 1
GeometryReader { geometry in
ZStack {
ForEach(0..<3) { i in
Rectangle()
.fill(
LinearGradient(
gradient: .init(colors: [Color.green, Color.blue]),
startPoint: .bottomLeading,
endPoint: .topTrailing)
)
// 2
.frame(width: geometry.size.width * 0.7,
height: geometry.size.width * 0.7)
.rotationEffect(.degrees(Double(i) * 60.0))
}
Image(systemName: "airplane")
.resizable().rotationEffect(.degrees(-90))
.opacity(0.5)
// 3
.scaleEffect(0.7)
}
}
Here’s what you just changed:
-
The
GeometryReadercontainer provides a way to get the size and shape of a view from within it. This lets you write code without relying on constants. -
You use the size property of the
geometryinstance to get the width and height of the view. You multiply both by 0.7 to scale down the squares so they will fit inside the frame after they’re rotated. You could calculate this scaling factor with trigonometry, or you can also simply try values until you get the look you’re going for. The beauty of SwiftUI preview is that you can make changes and see the results immediately without the compile–run loop. This makes it easy to tweak the value until you get the desired result. -
You also need to scale the image the same amount that you scaled the squares. You do this with the
.scaleEffect()call on the image.
Build and run, view the award on the Airport Awards view and you will see that the award now fits into the view. Smart, right?
Other shapes
You only used one shape for this first award, but SwiftUI provides several more shapes:
- Circle: The circle’s radius will be half the length of the framing rectangle’s smallest edge.
- Ellipse: The ellipse will align inside the frame of the view containing it.
- Rounded Rectangle: A rectangle, but with rounded corners instead of sharp corners. It draws within the containing frame.
- Capsule: A capsule shape is a rounded rectangle where the corner radius is half the length of the rectangle’s smallest edge.
All scale and other effects work within the framing view just as the rectangle does. You’ll find that combining these shapes can often produce complex results. For more complex drawings, you can use Paths.
Exercise: Try replacing the rectangle in the award with another shape and notice the results. Make sure to change it back before continuing.
Drawing lines with paths
Sometimes you want to define your own shape, and not use the built-in ones. For this, you use Paths, which allow you to define a shape by combining individual segments. These segments make up the outline of a two-dimensional shape. You’ll create your next award using paths.
First up, create a new SwiftUI View named OverNightParkAward.swift inside the MountainAirport group.
Add this new view to the awards view. Open AirportAwards.swift and add the following code below the first award and its text:
OverNightParkAward()
.frame(width: 250, height: 250)
Text("Left Car Overnight")
The simplest element that you can add to a path is the line. This award uses lines to draw a road.

Go back to **OverNightParkAward.swift**. First up, update the new view’s `body` to:
```swift
Path { path in
path.move(to: CGPoint(x: 120, y: 20))
path.addLine(to: .init(x: 180, y: 180))
path.addLine(to: .init(x: 20, y: 180))
path.addLine(to: .init(x: 80, y: 20))
}
Path creates an enclosure you use to build the path. The initial move(to:) call sets the starting location for the path; a move(to:) call moves the current position but doesn’t add anything to the path. You next add three lines to create a polygon that is narrow at the top and widens toward the bottom. This gives a pseudo-3D effect of a road going off into the distance. Notice that you don’t have to close the path by adding a line back to the initial point — this is handled for you automatically.
Note: Using constant values limits the flexibility of your view. When you can, design drawings to adapt to the size of the frame instead of hard-coding values.
First up, add the following code at the bottom of previews:
.frame(width: 200, height: 200)
Then, in the body of OverNightParkAward add a GeometryReader replacing the body so it looks like the following:
GeometryReader { geometry in
Path { path in
let size = min(geometry.size.width, geometry.size.height)
let nearLine = size * 0.1
let farLine = size * 0.9
path.move(to: CGPoint(x: size/2 + nearLine, y: nearLine))
path.addLine(to: .init(x: farLine, y: farLine))
path.addLine(to: .init(x: nearLine, y: farLine))
path.addLine(to: .init(x: size/2 - nearLine, y: nearLine))
}
}
Inside of a path, you have more flexibility with adding calculated values than in most view code. These calculations let you produce more frame-independent code; wrapping the Path inside a GeometryReader lets you adapt the path to the frame.
The first three lines determine a size as the smaller dimension between the width and height. You then define a near and far value based on the size. Again, instead of using constant numbers, you can define the path using these relative values. If you had 200 as the size, you would end up with the original constant numbers. You’ll see that the result didn’t change.
If the frame changes, the size of the drawing will adapt.
Add the following code after the path to change the color to a dark gray, which makes your image look more like a road. Here, you use the Color.init(_:red:green:blue:opacity:) method to define the custom color.
.fill(Color.init(red: 0.4, green: 0.4, blue: 0.4))
Drawing dashed lines
Next, you’ll add a dashed white line down the middle of the road. First, wrap the current Path inside a ZStack like so:
GeometryReader { geometry in
ZStack {
// Your current Path view
// ...
}
}
Then, right below your first path, add the following new Path:
Path { path in
let size = min(geometry.size.width, geometry.size.height)
let nearLine = size * 0.1
let farLine = size * 0.9
let middle = size / 2
path.move(to: .init(x: middle, y: farLine))
path.addLine(to: .init(x: middle, y: nearLine))
}
.stroke(Color.white,
style: .init(lineWidth: 3.0,
dash: [geometry.size.height / 20,
geometry.size.height / 30],
dashPhase: 0))
Much of the code here is the same as before. You once again define a variable with the coordinate of the middle of the view. Instead of using a fill, you tell SwiftUI to stroke the path as opposed to filling the path.
You set the color of the line to white, and you also define a custom line style to replace the default line that is solid, black, and one point wide. To make the dashed center line stand out, you set its width to three points, and you calculate the length of each dash as the ratio of the height of the view.
Note that the variables defined inside the path are no longer available as they are no longer in scope.
One more touch: adding the car. Add the following code below the two Paths:
Image(systemName: "car.fill")
.resizable()
.foregroundColor(Color.blue)
.scaleEffect(0.20)
.offset(x: -geometry.size.width / 7.25)
You use a SF Symbol image for the car and scale it to fit a lane of the road. An offset shifts the image from the center. Again, you define the amount in proportion to the size of the frame so the car appears centered in the right lane.
Build and run the app, go to Awards and you should see your two stylish awards.

## Drawing arcs and curves
Paths offer more flexibility than drawing lines. You’ll find a wide range of options, including shapes that are better suited for drawing curved objects. You’ll create the next award using arcs and quadratic curves.

As with the previous awards, start by creating a new **SwiftUI View** and name it **AirportMealAward.swift**.
Now, open **AirportAwards.swift** and add the following to the end of the view to add it to the collection of awards:
```swift
AirportMealAward()
.frame(width: 250, height: 250)
Text("Ate Meal at Airport")
Go back to AirportMealAward.swift, then add a frame to the preview:
.frame(width: 200, height: 200)
And replace the body of the view with some familiar code:
GeometryReader { geometry in
ZStack {
Path { path in
let size = min(geometry.size.width, geometry.size.height)
let nearLine = size * 0.1
let farLine = size * 0.9
let mid = size / 2
}
}
}
This creates a GeometryReader, a ZStack and a Path. You again calculate the locations you’ll use to draw the path independent of the size of the frame. You also calculate the middle of the view for later use.
Drawing quadratic curves
The name of a quadratic curve comes from its definition by following the line plot of a quadratic math equation. SwiftUI handles the math part (phew!) so you can simply think of a quadratic curve as an elastic line pulled toward a third point, known as the control point. At each end, the curve starts parallel to a line drawn to the control point and curves smoothly between all points.
You will define these curves between the middle of each side and the middle of the adjacent side. You’ll place the control point in the corner between the midpoints to bend the curve outward toward the corner.
Add the following code below mid in the path:
path.move(to: .init(x: mid, y: nearLine))
path.addQuadCurve(
to: .init(x: farLine, y: mid),
control: .init(x: size, y: 0))
path.addQuadCurve(
to: .init(x: mid, y: farLine),
control: .init(x: size, y: size))
path.addQuadCurve(
to: .init(x: nearLine, y: mid),
control: .init(x: 0, y: size))
path.addQuadCurve(
to: .init(x: mid, y: nearLine),
control: .init(x: 0, y: 0))
Here you add four quadratic curves that result in a single shape.
You will now change the path to use a radial gradient, which starts at a central point and transitions outward from that point. You define the central point of the gradient, as well as the distances at which the transition begins and ends.
Add the following modifier to the path:
.fill(
RadialGradient(
gradient: .init(colors: [Color.white, Color.yellow]),
center: .center,
startRadius: geometry.size.width * 0.05,
endRadius: geometry.size.width * 0.6)
)
You’ll now see a transition start near the center and extend just past halfway toward the edge of the shape, fading from white to yellow. Again, you’re using UnitCoordinate to specify the center point. A value of 0.5 puts the center point of the gradient at the center of the view, not the path.
In this case, the path and view are the same, but that’s not always the case. The start and end radius of the transition are not UnitCoordinates, since you don’t have access to the calculations inside the path.
For the next part of your award, you’ll add arcs that resemble the functional and decorative scores on a loaf of fancy bread. Add the following code after the existing path:
Path { path in
let size = min(geometry.size.width, geometry.size.height)
let nearLine = size * 0.1
let farLine = size * 0.9
path.addArc(center: .init(x: nearLine, y: nearLine),
radius: size / 2,
startAngle: .degrees(90),
endAngle: .degrees(0),
clockwise: true)
path.addArc(center: .init(x: farLine, y: nearLine),
radius: size / 2,
startAngle: .degrees(180),
endAngle: .degrees(90),
clockwise: true)
path.addArc(center: .init(x: farLine, y: farLine),
radius: size / 2,
startAngle: .degrees(270),
endAngle: .degrees(180),
clockwise: true)
path.addArc(center: .init(x: nearLine, y: farLine),
radius: size / 2,
startAngle: .degrees(0),
endAngle: .degrees(270),
clockwise: true)
path.closeSubpath()
}
.stroke(Color.orange, lineWidth: 2)
The addArc method adds a partial circle to a path; you specify the center of a circle and its radius. A full circle makes a complete sweep through 360 degrees. Since an arc is a partial circle, you specify what part of the full arc that SwiftUI should draw. In this code, you draw a 90-degree sweep of each circle. You also specify the direction the arc should draw, between the starting and ending angles.
Beautiful, right!?
Build and run, go to Awards, and you should now see three beautiful awards, custom-built using SwiftUI.
Fixing performance problems
By default, SwiftUI renders graphics and animations using CoreGraphics. SwiftUI draws each view individually on the screen when needed. The processor and graphics hardware inside modern Apple devices are powerful and can handle many views without the user seeing a slowdown. At some point, however, you can overload the system and see performance drop off to the point a user notices, and your app seems sluggish.
If this occurs, you can use the drawingGroup() modifier on your view. This modifier tells SwiftUI to combine the view’s contents into an offscreen image before the final display.
This offscreen composition uses Metal, Apple’s high-performance graphics framework, resulting in an impressive speedup in the rendering of complex views. Note that offscreen composition adds overheard and results in slower performance for simple graphics. Using a large number of gradients, shadows and other effects to your drawings will most likely result in performance problems.
Wait until you have a performance problem before turning to drawingGroup(). Keep in mind that the drawingGroup() modifier only works for graphics — shapes, images, text, etc.
Key points
- Shapes provide a quick way to draw simple controls. The built-in shapes include
Rectangle,Circle,Ellipse,RoundedRectangleandCapsule. - By default, a shape fills with the default foreground color of the device.
- Shapes can be filled with solid colors or with a defined gradient.
- Gradients can transition in a linear, radial, or angular manner.
-
rotationEffectwill rotate a shape around its axis. -
ZStackwill let you combine graphics so they share a common axis. You can mix drawn graphics and images. -
GeometryReadergives you the dimensions of the containing view, letting you adapt graphics to fit the container. -
Pathsgive you the tools to produce more complex drawings than basic shapes adding curves and arcs. - You can modify the shapes and fill on paths as you do with shapes.
- The
drawingGroup()can improve performance of graphics-heavy views, but should only be added when performance problems appear as it can slow rendering of simple graphics.
Where to go from here?
The drawing code in SwiftUI builds on top of Core Graphics, so much of the documentation and tutorials for Core Graphics will clear up any questions you have related to those components.
The SwiftUI Drawing and Animation documentation at https://developer.apple.com/documentation/swiftui/drawing_and_animation documents changes in SwiftUI compared to Apple’s graphics libraries.
The WWDC 2019 session Building Custom Views with SwiftUI at https://developer.apple.com/videos/play/wwdc2019/237/ provides more examples of layout and graphics. It also shows an example of using the drawingGroup() modifier.
The classic text Computer Graphics: Principles and Practice by John F. Hughes, et al. provides a very nice overview of most graphics topics when you need to build graphics beyond the frameworks that Apple provides.
The following two chapters will continue to build on this project, by adding animations and showing you more ways to build views designed for reuse. See you there!