Chapters

Hide chapters

SwiftUI by Tutorials

Fifth Edition · iOS 16, macOS 13 · Swift 5.8 · Xcode 14.2

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

18. Drawing & Custom Graphics
Written by Bill Morefield

As you develop more complex apps, you’ll need more flexibility or flash than the built-in controls SwiftUI offers. Fortunately, SwiftUI provides a rich library to assist in creating graphics within your app.

Graphics convey information to the user efficiently and understandably; for instance, you can augment text that takes time to read and understand with graphics that summarize the same information.

In this chapter, you’ll add graphics to a view and create a chart to display the history of how well a flight has been on time in the past. In doing this, you’ll explore the graphics SwiftUI has to offer.

Using Shapes

To start, open the starter project for this chapter. Run the project, and you’ll see the in-progress app for a small airport continued from Chapter 17: Sheets & Alert Views.

Starter project
Starter project

Tap Flight Status, then tap the name of any flight. You’ll see information on the flight, including a map of the terminal where the flight arrives or departs. In the previous chapter, you added information about the restaurants in each terminal that appears when you tap the terminal map. In this section, you’ll explore shapes in SwiftUI by adding Shape views to represent restaurants and shops in each terminal.

Sheet showing restaurants
Sheet showing restaurants

Open TerminalMapView.swift under the FlightDetails group. The view contains a conditional statement to show the correct terminal map. You wrap the conditional inside a Group to apply modifiers to the result of the conditional statement.

Add the following code after the Group:

.overlay {
  Rectangle()
}

The views inside the closure to an overlay(alignment:content:) modifier will appear on top of the modified view. In this case, the Rectangle view is a Shape that shows a rectangle that SwiftUI will overlay on top of the image. Using an overlay aligns the view’s axes. A ZStack also does this, but the overlay matches the size of the overlaid view to the original view.

Run the app, tap Flight Status and then any flight. Now, you’ll see the terminal map covered in black.

The terminal map now covered in black
The terminal map now covered in black

A Shape is a type of view, but it’s still a view and shares many defaults of other views. It will fill the available space, hence why the Rectangle fills the entire view and covers the terminal map. Change the Rectangle to:

Rectangle()
  .frame(width: 100, height: 100)
  .foregroundColor(.red)

Since the shape is a view, you can apply the same modifiers you would to any other view to change and adapt the shape to your needs. The frame(width:height:alignment:) sets the width and height for the Rectangle. You then set the foregroundColor(_:) to red.

Run the app, tap Flight Status and then any flight. You’ll now see a red rectangle in the middle of the terminal.

A red rectangle in the middle of the terminal map
A red rectangle in the middle of the terminal map

SwiftUI provides several of the most common Shapes. The other SwiftUI shapes are:

  • Capsule: A capsule shape is a rounded rectangle with a corner radius half the length of the rectangle’s smallest edge.
  • 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 with rounded corners instead of sharp corners. It renders so the shape appears within the containing frame.

Ensure you’re viewing the next section on an iPhone 14 Pro simulator or device, or you may see slightly different results.

The app includes a list of stores in each terminal in the TerminalStore struct. Each store contains information about the store and an indication of how busy it currently is. You’ll use this information to show the stores for each terminal.

Add the following new view above the current TerminalMapView struct:

struct TerminalStoresView: View {
  var flight: FlightInformation

  var body: some View {
    Text("Hello World")
  }
}

You’ll create your shapes in this new view to reduce clutter in your code. Now, add a new computed property to the TerminalStoresView view:

var stores: [TerminalStore] {
  if flight.terminal == "A" {
    return TerminalStore.terminalStoresA
  } else {
    return TerminalStore.terminalStoresB
  }
}

This property will return a list of stores using the static terminalStoresA or terminalStoresB properties of the TerminalStore struct based on the flight passed into the view. Now, change the body of the view to the following:

// 1
let firstStoreOffset = flight.terminal == "A" ? 140.0 : -140
let direction = flight.terminal == "A" ? -1.0 : 1.0
// 2
ForEach(stores.indices, id: \.self) { index in
  // 3
  let store = stores[index]
  // 4
  let xOffset = Double(index) * 85.0 * direction + firstStoreOffset
  // 5
  RoundedRectangle(cornerRadius: 5.0)
    // 6
    .foregroundColor(
      Color(
        hue: 0.3333,
        saturation: 1.0 - store.howBusy,
        brightness: 1.0 - store.howBusy
      )
    )
    // 7
    .overlay(
      Text(store.shortName)
        .font(.footnote)
        .foregroundColor(.white)
        .shadow(radius: 5)
    )
    // 8
    .frame(width: 70, height: 40)
    .offset(x: xOffset, y: -30)
}

