Chapters

Hide chapters

SwiftUI by Tutorials

Third Edition · iOS 14 · Swift 5.3 · Xcode 12

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

17. 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 of SwiftUI offer. 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 how well a flight has been on time in the past.

Using shapes

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.

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 chapter.

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 get a sense of 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. 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.

One of the basic drawing structures in SwiftUI is the Shape, which is a set of simple primitives you use to build up more complex drawings. In this section, you’ll use them to create a horizontal bar graph.

Open FlightTimeHistory.swift in the SearchFlights group. You’ll see the view currently uses a ForEach loop to display how close to the flight’s scheduled time it arrived for the previous ten days.

Since a bar chart is made of bars, the Rectangle shape is perfect to use to create one. Replace the HStack inside the loop to:

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

You’ve changed a few things in the view.

  1. You’ve changed the text only to show the date.
  2. You’ve also set a static frame. This change will allow the text displays to all take the same space, making it easier to line them up.
  3. You set the remainder of the stack to a Rectangle shape. You use foregroundColor(_:) to set the rectangle’s color to help indicate the delay’s severity.

Run the app, and you’ll see the result is a little underwhelming since the rectangles all fill the view and don’t show any additional information.

Initial rectangle
Initial rectangle

A shape is a special type of view. Therefore you adjust it as you would any other view. To change the width of the Rectangle to reflect the length of the delay, you’ll add the frame(width:height:alignment:) instance method setting the width. Add the following code after setting the foregroundColor for the rectangle:

.frame(width: CGFloat(history.timeDifference))

Note the need to cast the timeDifference integer property to CGFloat. Drawing code in SwiftUI is very sensitive to types. If you pass a type other than CGFloat as a position, you’ll often get odd compilation errors or the dreaded unable to type-check this expression in a reasonable time error.

Since you specify the width, the rectangle and view no longer take the full width. To fix this add a spacer view after the Rectangle so your HStack looks like this:

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()
}

Run the app, and you’ll see things look somewhat better as the lengths now reflect the delay’s length.

Poor graph
Poor graph

There are still some issues. Since flights can be early, some of the values can be negative. In those cases, this code attempts to set a negative frame. That’s not allowed, so you’ll notice that any flight that arrived early will result in a non-fatal exception in your app and no bar shows for those values.

Negative frame
Negative frame

Fixing this is a bit more difficult than you might initially think. You know the maximum value you need to show is a flight fifteen minutes early. Does that mean you can add 15 points to each value’s width to get the correct length?

No. A fifteen-minute early flight should be only 15 points wide, just as a flight 15 minutes late would only be 15 points wide. Instead, these early flights need to run to the left from the zero point and values to the right increase from that zero point. Change the frame for the Rectangle to:

.frame(width: CGFloat(abs(history.timeDifference)))

As noted, a fifteen-minute delay should generate the same width bar, whether it’s negative or positive. You use the abs(_:) function from Foundation to get the absolute value of the minutes — that is, the magnitude of the number without the sign. So -15 and 15 both have the absolute value of 15.

You still need to deal with offsetting the bars to allow space for negative values. To keep the view cleaner, add the following method after the flight property:

func minuteOffset(_ minutes: Int) -> CGFloat {
  let offset = minutes < 0 ? 15 + minutes : 15
  return CGFloat(offset)
}

This method uses the ternary operator. If the number of minutes is less than zero, it adds 15 to the number of minutes. If the number of minutes is zero or greater, then it returns 15. That will shift any negative value so that the right edge will be at the “zero” point, and any positive value will start at that “zero” point.

Add the following code to the rectangle after the frame(width:height:alignment:) call:

.offset(x: minuteOffset(history.timeDifference))

This code shifts the rectangle horizontally by the amount calculated using your minuteOffset(_:) method.

Run the app, and your chart looks much better.

Chart offset
Chart offset

Your chart now clearly shows the relative differences in time for each day, but it doesn’t take advantage of the view’s full size or adjust to different sized displays. In the next section, you’ll add those features using one of the most valuable helpers when creating graphics in SwiftUI — GeometryReader.

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 = CGFloat(75)

// 2
func minuteLength(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
  // 3
  let pointsPerMinute = proxy.size.width / minuteRange
  // 4
  return CGFloat(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)
)
Geometry chart
Xueciqtc zrisr

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 gradient
Vlupn mqijaosm

Adding grid marks

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

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 grid
Zpuyd tjoh

Using paths

Sometimes you want to define your own shape, and not use the built-in ones. For this, you use Paths, which allow 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
Soi wrewm

Adding a legend

One more touch to add. The chart looks good, but it needs some indication of what each colors means. 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 a 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)
    }
  }
}
Pie legend
Wuo pufeqx

.font(.footnote)
Pie legend
Dia vikony

.rotationEffect(.degrees(-90))
Rotated chart
Hihobap htojs

Fixing performance problems

By default, SwiftUI renders graphics and animations using CoreGraphics. SwiftUI draws each view individually on the screen when needed. The processor and graphics hardware inside modern Apple devices 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.

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 shapes and fill on paths as you do with shapes.
  • Using drawingGroup() can improve the performance of graphics-heavy views, but should only be added when performance problems appear as it can slow rendering of simple graphics.

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