18.
Paths & Custom Shapes
Written by Caroline Begbie
In this chapter, you’ll become adept at creating custom shapes with which you’ll crop the photos. You’ll tap a photo on the card, which enables the Frames button. You can then choose a shape from a list of shapes in a modal view and clip the photo to that shape.
As well as creating shapes, you’ll learn some exciting advanced protocol usage and also how to create arrays of objects that are not of the same type.
The Starter Project
The starter project is the same as the challenge project from the previous chapter, with the exception of the preview data.
Shapes
Skills you’ll learn in this section: predefined shapes
➤ In the Model folder, create a new SwiftUI View file called Shapes.swift. This file will hold all your custom shapes.
➤ Remove the Shapes structure. You’ll preview your shapes using the SwiftUI canvas preview.
➤ Replace the preview with
#Preview {
VStack {
Rectangle()
RoundedRectangle(cornerRadius: 25.0)
Circle()
Capsule()
Ellipse()
}
.padding()
}
These are the five built-in shapes, which fill as much space as they can.
➤ Live Preview the view.
These shapes conform to the Shape protocol, which inherits from View. Using Shape, you can define any shape you want using paths.
Paths
Skills you’ll learn in this section: paths; lines; arcs; quadratic curves
This is the triangle shape you’ll draw first. You’ll create a path made up of lines that go from point to point.
Paths are simply abstract until you give them an outline stroke or a fill. SwiftUI defaults to filling paths with the primary color, unless you specify otherwise.
➤ At the end of Shapes.swift, add a new shape with this code:
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
return path
}
}
Shape has one required method which returns a Path. path(in:) receives a CGRect containing the drawing canvas size in which to draw the path.
Lines
➤ Create a triangle with the same coordinates as in the diagram above. Add this to path(in:) before return path:
// 1
path.move(to: CGPoint(x: 20, y: 30))
// 2
path.addLine(to: CGPoint(x: 130, y: 70))
path.addLine(to: CGPoint(x: 60, y: 140))
// 3
path.closeSubpath()
Going through the code:
- You create a new subpath by moving to a point. Paths can contain multiple subpaths.
- Add straight lines from the previous point. You can alternatively put the two points in an array and use
addLines(_:). - Close the subpath when you’ve finished to create the polygon.
➤ Change the SwiftUI preview to:
#Preview {
Triangle()
.background(Color.yellow)
}
Shapes fill as much space as they can. The filled path is using the fixed numbers from path(in:). But Triangle itself is filling the whole yellow area. Your code only replicates the triangle in the previous diagram when you add .frame(width: 150, height: 150) to currentShape.
If you want the triangle to retain its shape, but size itself to fill the available size, you must use relative coordinates, rather than absolute values.
➤ In Triangle, replace path(in:) with:
func path(in rect: CGRect) -> Path {
let width = rect.width
let height = rect.height
var path = Path()
path.addLines([
CGPoint(x: width * 0.13, y: height * 0.2),
CGPoint(x: width * 0.87, y: height * 0.47),
CGPoint(x: width * 0.4, y: height * 0.93)
])
path.closeSubpath()
return path
}
Here you use addLines(_:) with an array of points to make up the triangle. You replace the hard coded coordinates with relative ones that depend upon the width and height. You can calculate these coordinates by dividing the hard coded coordinate by the original frame size. For example, 20.0 / 150.0 comes out at about 0.13.
➤ Change the preview to:
#Preview(traits: .sizeThatFitsLayout) {
Triangle()
.aspectRatio(1, contentMode: .fit)
.background(Color.yellow)
}
You maintain the square aspect ratio, and the triangle will now resize to the available space.
PreviewTrait.sizeThatFitsLayout restricts the container to the size of the preview when you use the Selectable option in the SwiftUI design canvas.
➤ Switch Live Preview to Selectable.
The preview only shows the triangle in its container.
Arcs
Another useful path component is an arc.
➤ At the bottom of Shapes.swift, add this code to create a new shape:
struct Cone: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
// path code goes here
return path
}
}
Here you create a new shape in which you’ll describe a cone. To draw the cone, you’ll draw an arc and two straight lines.
➤ Add the arc to path(in:) before return path
let radius = min(rect.midX, rect.midY)
path.addArc(
center: CGPoint(x: rect.midX, y: rect.midY),
radius: radius,
startAngle: Angle(degrees: 0),
endAngle: Angle(degrees: 180),
clockwise: true)
Here you set the center point to be in the middle of the given rectangle with the radius set to the smaller of width or height.
➤ In #Preview, replace Triangle() with Cone().
Forget everything you thought you knew about the clockwise direction. In iOS, angles always start at zero on the right hand side, and clockwise is reversed.
This would be the result of an endAngle of 270º:
This is for historical reasons. In macOS, the origin — that’s coordinate (0, 0) — is at the bottom left, as in the standard Cartesian coordinate system. When iOS came out, Apple flipped the iOS drawing coordinate system on the Y axis so that (0, 0) is at the top left. However, much of the drawing code is based on the old macOS drawing coordinate system.
➤ In Cone’s path(in:), add two straight lines to complete the cone before the return:
path.addLine(to: CGPoint(x: rect.midX, y: rect.height))
path.addLine(to: CGPoint(x: rect.midX + radius, y: rect.midY))
path.closeSubpath()
You start the first line where the arc left off and end it at the middle bottom of the available space. The second line ends at the middle of the right hand side.
Curves
As well as lines and arcs, you can add various other standard elements to a path, such as rectangles and ellipses. With curves, you can create any custom shape you want.
➤ At the end of Shapes.swift, add this code to create a new shape:
struct Lens: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
// path code goes here
return path
}
}
The lens shape will consist of two quadratic curves, like an ellipse with a point at each end.
If you have used vector drawing applications, you’ll have used control points to draw curves. To create a quadratic curve in code, you set a start point, an end point and a control point that defines where the curve goes.
The two mid points shown are calculated and define the curvature. It can take some practice to work out the control point for the curve.
➤ In path(in:), add this code before the return:
path.move(to: CGPoint(x: 0, y: rect.midY))
path.addQuadCurve(
to: CGPoint(x: rect.width, y: rect.midY),
control: CGPoint(x: rect.midX, y: 0))
path.addQuadCurve(
to: CGPoint(x: 0, y: rect.midY),
control: CGPoint(x: rect.midX, y: rect.height))
path.closeSubpath()
The first curve here is the same as in the diagram above, and the second curve mirrors it.
➤ In #Preview, replace Cone() with Lens():
Strokes and Fills
Skills you’ll learn in this section: stroke; stroke style; fill
SwiftUI is currently filling the paths with a solid fill. You can specify the fill color or, alternatively, you can assign a stroke, which outlines the shape.
In the preview, add this modifier to Lens():
.stroke(lineWidth: 5)
You can only use stroke(_:) on objects conforming to Shape, so you must place the modifier directly after Lens().
Stroke Style
When you define a stroke, instead of giving it a lineWidth, you can give it a StrokeStyle instance.
For example:
Lens()
.stroke(style: StrokeStyle(dash: [30, 10]))
With a stroke style, you can define what the outline looks like — whether it is dashed, how the dash is formed and how the line ends look.
To form a dash, you create an array which defines the number of horizontal points of the filled section followed by the number of horizontal points of the empty section.
The example above describes a dashed line where you have a 5 point vertical line, followed by a 10 point space, followed by a one point vertical line, followed by a 5 point space.
This second example adds a dash phase, which moves the start of the dash to the right by 15 points, so that the dash starts with the one point line.
Swift tip: You haven’t done much animation so far as you’ll cover this later in Chapter 21, “Delightful UX — Final Touches”, but these dashed line parameters are animatable, so you can easily achieve the “marching ants” marquee look.
You can choose to change how the ends of lines look with the lineCap parameter:
lineCap: .square is similar to .butt, except that the ends protrude a bit further.
➤ In #Preview, replace .stroke(lineWidth:) with:
.stroke(
Color.primary,
style: StrokeStyle(lineWidth: 10, lineJoin: .round))
.padding()
Here you give the stroke an outline color and, using the lineJoin parameter, the two sections of lens shape are now nicely rounded at each side:
You’ve now created a few shapes and feel free to experiment with more. The challenge for this chapter suggests a few shapes for you to try.
Selecting an Element
Skills you’ll learn in this section: borders; clip shapes
As well as displaying a shape view, you can use a shape to clip another view. You’ll list all your shapes in a modal so that the user can select a photo element on the card and clip it to a chosen shape.
Before creating the modal, you’ll set up a property to hold the selected element. You could pass this element around as a binding, but in this case you’ll add it to CardStore as a published property. As you’ll see shortly, when this property changes, all views affected by the property will redraw.
➤ Open CardStore.swift and add the new property:
@Published var selectedElement: CardElement?
You’ll update this selected element when the user taps a card element.
When adding a selection property like this one, you should consider where best to place it. When listing and selecting cards, you added selectedCard to CardsListView. In that case, the selected card was only needed by one view, so it was an easy decision. If your app gets more complex, you may choose to move that selectedCard to either CardStore or a separate view state class as a published property, so that any view that uses selectedCard will redraw when the property changes. selectedElement will be used in several places, so it’s easier to place the property in CardStore.
➤ Open CardDetailView.swift and locate CardElementView(element: element). Add a new modifier to CardElementView(element:):
.onTapGesture {
store.selectedElement = element
}
When the user taps this element, you save it as the selected element.
➤ Add a new modifier to card.backgroundColor:
.onTapGesture {
store.selectedElement = nil
}
When the user taps the card background, the selection clears.
The user leaves the card by tapping Done, but you’ve defined the Done button in a different file, and it’s a good idea to keep similar code in close proximity.
➤ Add this new modifier to ZStack:
.onDisappear {
store.selectedElement = nil
}
When the user taps Done, CardDetailView disappears and performs this closure.
The user will want to know which element he’s selected, so you’ll add a border to the element.
➤ First, add a new method to CardDetailView to determine whether the current element is selected:
func isSelected(_ element: CardElement) -> Bool {
store.selectedElement?.id == element.id
}
This allows you to determine if a particular element is the currently selected element by comparing their ids.
➤ Add a new modifier to CardElementView(element:). Because you want the border to resize and reposition together with the element, this should be the first modifier in the list.
.border(
Settings.borderColor,
width: isSelected(element) ? Settings.borderWidth : 0)
All views have a border, but if the element is not currently selected, the line width of the border is 0.
➤ Test your selection and border in Live Preview. Tap the background to clear the selection.
You can only have one element selected at a time. When you update store.selectedElement, this affects the border of all element views. Because store.selectedElement is a published property, all views are redrawn with the correct border.
Clip Shapes Modal
Now that you can select an element, you’ll apply a clip shape selected from frames displayed on a modal view.
➤ In the Card Modal Views folder, create a new SwiftUI View file called FrameModal.swift. This will be very similar to StickerModal.swift, but will load your custom shapes instead of stickers into a grid.
First, set up an array of all your shapes for the modal to iterate through.
Open Shapes.swift and add a new enumeration to hold all the shapes:
enum Shapes {
}
Initially, you might think you can define the array in Shapes like this:
static let shapes: [Shape] = [Circle(), Rectangle()]
However, this will give you a compile error:
Use of protocol 'Shape' as a type must be written 'any Shape'
So, how can you solve this? Read on!
Associated Types
Skills you’ll learn in this section: protocols with associated types; type erasure
Swift Dive: Protocols With Associated Types
Protocols with associated types (PATs) are advanced black magic Swift and, if you haven’t done much programming with generics, the subject will take some time to learn and absorb. Apple APIs use them everywhere, so it’s useful to have an overview.
Shape inherits from View, and this is how View is defined:
public protocol View {
associatedtype Body : View
@ViewBuilder var body: Self.Body { get }
}
associatedType makes a protocol generic. When you create a structure that conforms to View, the requirement is that you have a body property, and you tell the View the real type to substitute. For example:
struct ContentView: View {
var body: some View {
EmptyView()
}
}
In this example, body is of type EmptyView.
Earlier, you created the protocol CardElement. This doesn’t use an associated type, and so you were able to set up an array of type CardElement. This is how you defined CardElement:
protocol CardElement {
var id: UUID { get }
var transform: Transform { get set }
}
All of the property types in CardElement are existential types. That means they are types in their own right and not generic. However, you might have a requirement for id to be either a UUID or an Int or a String. In that case you can define CardElement with a generic type of ID:
protocol CardElement {
associatedtype ID
var id: ID { get }
var transform: Transform { get set }
}
When you create a structure conforming to CardElement, you tell it what type ID actually is. For example:
struct NewElement: CardElement {
let id = Int.random(in: 0...1000)
var transform = Transform()
}
In this case, whereas the other CardElement ids are of type UUID, this id is of type Int.
Once a protocol has an associated type, because it is now a generic, the protocol is no longer an existential type. The protocol is constrained to using another type, and the compiler doesn’t have any information about what type it might actually be. For this reason, you can’t set up an array containing protocols with associated types, such as View or Shape.
Going back to the code at the start of this section which doesn’t compile:
static let shapes: [Shape] = [Circle(), Rectangle()]
Even though Circle and Rectangle both conform to Shape, they are Shapes with different associated types and, as such, you can’t put them both in the same Shape array.
Type Erasure
You are able to place different Views in an array by converting the View type to AnyView:
// does not compile
let views: [View] = [Text("Hi"), Image("giraffe")]
// does compile
let views: [AnyView] = [
AnyView(Text("Hi")),
AnyView(Image("giraffe"))
]
AnyView is a type-erased view. It takes in any type of view and passes back an existential, non-generic type of AnyView.
Similarly, Apple provides AnyShape.
A Type Erased Array
➤ In Shapes.swift, add a new property to Shapes:
static let shapes: [AnyShape] = [
AnyShape(Circle()), AnyShape(Rectangle()),
AnyShape(Cone()), AnyShape(Lens())
]
This holds a type-erased list of all your defined shapes. When you create more shapes, add them to this array.
Shape Selection Modal
Now that you have all your shapes in an array, you can create a selection modal, just as you did for your stickers.
➤ Open FrameModal.swift and replace FrameModal with:
struct FrameModal: View {
@Environment(\.dismiss) var dismiss
// 1
@Binding var frameIndex: Int?
private let columns = [
GridItem(.adaptive(minimum: 120), spacing: 10)
]
private let style = StrokeStyle(
lineWidth: 5,
lineJoin: .round)
var body: some View {
ScrollView {
LazyVGrid(columns: columns) {
// 2
ForEach(0..<Shapes.shapes.count, id: \.self) { index in
Shapes.shapes[index]
// 3
.stroke(Color.primary, style: style)
// 4
.fill(Color.secondary)
.frame(width: 100, height: 120)
.padding()
// 5
.onTapGesture {
frameIndex = index
dismiss()
}
}
}
}
.padding(5)
}
}
This is almost exactly the same code as you wrote for StickerModal. The exceptions are:
- Pass in an integer that will hold the index of the selected shape in the
Shapesarray. - Iterate through the array of shapes by index.
- Outline the shape with the primary color.
- Fill the shape so that you have a touch area. If you don’t fill the shape, the tap will only work on the stroke.
- When the user taps the shape, update
frameIndexand dismiss the modal.
➤ Change the preview to:
#Preview {
FrameModal(frameIndex: .constant(nil))
}
➤ Live Preview FrameModal to see all your shapes in a grid:
Adding the Frame Picker Modal to the Card
Skills you’ll learn in this section: missing environment objects; conditional modifiers; disabling button
➤ Open CardToolbar.swift and add two new properties to CardToolbar:
@EnvironmentObject var store: CardStore
@State private var frameIndex: Int?
You load the card store into the environment so you can access the selected element. You also define the index of the frame shape that you’ll pass to FrameModal.
Whenever you refer to an object in the environment, you have to ensure that object exists in the current environment, otherwise you’ll get errors further down the line. (Spoiler: A preview will crash in the next section.)
In #Preview, add a new modifier to Color.yellow after the custom modifier:
.environmentObject(CardStore(defaultData: true))
➤ In body(content:), locate .sheet(item:) and add a new case to the switch statement before the default case:
case .frameModal:
FrameModal(frameIndex: $frameIndex)
.onDisappear {
if let frameIndex {
card.update(
store.selectedElement,
frameIndex: frameIndex)
}
frameIndex = nil
}
Here you call the modal and update the card element with the frame index. As you haven’t written update(_:frameIndex:) yet, you’ll get a compile error.
Adding the Frame to the Card Element
➤ Open CardElement.swift in the Model folder and add a new property to ImageElement:
var frameIndex: Int?
This will hold the element’s frame index. You add it solely to ImageElement because the frame will clip only images, not text.
Note: If later, you create another type of element such as
ColorElement, to which you also want to be able to add clip frames, you could create a protocolClippable, withframeIndexas a required property. Instead of testing to see if an element is anImageElement, you can test to see whether the element isClippable.
➤ Open Card.swift and add the new update method to Card:
mutating func update(_ element: CardElement?, frameIndex: Int) {
guard element is ImageElement,
let index = element?.index(in: elements),
var imageElement = elements[index] as? ImageElement
else { return }
imageElement.frameIndex = frameIndex
elements[index] = imageElement
}
Here you pass in the element and the frame index. You first check that element is an ImageElement, as it makes no sense to assign a frame to text. TextElement doesn’t even have a frameIndex property.
ImageElement being a structure is a value type. When you pass selectedElement to the method, Swift makes a copy of the element. To update the correct element, you locate it in the elements array and make a copy, ensuring that it is of type ImageElement. You then update the frame index then finally update elements with this new instance.
All that’s left to do now is to clip the image element.
➤ Open CardDetailView.swift and locate CardElementView. Add a new modifier to CardElementView before border(_:width:):
.clipShape(Shapes.shapes[0])
The first element in Shapes.shapes is a circle.
➤ Live Preview the view:
Conditional Modifiers using @ViewBuilder
You applied .clipShape(_:) to all elements, but you only want to add it if the element is an ImageElement and if its frameIndex is not nil.
Surprisingly, it’s not easy to add a conditional modifier in SwiftUI. You can show Views conditionally using if {} else {}, and with simple conditions, you could show the same view with and without a modifier. However, when you have multiple modifiers on a view, this leads to heavily duplicated code.
In Chapter 9, “Refining Your App”, when you wanted to conditionally show a button shape, you created a method with a ViewBuilder attribute, and that’s what you’ll do here too.
➤ First, remove the previous code .clipShape(Shapes.shapes[0]) from CardDetailView.
➤ Open CardElementView.swift and at the end of the file, add a new extension:
// 1
private extension ImageElementView {
// 2
@ViewBuilder
func clip() -> some View {
// 3
if let frameIndex = element.frameIndex {
// 4
let shape = Shapes.shapes[frameIndex]
self
.clipShape(shape)
} else { self }
}
}
Going through the code:
- The modifier is specific to this view, so you create it as a private extension. Creating the extension on
ImageElementViewmeans that clipping will only apply to this type. Clipping a text element makes little sense. - The
ViewBuilderattribute allows you to build up views and combine them into one. Check out Chapter 9, “Refining Your App” if you need a refresher on how this works. - Use
if-letto get theframeIndex. - If there’s a value in
frameIndex, clip the view with the element’s frame shape. Otherwise, return the unmodified view.
➤ In CardElementView, add the new modifier to ImageElementView:
ImageElementView(element: element)
.clip()
➤ Open SingleCardView.swift and Live Preview the view.
As mentioned earlier, the SwiftUI canvas will probably crash with this error:
This is an easy fix. Even though you added CardStore to the environment in CardToolbar.swift, the current hierarchy of views starts with SingleCardView. So when CardToolbar shows up in the hierarchy, CardStore isn’t in the environment, causing the crash.
➤ In the preview add this to SingleCardView():
.environmentObject(CardStore(defaultData: true))
The preview will now work.
➤ Live Preview SingleCardView. Tap a giraffe and choose Frames. Select a frame and the giraffe photo gets clipped to that shape. The selection border is still rectangular, but you’ll fix that in the challenge at the end of this chapter.
When you tap the background near an unselected clipped image, but inside the area of the original unclipped image, SwiftUI still thinks you’re tapping the image.
➤ Open CardElementView.swift. In clip(), add this modifier after .clipShape(shape)
.contentShape(shape)
This will clip the tap area to the frame.
➤ In SingleCardView.swift, test the tap area on a photo by applying first a Cone frame and then a Lens frame.
The photo with the Cone frame now has a properly defined tap area. However, you can’t select the photo with the Lens frame at all.
Unfortunately, when using a quadratic curve in the path, contentShape(_:eoFill:) currently doesn’t calculate the hittable area correctly. The Lens shape uses a quadratic curve in its path, so the Lens frame doesn’t allow selection of the photo.
➤ To work around this, in CardElementView.swift, change the previous code, .contentShape(shape), to:
.contentShape(Ellipse())
Here you change the shape of the tap area to be an ellipse. The hit test is less accurate, but when you need to use quadratic curves in paths, this workaround is a good compromise.
Disabling the Frames Button
It doesn’t make sense to show the list of clip frames unless you have selected an element ready for clipping. So, until you select an element, the Frames button should be disabled.
➤ Open BottomToolbar.swift and add this new property to BottomToolbar:
@EnvironmentObject var store: CardStore
The bottom toolbar will need access to selectedElement to check whether it has a value.
➤ In the preview, add this modifier to BottomToolbar(card:modal:):
.environmentObject(CardStore())
Here you ensure that CardStore exists in the current environment.
In BottomToolbar, in body, you’ll duplicate the default button and use it for frameModal.
➤ To reduce code duplication, copy the default code into a new method:
func defaultButton(_ selection: ToolbarSelection) -> some View {
Button {
modal = selection
} label: {
ToolbarButton(modal: selection)
}
}
➤ In body, in switch selection, replace the entire default: condition with:
case .frameModal:
defaultButton(selection)
.disabled(
store.selectedElement == nil
|| !(store.selectedElement is ImageElement))
default:
defaultButton(selection)
By separating out frameModal, you can disable the button when there is no selected element. It also makes no sense to be able select clip frames on a TextElement, so you check that the selected element is an ImageElement.
In Live Preview, the Frames button is disabled, as CardStore is initialized by the preview.
➤ Return to SingleCardView.swift and check you can still add frames to selected image elements:
Challenges
Challenge 1: Create new Shapes
Practice creating new shapes and place them in the frame picker modal. Here are some suggestions:
The last two are a Polygon shape with a number of sides property, so try that out, and take a look at the code in the challenge folder.
Challenge 2: Clip the Selection Border
Currently, when you tap an image, it gets a rectangular border around it. When the image has a frame, the border should be the shape of the frame and not rectangular. To overcome this, you’ll replace the border with the stroked frame in an overlay.
- In CardDetailView.swift, add a new
Viewextension and create a new method similar toImageElementView.clip(). Pass in the element and whether the element is selected. Call itoverlay(element:isSelected). - In the new method, if the element is selected, when it is an image and has a frame, replace the border modifier with an overlay of the stroked frame. If the element is selected and doesn’t have a frame or is not an image, add a border modifier as before.
- In
CardDetailView, replace the border modifier with your new overlay.
Check your changes out by running the app or by Live Previewing SingleCardView.
Key Points
- The
Shapeprotocol provides an easy way to draw a 2D shape. There are some built-in shapes, such asRectangleandCircle, but you can create custom shapes by providing aPath. -
Paths are the outline of the 2D shape, made up of lines and curves. - A
Shapefills by default with the primary color. You can override this with thefill(_:style:)modifier to fill with a color or gradient. Instead of filling the shape, you can stroke it with thestroke(_:lineWidth:)modifier to outline the shape with a color or gradient. - With the
clipShape(_:style:)modifier, you can clip any view to a given shape. - Associated types in a protocol make a protocol generic, making the code reusable. Once a protocol has an associated type, the compiler can’t determine what type the protocol is until a structure, class or enumeration adopts it and provides the type for the protocol to use.
- Using type erasure, you can hide the type of an object. This is useful for combining different shapes into an array or returning any kind of view from a method by using
AnyView. - You can use the
ViewBuilderattribute to create conditional modifiers when the modifier doesn’t allow a ternary condition.