Coordinates within a view originate at the view’s center. Points then run negatively toward the top and left of the view and positively downward and to the right. To draw shapes for the stores in a terminal:

  1. First, you compute a few values to display the stores in the correct place on both terminal maps. In both cases, you want the stores to display starting at the edge of the terminal nearest gate one. Stores in Terminal A will then flow toward the left, and those in Terminal B will flow to the right. You set firstStoreOffset to 140.0 for the right side of the image and -140 for the left side of the image. You also set direction to negative one for Terminal A and one for Terminal B. You’ll see how you use this in step four.

  2. You loop through the indices of the stores computed property. You use the indices instead of accessing the objects directly because you’ll use the index later.

  3. You get the store object at position index. Note that if you didn’t need the index, you could iterate over stores and pass store as the parameter to the closure.

  4. You specify the location of shapes relative to the top-leading corner. To calculate each store’s horizontal position, convert the index to a double and multiply it by the direction above. When direction is one (Terminal B,) this calculation will give you a sequence of 0, 1, 2, etc. as the index increases, and 0, -1, -2, etc. when direction is negative one (Terminal A). You multiply this by 85.0 to offset each successive store an additional 85 points in steadily decreasing (Terminal A) or increasing (Terminal B) values. You then add firstStoreOffset to place the first store in the desired location.

  5. The RoundedRectangle shape produces a rectangle with rounded corners. Here you provide a cornerRadius parameter to tell SwiftUI the radius of the curve in each corner.

  6. The howBusy property of the store object contains a value between zero and one, representing how busy the store is relative to the busiest it usually gets. You create a shade of green using the Color initializer that takes a hue, saturation, and brightness instead of the more common red, blue, and green components. A hue value of 120 represents green. You then set the saturation and brightness to one minus the howBusy property to produce a lower value as the store becomes busy. Using it for both elements makes a darker shade of green as the store gets busier.

  7. You use an overlay(_:alignment:) modifier to add a view on top of the RoundedRectangle. Here, you add a Text view showing the shortName of the store in white in the footnote font. The shadow(color:radius:x:y:) modifier adds dark color around the text to help it stand out against lighter backgrounds.

  8. To set the shape and location for the view, you use the frame(width:height:alignment:) modifier to apply the width and height of the rounded rectangle, and you use the offset(x:y:) modifier to place the rounded rectangle in the desired position of the view.

Replace your current Rectangle shape and its modifiers inside the overlay with:

TerminalStoresView(flight: flight)

This change uses your new view as the overlay for the terminal map. If you look at the preview for either terminal, the stores appear from the side of the first gate and move toward higher gate numbers.

Stores for terminal A in preview
Stores for terminal A in preview

The magic numbers in step six should cause concern. This looks perfect on a view that takes up the full-screen iPhone 14 Pro but will break in most other cases.

Run the app, tap Flight Status, then tap any flight in terminal A. The results don’t quite look right:

Same view in the app with stores misaligned
Same view in the app with stores misaligned

Using specified numbers with Shape views sometimes works, but when mixing with other views, you make implicit assumptions about the view’s size. When those assumptions are wrong, the layout falls apart. When you tap a row on the Flight Status page, the FlightDetails view shows the terminal map as part of a larger view, and the TerminalMapView no longer fills the full view. Even when it does, the same issue occurs when viewed on smaller or larger devices.

So, how do you fix the problem? Well, you need to know the actual size when the view renders on the device. SwiftUI provides a way to access this information, and you’ll learn how to do that in the next section.

Using GeometryReader

The GeometryReader container provides a way to get the size and shape of a view from within it. This information lets you create drawing code that adapts to the view’s size. It also allows you to match your graphics to the available space.

First, wrap the entire body of the TerminalStoresView with:

GeometryReader { proxy in
  // Current body of the view
}

This code places the entire body of the view in a GeometryReader. Wrapping a view within a GeometryReader causes two immediately noticeable effects. The view now takes up the entire available space within the container view and shifts the origin from the center to the top-leading point of the view. This shift of the origin changes the locations of the shapes, as you can see in the preview.

