Chapters

Hide chapters

SwiftUI Apprentice

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

Section I: Your first app: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your second app: Cards

Section 2: 9 chapters
Show chapters Hide chapters

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 moves on from hedgehogs to giraffes.

A new published property in ViewState, called selectedElement, holds the currently selected element. In CardDetailView, tapping an element updates selectedElement and CardElementView shows a border around the selected element.

In CardBottomToolbar, the Frames button is disabled when selectedElement is nil, but enabled when you tap an element. Tapping the background color deselects the element and disables Frames again.

Currently when you tap Frames, a modal pops up with an EmptyView. You’ll replace this modal view with a FramePicker view where you’ll be able to select a shape.

➤ Build and run the project to see the changes.

A selected element enables the Frames button
A selected element enables the Frames button

Shapes

Skills you’ll learn in this section: predefined shapes

➤ In the Model group, create a new SwiftUI View file called Shapes.swift. This file will hold all your custom shapes.

➤ Replace body with

var body: some View {
  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.

➤ Preview the view.

Five predefined shapes
Five predefined shapes

These shapes conform to the Shape protocol, which inherits from View. Using the Shape protocol, 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.

Triangle
Triangle

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:

  1. You create a new subpath by moving to a point. Paths can contain multiple subpaths.
  2. Add straight lines from the previous point. You can alternatively put the two points in an array and use addLines(_:).
  3. Close the subpath when you’ve finished to create the polygon.

➤ Change Shapes to:

struct Shapes: View {
  let currentShape = Triangle() 

  var body: some View {
    currentShape
      .background(Color.yellow)
  }
}

➤ Preview Shapes.

Triangle Shape
Triangle Shape

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.

Fixed Triangle
Fixed Triangle

If you want the triangle to retain its shape, but size with 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.

➤ In Shapes, change the contents of body to:

currentShape
  .aspectRatio(1, contentMode: .fit)
  .background(Color.yellow)

You maintain the square aspect ratio, and the triangle will now resize to the available space.

Resizable Triangle
Resizable Triangle

➤ In Shapes_Previews, add this modifier to Shapes:

.previewLayout(.sizeThatFits)

Now the preview will only show the part of Shapes that holds the triangle.

Resized preview
Resized preview

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 Shapes, replace currentShape with:

let currentShape = Cone()

➤ Preview the top of the cone.

The arc
The arc

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. So when you go from a start angle of 0º to an end angle of 180º with clockwise set true, you start at the right hand side and go anti-clockwise around the circle.

Describe an arc
Describe an arc

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.

The completed cone
The completed cone

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.

Quadratic curve
Quadratic curve

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 above diagram, and the second curve mirrors it.

➤ In Shapes, replace currentShape to use this shape:

let currentShape = Lens()

➤ Preview the shape.

Lens shape
Lens shape

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.

Stroke and fill
Stroke and fill

In the body of Shapes, add this to currentShape:

.stroke(lineWidth: 5)

You can only use stroke(_:) on objects conforming to Shape, so you must place the modifier directly after currentShape.

Stroke
Stroke

Stroke style

When you define a stroke, instead of giving it a lineWidth, you can give it a StrokeStyle instance.

For example:

currentShape
  .stroke(style: StrokeStyle(dash: [30, 10]))

StrokeStyle with dash
StrokeStyle with dash

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:

Line caps
Line caps

lineCap: .square is similar to .butt, except that the ends protrude a bit further.

➤ In Shapes, 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:

Line join
Line join

Clip shapes modal

You’ve now created a few shapes and feel free to experiment with more. The challenge for this chapter will suggest a few shapes for you to try.

As well as displaying a shape view, you can use a shape to clip another view. You’re going to list all your shapes in a modal so that the user can select a photo and clip it to a chosen shape.

➤ In the Card Modal Views group, create a new SwiftUI View file called FramePicker.swift. This will be very similar to StickerPicker.swift, but will load your custom shapes into a grid instead of stickers.

First, you’ll set up an array of all your shapes for the modal to iterate through.

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:

Protocol 'Shape' can only be used as a generic constraint because it has Self or associated type requirements.

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.

Unfortunately, there isn’t a built-in AnyShape for your array of Shapes, but it’s quite easy to make one, when you know what the requirements for a Shape are.

➤ Create a new Swift file called AnyShape.swift.

➤ Replace the code with:

import SwiftUI

struct AnyShape: Shape {
  func path(in rect: CGRect) -> Path {
  }
}

AnyShape conforms to Shape with the required path(in:). You’ll get a compile error until you return a path from the method.

To convert your custom shape to an AnyShape, you’ll use an initializer which takes in a generic Shape. This initializer will create a closure that uses this shape to create a path. You’ll store this closure as a property, and when a view calls for the path, you’ll perform the closure.

If you need to review closures, take a look at Chapter 9, “Saving History Data”.

➤ Add a property to hold the closure:

private let path: (CGRect) -> Path

You’ll perform the custom shape’s path(in:) when it’s required. path(in:) takes in a CGRect and returns a Path.

➤ Add the initializer:

// 1
init<CustomShape: Shape>(_ shape: CustomShape) {
  // 2
  self.path = { rect in
    // 3
    shape.path(in: rect)
  }
}

You take in the custom shape when you create the structure. To explain the code:

  1. Because CustomShape is a generic type — in angled brackets — you tell the initializer that CustomShape is some sort of Shape.
  2. You define the closure to receive a CGRect with { rect in }
  3. When you execute the closure, it calls the shape’s path(in:) using the supplied rect.

You’re still getting a compile error because path(in:) needs a return.

➤ Add this code to path(in:):

path(rect)

You call your path closure supplying the current rect as the parameter. The method now returns the custom shape’s path as the Path.

Your code now compiles, and AnyShape is ready to convert any custom shape to itself.

A type erased array

➤ In Shapes.swift, add a new extension to Shapes:

extension 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 FramePicker.swift and replace FramePicker with:

struct FramePicker: View {
  @Environment(\.presentationMode) var presentationMode
  
  // 1 
  @Binding var frame: AnyShape?
  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
            .background(
              Shapes.shapes[index].fill(Color.secondary))
            .frame(width: 100, height: 120)
            .padding()
            // 5
            .onTapGesture {
              frame = Shapes.shapes[index]
              presentationMode.wrappedValue.dismiss()
            }
        }
      }
    }
    .padding(5)
  }
}

