Chapters

Hide chapters

SwiftUI by Tutorials

Fourth Edition · iOS 15, macOS 12 · Swift 5.5 · Xcode 13.1

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

18. Drawing & Custom Graphics
Written by Bill Morefield

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

As you begin to develop more complex apps, you’ll find that you need more flexibility or flash than the built-in controls SwiftUI offers. Fortunately, SwiftUI provides a rich library to assist in the creation of 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 explore the graphics in SwiftUI by creating charts to display the history of if a flight has been on time in the past.

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 16: Grids.

Starter project
Starter project

Tap Search Flights, then tap on the name of any flight. From the flight summary, tap on the On-Time History button. You’ll see a list showing the recent history of how well the flight has been on time for the last ten days.

Note: The first flight — US 810 to Denver — will provide a suitable range of delays for this section.

List history
List history

Looking at a few data points can be enlightening, but staring at a long list of numbers isn’t the best way to gain insight. A list of numbers doesn’t make it easier to understand how warm a particular month was or determine the driest months.

Most people have an easier time grasping information presented graphically. A chart can provide a graphic representation of data designed to inform the viewer.

You’ll first look at creating a bar chart.

Creating a bar chart

A bar chart provides a bar for each data point. Each bar’s length represents the numerical value and can run horizontally or vertically to suit your needs.

HStack {
  // 1
  Text("\(history.day) day(s) ago")
    .padding()
    // 2
    .frame(width: 140, alignment: .trailing)
  // 3
  Rectangle()
    .foregroundColor(history.delayColor)
}
Initial rectangle
Eyoseac mabwughda

.frame(width: CGFloat(history.timeDifference))
HStack {
  // 1
  Text("\(history.day) day(s) ago")
    .padding()
    // 2
    .frame(width: 140, alignment: .trailing)
  // 3
  Rectangle()
    .foregroundColor(history.delayColor)
    .frame(width: CGFloat(history.timeDifference))
  Spacer()
}
Initial graph
Owejuid qvikw

Negative frame
Tipiboxu nzika

.frame(width: CGFloat(abs(history.timeDifference)))
func minuteOffset(_ minutes: Int) -> CGFloat {
  let offset = minutes < 0 ? 15 + minutes : 15
  return CGFloat(offset)
}
.offset(x: minuteOffset(history.timeDifference))
Chart with offsets
Zguwb bipc ujkmesv

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 size of the view. It also gives you a way to ensure you use the available space fully.

//1
let minuteRange = 75.0

// 2
func minuteLength(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
  // 3
  let pointsPerMinute = proxy.size.width / minuteRange
  // 4
  return Double(abs(minutes)) * pointsPerMinute
}
func minuteOffset(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
  let pointsPerMinute = proxy.size.width / minuteRange
  let offset = minutes < 0 ? 15 + minutes : 15
  return CGFloat(offset) * pointsPerMinute
}
HStack {
  Text("\(history.day) day(s) ago")
    .frame(width: 110, alignment: .trailing)
  // 1
  GeometryReader { proxy in
    Rectangle()
      .foregroundColor(history.delayColor)
      // 2
      .frame(width: minuteLength(history.timeDifference, proxy: proxy))
      .offset(x: minuteOffset(history.timeDifference, proxy: proxy))
  }
  // 3
}
.padding()
.background(
  Color.white.opacity(0.2)
)
Chart better filling view
Vvozk yiyquy botqahy yaob

Using gradients

A solid color fill works well for many cases, but you’ll use a gradient fill for these bars instead.

func chartGradient(_ history: FlightHistory) -> Gradient {
  if history.status == .canceled {
    return Gradient(
      colors: [
        Color.green,
        Color.yellow,
        Color.red,
        Color(red: 0.5, green: 0, blue: 0)
      ]
    )
  }

  if history.timeDifference <= 0 {
    return Gradient(colors: [Color.green])
  }
  if history.timeDifference <= 15 {
    return Gradient(colors: [Color.green, Color.yellow])
  }
  return Gradient(colors: [Color.green, Color.yellow, Color.red])
}
Rectangle()
  // 1
  .fill(
    // 2
    LinearGradient(
      gradient: chartGradient(history),
      // 3
      startPoint: .leading,
      endPoint: .trailing
    )
  )
  // 2
  .frame(width: minuteLength(history.timeDifference, proxy: proxy))
  .offset(x: minuteOffset(history.timeDifference, proxy: proxy))
Chart with gradient
Nxohq mikc bmuxoebh

Adding grid marks

Charts typically provide indicators for the values shown in the chart. These lines, known as grid marks, make it easier to follow the chart without displaying each value. These marks help the user better understand the magnitude of the values and not just the relationship between values.

func minuteLocation(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
  let minMinutes = -15
  let pointsPerMinute = proxy.size.width / minuteRange
  let offset = CGFloat(minutes - minMinutes) * pointsPerMinute
  return offset
}
// 1
ForEach(-1..<6) { val in
  Rectangle()
    // 2
    .stroke(val == 0 ? Color.white : Color.gray, lineWidth: 1.0)
    // 3
    .frame(width: 1)
    // 4
    .offset(x: minuteLocation(val * 10, proxy: proxy))
}
Chart with grid marks
Plalh kiql wkev zikbd

Using paths

Sometimes you want to define your own shape, and 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 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: Color(red: 0.5, green: 0, blue: 0))
  ]

  // 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 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
Jae mjipf

Adding a legend

One more touch to add. The chart looks good, but it needs some indication of what each color means. 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
Que bquqz jarn pamizt

.font(.footnote)
Resized pie chart legend
Hedejan xiu hwirr nakojc

.rotationEffect(.degrees(-90))
Rotated pie chart
Vujidas jua pbimq

Fixing performance problems

By default, SwiftUI renders graphics and animations using CoreGraphics. SwiftUI draws each view individually on the screen when needed. Modern Apple devices 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 seems 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
Ulerfj cfekesf zremf

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.
  • You can fill shapes with solid colors or with a defined gradient.
  • Gradients can transition in a linear, radial or angular manner.
  • GeometryReader gives you the dimensions of the containing view, letting you adapt graphics to fit the container.
  • Paths give 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 reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now