Adding a GeometryReader changes the layout of the view
Adding a GeometryReader changes the layout of the view

The parameter to the closure of the GeometryReader named proxy is of type GeometryProxy and provides access to the view’s size. The proxy gives you access to the view size, and you can use it to adapt your shapes to the view size. Add the following after comment one and before the existing two variables:

let width = proxy.size.width
let height = proxy.size.height
let storeWidth = width / 6
let storeHeight = storeWidth / 1.75
let storeSpacing = width / 5

This code stores the width and height of the view from the size property of the GeometryProxy. You use these values to calculate storeWidth, a width for each store that’s one-sixth the width of the view. You determine a storeHeight to match the previous 70/40 width-to-height ratio. Finally, you calculate a storeSpacing at one-fifth of the view width to represent the separation between adjacent stores.

Now update firstStoreOffset to:

let firstStoreOffset = flight.terminal == "A" ?
width - storeSpacing :
storeSpacing - storeWidth

To calculate firstStoreOffset, you have separate cases for each terminal. For Terminal A, you want the store to appear at the left of the image, so you set it to the full width of the view and subtract storeSpacing so it will appear on the view’s right side. For Terminal B, you want to offset the view by the difference between storeWidth and storeSpacing between stores.

Notice that you made no change to the direction variable because it’s not reliant on the view’s size. Next, change the xOffset under comment four to:

let xOffset = Double(index) * storeSpacing * direction + firstStoreOffset

This change replaces the 85.0 with storeSpacing calculated above. Now replace the modifiers under comment eight to:

.frame(width: storeWidth, height: storeHeight)
.offset(x: xOffset, y: height * 0.4)

These values now adapt the size and location of each shape to fit the view. Run the app, Tap Slight Status, and tap any flight in Terminal A. The results now look appropriate here as they did in the preview because your code adjusts for the view size.

Shapes adjusting to size of the view in Terminal A
Shapes adjusting to size of the view in Terminal A

Run the app on an iPad, and the shapes adjust to match the larger view.

Stores adjusting for an iPad
Stores adjusting for an iPad

In this section, you learned to adapt your shapes to work on any view using a GeometryReader. While shapes work well, sometimes you need more flexibility than shapes can provide. For those drawings, you can use Paths. In the next section, you’ll look at implementing a pie chart using Paths.

Using Paths

Sometimes you want to define your shape, not use the built-in ones. You use Paths for this, which allows you to create shapes by combining individual segments. These segments make up the outline of a two-dimensional shape.

Earlier editions of this book demonstrated drawing by building charts. Along with SwiftUI 4.0, Apple introduced a Swift Charts API that handles many of these cases, and you’ll use it in Chapter 20: View Transitions & Charts to implement most of the same charts. However, it does not include support for pie charts, and in this section, you’ll use Paths to add a pie chart that shows the breakdown of flight delays into broad categories. The categories you’ll use are:

  • On-time: Flights that are on time or early.
  • Short delay: A delay of 15 minutes or less.
  • Significant delay: A delay of 15 minutes or more.
  • Canceled: Canceled flights.

Preparing for the Chart

To start, create a new SwiftUI view under the SearchFlights group named HistoryPieChart. Add the following to the top of the view:

var flightHistory: [FlightHistory]

Also, update the preview to provide sample data:

HistoryPieChart(
  flightHistory: FlightData.generateTestFlightHistory(
    date: Date()
  ).history
)

First, you need a struct to define the information for each pie chart segment. Above the definition for the HistoryPieChart struct, add the following code:

struct PieSegment: Identifiable {
  var id = UUID()
  var fraction: Double
  var name: String
  var color: Color
}

This struct stores information about each pie segment. You’ve implemented Identifiable and set the id property to a unique identifier using a new UUID for each element to allow you to iterate over PieSegments.

Now, add the following computed properties after the flightHistory property:

var onTimeCount: Int {
  flightHistory.filter { $0.timeDifference <= 0 }.count
}

var shortDelayCount: Int {
  flightHistory.filter {
    $0.timeDifference > 0 && $0.timeDifference <= 15
  }.count
}

var longDelayCount: Int {
  flightHistory.filter {
    $0.timeDifference > 15 && $0.actualTime != nil
  }.count
}

var canceledCount: Int {
  flightHistory.filter { $0.status == .canceled }.count
}

