Day 43
Day 43 êŽë š
Project 9, part 1
Today kicks off another new technique project where weâll be focusing on drawing. This is an area of SwiftUI you might think you wonât need much, but nothing could be further from the truth: SwiftUI makes high-performance drawing so easy, so accessible to everyone, that youâll find places you can use your skills in almost every app you build.
Another thing that drawing is good for â and this will become more apparent in parts two and three of this project â is help create a sense of playfulness. As you work your way through these next few days youâll find you can create beautiful designs in only a handful of lines of code, and I lost countless hours while preparing my examples just playing around and having fun.
Donât take my word for it â renowned Dutch impressionist Vincent Van Gogh said, âI sometimes think there is nothing so delightful as drawing.â
I think he was on to something!
Anyway, I hope youâre able to approach these next few days with an open mind. Maybe you want to follow along with the coding (I hope you do!), or maybe you just want to sit back and see whatâs possible â either way, I think youâll be impressed by how smart SwiftUIâs drawing is!
Today you have four topics to work through, in which youâll learn about paths, shapes, insettable shapes, and more.
Drawing: Introduction
Drawing: Introduction
In this technique project weâre going to take a close look at drawing in SwiftUI, including creating custom paths and shapes, animating your changes, solving performance problems, and more â itâs a really big topic, and deserves close attention.
Behind the scenes, SwiftUI uses the same drawing system that we have on the rest of Appleâs frameworks: Core Animation and Metal. Most of the time Core Animation is responsible for our drawing, whether thatâs custom paths and shapes or UI elements such as TextField
, but when things really get complex we can move down to Metal â Appleâs low-level framework thatâs optimized for complex drawing. One of the neat features of SwiftUI is that these two are almost interchangeable: we can move from Core Animation to Metal with one small change.
Anyway, we have lots to cover so please create a new App project called Drawing and letâs dive inâŠ
Creating custom paths with SwiftUI
Creating custom paths with SwiftUI
SwiftUI gives us a dedicated Path
type for drawing custom shapes. Itâs very low level, by which I mean you will usually want to wrap it in something else in order for it to be more useful, but as itâs the building block that underlies other work weâll do weâre going to start there.
Just like colors, gradients, and shapes, paths are views in their own right. This means we can use them just like text views and images, although as youâll see itâs a bit clumsy.
Letâs start with a simple shape: drawing a triangle. There are a few ways of creating paths, including one that accepts a closure of drawing instructions. This closure must accept a single parameter, which is the path to draw into. I realize this can be a bit brain-bending at first, because weâre creating a path and inside the initializer for the path weâre getting passed the path to draw into, but think of it like this: SwiftUI is creating an empty path for us, then giving us the chance to add to it as much as we want.
Paths have lots of methods for creating shapes with squares, circles, arcs, and lines. For our triangle we need to move to a stating position, then add three lines like this:
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 100, y: 300))
path.addLine(to: CGPoint(x: 300, y: 300))
path.addLine(to: CGPoint(x: 200, y: 100))
}
We havenât used CGPoint
before, but I did sneak in a quick reference to CGSize
back in project 6. âCGâ is short for Core Graphics, which provides a selection of basic types that lets us reference X/Y coordinates (CGPoint
), widths and heights (CGSize
), and rectangular frames (CGRect
).
When our triangle code runs, youâll see a large black triangle. Where you see it relative to your screen depends on what simulator you are using, which is part of the problem of these raw paths: we need to use exact coordinates, so if you want to use a path by itself you either need to accept that sizing across all devices or use something like GeometryReader
to scale them relative to their container.
Weâll look at a better option shortly, but first letâs look at coloring our path. One option is to use the fill()
modifier, like this:
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 100, y: 300))
path.addLine(to: CGPoint(x: 300, y: 300))
path.addLine(to: CGPoint(x: 200, y: 100))
}
.fill(.blue)
We can also use the stroke()
modifier to draw around the path rather than filling it in:
.stroke(.blue, lineWidth: 10)
That doesnât look quite right, though â the bottom corners of our triangle are nice and sharp, but the top corner is broken. This happens because SwiftUI makes sure lines connect up neatly with what comes before and after rather than just being a series of individual lines, but our last line has nothing after it so thereâs no way to make a connection.
One way to fix this is ask SwiftUI to close the subpath, which is the shape weâve drawn inside our path:
Path { path in
path.move(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 100, y: 300))
path.addLine(to: CGPoint(x: 300, y: 300))
path.addLine(to: CGPoint(x: 200, y: 100))
path.closeSubpath()
}
.stroke(.blue, lineWidth: 10)
An alternative is to use SwiftUIâs dedicated StrokeStyle
struct, which gives us control over how every line should be connected to the line after it (line join) and how every line should be drawn when it ends without a connection after it (line cap). This is particularly useful because one of the options for join and cap is .round
, which creates gently rounded shapes:
.stroke(.blue, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
With that in place you can remove the call to path.closeSubpath()
, because itâs no longer needed.
Using rounded corners solves the problem of our rough edges, but it doesnât solve the problem of fixed coordinates. For that we need to move on from paths and look at something more complex: shapes.
Paths vs shapes in SwiftUI
Paths vs shapes in SwiftUI
SwiftUI enables custom drawing with two subtly different types: paths and shapes. A path is a series of drawing instructions such as âstart here, draw a line to here, then add a circle thereâ, all using absolute coordinates. In contrast, a shape has no idea where it will be used or how big it will be used, but instead will be asked to draw itself inside a given rectangle.
Helpfully, shapes are built using paths, so once you understand paths shapes are easy. Also, just like paths, colors, and gradients, shapes are views, which means we can use them alongside text views, images, and so on.
SwiftUI implements Shape
as a protocol with a single required method: given the following rectangle, what path do you want to draw? This will still create and return a path just like using a raw path directly, but because weâre handed the size the shape will be used at we know exactly how big to draw our path â we no longer need to rely on fixed coordinates.
For example, previously we created a triangle using a Path
, but we could wrap that in a shape to make sure it automatically takes up all the space available like this:
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
return path
}
}
That job is made much easier by CGRect
, which provides helpful properties such as minX
(the smallest X value in the rectangle), maxX
(the largest X value in the rectangle), and midX
(the mid-point between minX
and maxX
).
We could then create a red triangle at a precise size like this:
Triangle()
.fill(.red)
.frame(width: 300, height: 300)
Shapes also support the same StrokeStyle
parameter for creating more advanced strokes:
Triangle()
.stroke(.red, style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
.frame(width: 300, height: 300)
The key to understanding the difference between Path
and Shape
is reusability: paths are designed to do one specific thing, whereas shapes have the flexibility of drawing space and can also accept parameters to let us customize them further.
To demonstrate this, we could create an Arc
shape that accepts three parameters: start angle, end angle, and whether to draw the arc clockwise or not. This might seem simple enough, particularly because Path
has an addArc()
method, but as youâll see it has a couple of interesting quirks.
Letâs start with the simplest version of an arc shape:
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool
func path(in rect: CGRect) -> Path {
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: startAngle, endAngle: endAngle, clockwise: clockwise)
return path
}
}
We can now create an arc like this:
Arc(startAngle: .degrees(0), endAngle: .degrees(110), clockwise: true)
.stroke(.blue, lineWidth: 10)
.frame(width: 300, height: 300)
If you look at the preview of our arc, chances are it looks nothing like you expect. We asked for an arc from 0 degrees to 110 degrees with a clockwise rotation, but we appear to have been given an arc from 90 degrees to 200 degrees with a counterclockwise rotation.
Whatâs happening here is two-fold:
- In the eyes of SwiftUI 0 degrees is not straight upwards, but instead directly to the right.
- Shapes measure their coordinates from the bottom-left corner rather than the top-left corner, which means SwiftUI goes the other way around from one angle to the other. This is, in my not very humble opinion, extremely alien.
We can fix both of those problems with a new path(in:)
method that subtracts 90 degrees from the start and end angles, and also flips the direction so SwiftUI behaves the way nature intended:
func path(in rect: CGRect) -> Path {
let rotationAdjustment = Angle.degrees(90)
let modifiedStart = startAngle - rotationAdjustment
let modifiedEnd = endAngle - rotationAdjustment
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)
return path
}
Run that code and see what you think â to me it produces a much more natural way of working, and neatly isolates SwiftUIâs drawing behavior.
Adding strokeBorder()
support with InsettableShape
Adding strokeBorder() support with InsettableShape
If you create a shape without a specific size, it will automatically expand to occupy all available space. For example, this will create a circle that fills our view, giving it a 43-point blue border:
struct ContentView: View {
var body: some View {
Circle()
.stroke(.blue, lineWidth: 43)
}
}
Take a close look at the left and right edges of the border â do you notice how they are cut off?
What youâre seeing here is a side effect of the way SwiftUI draws borders around shapes. If you handed someone a pencil outline of a circle and asked them to draw over that circle with a thick pen, they would trace the exact line of the circle â about half the pen would be inside the line, and half outside. This is what SwiftUI is doing for us, but where our shapes go to the edge of the screen it means the outside part of the border ends up beyond our screen edges.
Now try using this circle instead:
Circle()
.strokeBorder(.blue, lineWidth: 43)
That changes stroke()
to strokeBorder()
and now we get a better result: all our border is visible, because Swift strokes the inside of the circle rather than centering on the line.
Previously we built an Arc
shape like this:
struct Arc: Shape {
var startAngle: Angle
var endAngle: Angle
var clockwise: Bool
func path(in rect: CGRect) -> Path {
let rotationAdjustment = Angle.degrees(90)
let modifiedStart = startAngle - rotationAdjustment
let modifiedEnd = endAngle - rotationAdjustment
var path = Path()
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)
return path
}
}
Just like Circle
, that automatically takes up all available space. However, this kind of code wonât work:
Arc(startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: true)
.strokeBorder(.blue, lineWidth: 43)
If you open Xcodeâs error message youâll see it says âValue of type 'Arc' has no member 'strokeBorderââ â that is, the strokeBorder()
modifier just doesnât exist on Arc
.
There is a small but important difference between SwiftUIâs Circle
and our Arc
: both conform to the Shape
protocol, but Circle
also conforms to a second protocol called InsettableShape
. This is a shape that can be inset â reduced inwards â by a certain amount to produce another shape. The inset shape it produces can be any other kind of insettable shape, but realistically it should be the same shape just in a smaller rectangle.
To make Arc
conform to InsettableShape
we need to add one extra method to it: inset(by:)
. This will be given the inset amount (half the line width of our stroke), and should return a new kind of insettable shape â in our instance that means we should create an inset arc. The problem is, we donât know the arcâs actual size, because path(in:)
hasnât been called yet.
It turns out the solution is pretty simple: if we give our Arc
shape a new insetAmount
property that defaults to 0, we can just add to that whenever inset(by:)
is called. Adding to the inset allows us to call inset(by:)
multiple times if needed, for example if we wanted to call it once by hand then use strokeBorder()
.
First, add this new property to Arc
:
var insetAmount = 0.0
Now give it this inset(by:)
method:
func inset(by amount: CGFloat) -> some InsettableShape {
var arc = self
arc.insetAmount += amount
return arc
}
Important: This is one of the very few places where we need to use CGFloat
, which is an archaic form of Double
that, somewhat bizarrely, wormed its way into SwiftUI. It gets used in many other places too, but mostly Swift lets us use Double
instead!
The amount
parameter being passed in should be applied to all edges, which in the case of arcs means we should use it to reduce our draw radius. So, change the addArc()
call inside path(in:)
to be this:
path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2 - insetAmount, startAngle: modifiedStart, endAngle: modifiedEnd, clockwise: !clockwise)
With that change we can now make Arc
conform to InsettableShape
like this:
struct Arc: InsettableShape {
}
Note: InsettableShape
actually builds upon Shape
, so thereâs no need to add both there.