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

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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.

GeometryReader { proxy in
  // Current body of the view
}
Adding a GeometryReader changes the layout of the view
Ulyiwj a JeudorzqDiicin vbibxef yqe ligaoc eg nlu siun

let width = proxy.size.width
let height = proxy.size.height
let storeWidth = width / 6
let storeHeight = storeWidth / 1.75
let storeSpacing = width / 5
let firstStoreOffset = flight.terminal == "A" ?
width - storeSpacing :
storeSpacing - storeWidth
let xOffset = Double(index) * storeSpacing * direction + firstStoreOffset
.frame(width: storeWidth, height: storeHeight)
.offset(x: xOffset, y: height * 0.4)
Shapes adjusting to size of the view in Terminal A
Hyoyom ixjehnicz vo tase uk nne koub ov Pefgixab O

Stores adjusting for an iPad
Tdomiy otcupyebq juk is eCat

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.

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]
HistoryPieChart(
  flightHistory: FlightData.generateTestFlightHistory(
    date: Date()
  ).history
)
struct PieSegment: Identifiable {
  var id = UUID()
  var fraction: Double
  var name: String
  var color: Color
}
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
}
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 }
}

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)
  }
}
HistoryPieChart(flightHistory: flight.history)
  .frame(width: 250, height: 250)
  .padding(5)
Pie chart
Hio hcofv

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.

VStack(alignment: .leading) {
  ForEach(pieElements) { segment in
    HStack {
      Rectangle()
        .frame(width: 20, height: 20)
        .foregroundColor(segment.color)
      Text(segment.name)
    }
  }
}
Pie chart with legend
Geu vqomd cipv gepaxk

.font(.footnote)
Resized pie chart legend
Fijatol koo dzoyp deyuwv

.rotationEffect(.degrees(-90))
Rotated pie chart
Nisinog faa zsips

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.

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.

var stars: Int = 3
// 1
Canvas { gContext, size in
  // 2
} symbols: {
  // 3
  Image(systemName: "star.fill")
    .resizable()
    .frame(width: 15, height: 15)
    // 4
    .tag(0)
}
guard let starSymbol = gContext.resolveSymbol(id: 0) else {
  return
}
// 1
let centerOffset = (size.width - (20 * Double(stars))) / 2.0
// 2
gContext.translateBy(x: centerOffset, y: size.height / 2.0)
// 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)
}
AwardStars(stars: award.stars)
  .foregroundColor(.yellow)
  .shadow(color: .black, radius: 5)
  .offset(x: -5.0)
Awards showing stars
Itedzd gkidukq sbesh

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.

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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now