These four properties filter the array to return the appropriate number of flights. The categories for each match those used to define the delayColor property in FlightHistory.swift.

With these counts, you can now determine the size of the pie segments you’ll display. Add the following computed property after canceledCount:

var pieElements: [PieSegment] {
  // 1
  let historyCount = Double(flightHistory.count)
  // 2
  let onTimeFrac = Double(onTimeCount) / historyCount
  let shortFrac = Double(shortDelayCount) / historyCount
  let longFrac = Double(longDelayCount) / historyCount
  let cancelFrac = Double(canceledCount) / historyCount

  // 3
  let darkRed = Color(red: 0.5, green: 0, blue: 0)
  let segments = [
    PieSegment(fraction: onTimeFrac, name: "On-Time", color: Color.green),
    PieSegment(fraction: shortFrac, name: "Short Delay", color: Color.yellow),
    PieSegment(fraction: longFrac, name: "Long Delay", color: Color.red),
    PieSegment(fraction: cancelFrac, name: "Canceled", color: darkRed)
  ]

  // 4
  return segments.filter { $0.fraction > 0 }
}

Here’s what this code is doing to define the segments:

  1. You start by getting the number of FlightHistory elements in the array.
  2. You use the previously created methods to count the flights matching each category. You divide that number by the array’s total number of elements to get a fraction of the flights that meet the criteria.
  3. You create an array where each element represents the indicated portion of flights matching the criteria.
  4. You return the array after filtering out any segments with no matching values.

Building the Pie Chart

With all that preparation done, creating the pie chart takes less code. Change the view body to:

GeometryReader { proxy in
  // 1
  let radius = min(proxy.size.width, proxy.size.height) / 2.0
  // 2
  let center = CGPoint(x: proxy.size.width / 2.0, y: proxy.size.height / 2.0)
  // 3
  var startAngle = 360.0
  // 4
  ForEach(pieElements) { segment in
    // 5
    let endAngle = startAngle - segment.fraction * 360.0
    // 6
    Path { pieChart in
      // 7
      pieChart.move(to: center)
      // 8
      pieChart.addArc(
        center: center,
        radius: radius,
        startAngle: .degrees(startAngle),
        endAngle: .degrees(endAngle),
        clockwise: true
      )
      // 9
      pieChart.closeSubpath()
      // 10
      startAngle = endAngle
    }
    // 11
    .foregroundColor(segment.color)
  }
}

There’s a lot here. This view loops through the segments of the pie and draws each. You draw each segment after the previous segment ends. A complication arises in that, angles inside a path used with an arc increase counterclockwise. You want to draw segments in a clockwise direction. To do so, you can take advantage of the fact that angles wrap around. An angle of 360 degrees will correspond to the same direction at zero degrees. You start at 360 degrees, then subtract angles to move clockwise around the circle of the pie.

Here’s how the individual lines work:

  1. You need to determine the size of the pie chart using the GeometryProxy. You start by finding the smaller measurement of the height and width of the view. You divide that value by two to calculate the radius of a circle. This radius will produce a pie that fills the smaller dimension of the view.

  2. You divide the width and height of the view by two to determine the center point for each dimension and then create a point indicating this location.

  3. You can define variables inside a GeometryReader. Here, you create a startAngle variable that will remain in scope for the rest of the view. The default angle of zero is along the direction the x value increases in the view. As mentioned above, you start at 360, so you can subtract angles, making the segments flow clockwise.

  4. You loop through the segments taking advantage of PieSegment implementing the Identifiable protocol.

  5. An arc needs starting and ending angles. You already have the starting angle of the arc in startAngle. Now you’ll calculate the angle of the endpoint. You multiply 360 degrees by the fraction of the full circle this arc will take to get the arc’s size in degrees. You subtract this size from the arc’s starting point to get the arc’s ending position angle, so the segments sweep counterclockwise.

  6. The drawing begins. Declaring Path creates an enclosure you use to build the path.

  7. The move(to:) method on the path sets the starting location for the path, which, here, is the center of the view; a move(to:) call moves the current position but doesn’t add anything to the path.

  8. You add the arc to the path. An arc takes the center and radius that defines the circle. Then you specify both the starting and ending angles for the arc. The clockwise parameter tells SwiftUI the arc begins at the startAngle and moves clockwise to the endAngle. Note that you can use degrees or radians by using the corresponding initializer.

  9. You close the path, which adds a line from the current back to the path’s starting position.

  10. Inside the path, you can update and set variables. The following pie segment should appear at the end of this one, so you update the startAngle variable to match this segment’s ending angle.

  11. Lastly, you close the path and then use the fill() method to fill the path with the segment’s color.