This is almost exactly the same code as you wrote for StickerPicker. The exceptions are:

  1. You pass in a frame that will hold the selected shape.
  2. You iterate through the array of shapes by index.
  3. Outline the shape with the primary color.
  4. You need to 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.
  5. When you tap the shape, you update frame and dismiss the modal.

➤ Change the preview to:

struct FramePicker_Previews: PreviewProvider {
  static var previews: some View {
    FramePicker(frame: .constant(nil))
  }
}

➤ Preview FramePicker to see all your shapes in a grid:

Shapes Listing
Shapes Listing

Add the frame picker modal to the card

➤ Open CardDetailView.swift and add a new property:

@State private var frame: AnyShape?

This is the frame that you’ll pass to FramePicker.

➤ Locate .sheet(item:) and add a new case to the switch statement:

case .framePicker:
  FramePicker(frame: $frame)
    .onDisappear {
      if let frame = frame {
        card.update(
          viewState.selectedElement, 
          frame: frame)
      }
      frame = nil
    }

Here you call the modal and then update the card element with the frame. As you haven’t written update(_:frame:) yet, you’ll get a compile error.

Add the frame to the card element

➤ Open CardElement.swift and add a new property to ImageElement:

var frame: AnyShape?

This will hold the element’s frame. You add it only to ImageElement, because the frame will only clip images.

➤ Open Card.swift and add the new update method to Card:

mutating func update(_ element: CardElement?, frame: AnyShape) {
  if let element = element as? ImageElement,
    let index = element.index(in: elements) {
      var newElement = element
      newElement.frame = frame
      elements[index] = newElement
  }
}

Here you pass in the element and the frame. Because element is immutable and you need to update its frame, you create a new mutable copy and update elements with this new instance.

All that’s left to do now, is to clip the image element.

➤ Open CardElementView.swift and locate ImageElementView.

The modifier you’ll add is .clipShape(_:), but you only want to add it if the element’s frame is not nil. Surprisingly, it’s not easy to add a conditional modifier in SwiftUI, but the following is a solution when the existing code is quite simple.

Add a modifier conditionally

➤ In ImageElementView, rename body to bodyMain.

➤ Add a new property to ImageElementView:

var body: some View {
  if let frame = element.frame {
    bodyMain
      .clipShape(frame)
  } else {
    bodyMain
  }
}

You recreate body and use bodyMain in both parts of the conditional. If there is a frame, add the modifier.

➤ Build and run the app, and choose the green card. Tap the giraffe and choose Frames. Select a frame and the giraffe photo gets clipped to that shape.

Clipped giraffe
Clipped giraffe

Challenges

Challenge 1: Create new shapes

Practice creating new shapes and place them in the frame picker modal. Here are some suggestions:

Try these shapes
Try these shapes

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 achieve this, you’ll replace the border with the stroked frame in an overlay.

  1. In CardElementView.swift, in CardElementView, remove the border modifier on ImageElementView. Place it in each part of the conditional inside ImageElementView’s body.
  2. Pass selected from CardElementView to ImageElementView.
  3. When the image has a frame, replace the border modifier with an overlay of the stroked frame.
  4. When you tap the space outside the frame, but within the original unclipped image, SwiftUI still thinks you’re tapping the image. After the overlay, add the modifier .contentShape(frame). This will clip the tap area to the frame.

Check your changes out by running the app or by live previewing SingleCardView.

A selected giraffe
A selected giraffe

Key points

  • The Shape protocol provides an easy way to draw a 2D shape. There are some built-in shapes, such as Rectangle and Circle, but you can create custom shapes by providing a Path.
  • Paths are the outline of the 2D shape, made up of lines and curves.
  • A Shape fills by default with the primary color. You can override this with the fill(_:style:) modifier to fill with a color or gradient. Instead of filling the shape, you can stroke it with the stroke(_: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.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.