Now you have a pie chart, but you need to add it to the history view. Open FlightTimeHistory.swift and add the following code to the end of the VStack after the ScrollView:

HistoryPieChart(flightHistory: flight.history)
  .frame(width: 250, height: 250)
  .padding(5)

Run the app and view the on-time history for a flight. You’ll see the pie chart at the bottom of the history chart:

Pie chart
Pie chart

You have a clear pie chart, but it’s unclear at a glance what the color of the segments represents. In the next section, you’ll add a legend to the chart.

Adding a Legend

You have one more touch to add. The chart looks good, but it needs some indication of each color’s meaning. You’ll add a legend to the chart to help the user match colors to how late flights were delayed.

Open HistoryPieChart.swift. Wrap the GeometryReader that makes up the view inside an HStack. Now, add the following code at the end of the HStack:

VStack(alignment: .leading) {
  ForEach(pieElements) { segment in
    HStack {
      Rectangle()
        .frame(width: 20, height: 20)
        .foregroundColor(segment.color)
      Text(segment.name)
    }
  }
}

You loop through the segments. For each, you show a small square using the Rectangle shape you worked with earlier in this chapter, coloring the square the color of the associated segment. You then show the name for that segment using the footnote font.

Run the app, and you’ll see the legend makes it clear what each color represents:

Pie chart with legend
Pie chart with legend

The default font is a bit large, so you’ll change that. Go back to FlightTimeHistory.swift and add the following modifier after the call to HistoryPieChart() and before the frame(width:height:alignment:) modifier:

.font(.footnote)

Run the app and view the on-time history for a flight. You’ll now see a clear legend next to the pie chart:

Resized pie chart legend
Resized pie chart legend

Your pie chart looks clear now, but it would look a bit more traditional if the chart started with the first segment vertically. You could change the angles of the arc, but a more straightforward way is to rotate the finished path. Add the following modifier after the .foregroundColor(segment.color) call after comment 11:

.rotationEffect(.degrees(-90))

Run the app, and you’ll see the chart rotated one-quarter rotation counterclockwise. Yes, the direction of the angle when rotating in the view is the opposite of those used when drawing arcs.

Rotated pie chart
Rotated pie chart

Having created a pair of complex views using shapes and paths to create graphics, you’ll now look at performance when drawing in SwiftUI.

Fixing Performance Problems

By default, SwiftUI renders graphics and animations using CoreGraphics. SwiftUI draws each view individually on the screen when needed. Modern Apple device processors and graphics hardware are powerful and can handle many views without seeing a slowdown. However, you can overload the system and see performance drop off to the point a user notices, and your app will seem 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 rendering complex views. Note that offscreen composition adds overheard, resulting in slower performance for simple graphics. Using many gradients, shadows, and other effects will most likely result in performance problems.

Wait until you have a performance problem before turning to drawingGroup(). Remember that the drawingGroup() modifier only works for graphics — shapes, images, text, etc.

Drawing High-Performance Graphics

SwiftUI 3.0 added a new Canvas view meant to provide high-performance graphics in SwiftUI. The other graphics views you’ve seen in this chapter work within the SwiftUI view builder. A Canvas view provides immediate mode drawing operations that resemble the traditional Core Graphics-based drawing system. The Canvas includes a withCGContext(content:) method whose closure provides access to a Core Graphics context compatible with existing Core Graphics code.

You draw using the GraphicsContext passed into the closure. The closure to the view also receives a CGSize parameter describing the dimensions of the Canvas.

While view builder based views aren’t directly supported, SwiftUI provides a mechanism to reference and use SwiftUI views within a Canvas. In this section, you’ll build a simple Canvas view on the Awards page that uses a passed in SwiftUI view.

Create a new SwiftUI view named AwardStars.swift inside the AwardsView group. The view will have one property for the number of stars to display.

Add the following property to the top of the view:

var stars: Int = 3

This parameter will provide the number of stars to draw. Now change the view body to:

// 1
Canvas { gContext, size in
  // 2
} symbols: {
  // 3
  Image(systemName: "star.fill")
    .resizable()
    .frame(width: 15, height: 15)
    // 4
    .tag(0)
}

You define a Canvas view that uses the symbols parameter of the initializer to pass a SwiftUI view into the Canvas:

  1. The closure receives a GraphicsContext you use to draw along with a CGSize parameter with the dimensions of the Canvas.
  2. The closure of the Canvas isn’t a view builder, but the closure to the symbols parameter is. You pass SwiftUI views into the closure to reference them from inside the Canvas drawing commands.
  3. You define a SwiftUI Image of the star.fill symbol resized to 15 points square.
  4. To access a SwiftUI view from within the Canvas closure, you must use the tag(_:) modifier to assign each view a unique identifier.

Now you can begin to fill in the closure of the Canvas. Replace comment two with the following code:

guard let starSymbol = gContext.resolveSymbol(id: 0) else {
  return
}

You use the resolveSymbol(id:) method on the GraphicsContext to look for a symbol with the 0 tag. The guard means the method returns if no matching symbol exists. You can just return because the inside of the Canvas closure executes in immediate mode instead of creating a view builder, meaning all parts of the Swift language are available.

Next, add the following code to the Canvas closure:

// 1
let centerOffset = (size.width - (20 * Double(stars))) / 2.0
// 2
gContext.translateBy(x: centerOffset, y: size.height / 2.0)

These adjust the canvas to center the stars that you’ll draw.

  1. The width property on the size parameter contains the width of the canvas. You subtract from the width the number of stars multiplied by 20 as each star will take 20 points of space, including padding. The result will give you the remaining space left in the view outside the stars. Dividing this by two splits the space in half, giving you the position to place the first star to center them horizontally.
  2. The translateBy(x:y:) shifts the origin of the canvas by the provides points in each direction. The default origin lies in the top right of the Canvas. You move the horizontal position by the number of points calculated in step one horizontally and half the height of the canvas vertically.

With the drawing position shifted you can now loop and draw the stars. Add the following code to the end of closure:

// 1
for star in 0..<stars {
  // 2
  let starXPosition = Double(star) * 20.0
  // 3
  let point = CGPoint(x: starXPosition + 8, y: 0)
  // 4
  gContext.draw(starSymbol, at: point, anchor: .leading)
}

Here are the steps to draw the stars:

  1. Since you’re not in a view builder, you use a standard for-in Swift loop instead of a ForEach view.

  2. You’ll offset each view by 20 points. Remember you sized the symbol to 15 points, so this gives five points of space between stars.

  3. In a Canvas, you’ll use Core Graphics drawing data types and structures. You create a CGPoint with the x position from step two and zero for the y position. With the translated offset from earlier, these values are relative to the new origin.

  4. The draw(_:at:anchor:) method takes the resolved symbol to draw, the point you want to draw the symbol and how that point relates to the symbol’s origin. Using leading means the point you pass acts as the leading point of the drawn symbol or at the vertical center of the leading edge. By default, SwiftUI would draw the symbol with the point as the center.

With the view completed, you can now add it to the Awards. Open AwardCardView.swift. Before the final spacer, add the following view:

AwardStars(stars: award.stars)
  .foregroundColor(.yellow)
  .shadow(color: .black, radius: 5)
  .offset(x: -5.0)

You’ll set all awards to three stars. You set the foreground color of the view to yellow and add a small black shadow under the view to help the stars stand out. You apply a horizontal offset to account for the padding used on the view. Now run the app and navigate to the Your Awards page to view the results:

Awards showing stars
Awards showing stars

Key Points

  • Shapes provide a quick way to draw simple controls. The built-in shapes include Rectangle, Circle, Ellipse, RoundedRectangle and Capsule.
  • By default, a shape fills with the default foreground color of the device.
  • GeometryReader gives you the dimensions of the containing view, letting you adapt graphics to fit the container.
  • Paths gives you the tools to produce more complex drawings than basic shapes adding curves and arcs.
  • You can modify the appearance of paths in the same manner as shapes.
  • Using drawingGroup() can improve the performance of graphics-heavy views, but should only be added when performance problems appear as it can slow the rendering of simple graphics.
  • A Canvas view provides a view focused on high-performance graphics. You can pass SwiftUI views to use in a Canvas, but it does not use the view builder approach used in most of SwiftUI.

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.

You can find more examples of drawing charts in SwiftUI in the SwiftUI Tutorial for iOS: Creating Charts at https://www.kodeco.com/6398124-swiftui-tutorial-for-ios-creating-charts.

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.