Chapters

Hide chapters

macOS Apprentice

Second Edition · macOS 15 · Swift 5.9 · Xcode 16.2

Section II: Building With SwiftUI

Section 2: 6 chapters
Show chapters Hide chapters

Section III: Building With AppKit

Section 3: 6 chapters
Show chapters Hide chapters

9. Charting Your Progress
Written by Sarah Reichelt

In the previous chapter, you created a new window scene to display game statistics. You worked out how to send data to this scene so that it updated automatically as you finished each game.

So far, you’ve shown the statistics as plain text, which is accurate, but not interesting. It’s not even easy to comprehend at a glance.

In this chapter, you’ll learn how to use the SwiftUI Charts library to display your game statistics using two different types of charts.

Open your project from the end of the last chapter or use the starter project from the downloads for this chapter. Run the app, play a few games and then press Command-T to open the Statistics window and see the current display:

Statistics as text.
Statistics as text.

This window could certainly do with an upgrade. :]

Preparing the Data

To draw a chart, you start with data points. Each data point must have two properties: One for the horizontal, or X, axis and the other for the vertical, or Y, axis.

Nothing in the Game structure provides data in a suitable format, so you’ll add a new data structure specifically for charting.

Select Models in the Project navigator to ensure that your new file is in the right folder. Now, right-click and choose New File from Template… — yet another way to add a file. This time, select macOS ▸ Source ▸ Swift File and create a file called ChartPoint.swift.

Add this to your new file:

// 1
struct ChartPoint: Identifiable {
  // 2
  let id = UUID()
  // 3
  let name: String
  let value: Int
}

What does this structure do?

  1. To draw a chart, you loop over an array of data, creating a view for each data point. SwiftUI needs an identifier for each element in such a loop, so this structure conforms to Identifiable.
  2. Identifiable requires a property called id. Here, you set this property to a UUID. The ChartPoint initializer creates one for every new instance.
  3. The remaining two properties define what appears in the chart. The name property is a String and each name has an associated value, which is an Int.

In previous Identifiable structures, you’ve used integer ids. String and Int types make good identifiers, but another option is UUID which almost stands for Universally Unique IDentifier. :] This is a “128-bit value guaranteed to be unique over both space and time”. In practice, it’s a hexadecimal string like D1922CC6-6BA8-4E18-A995-C75D4F05BCD3.

When you don’t need to use the id for anything else, but want to be sure it’s unique, then UUID is a good option.

Now that you’ve set up ChartPoint, you can start to use it.

Creating Chart Points

Open Views/Statistics/GameStats.swift and look at where you defined gameReport. This uses the data in games and produces a computed String for display. Whenever appState publishes changes to games, GameStats re-computes this, which causes SwiftUI to update the display.

You’ll use a similar technique for the charts, but this time, you’ll use the data in games to compute an array of ChartPoint elements.

Add this new property to GameStats:

// 1
var gameStatsPoints: [ChartPoint] {
  // 2
  let wonGamesCount = games.count {
    $0.gameStatus == .won
  }
  let lostGamesCount = games.count {
    $0.gameStatus == .lost
  }

  // 3
  let chartPoints = [
    ChartPoint(name: "Wins", value: wonGamesCount),
    ChartPoint(name: "Losses", value: lostGamesCount)
  ]

  // 4
  return chartPoints
}

Stepping through this code:

  1. The new computed property is an array of ChartPoint elements.
  2. Use the same code you used in gameReport to count the won and lost games.
  3. Create an array with two elements. The first one has its name property initialized to Wins and value set to the number of games the player has won. The second holds the lost game data.
  4. Return this array as the value for the computed property.

And with this in place, you have the data you need to show a chart.

Displaying a Chart

Still in GameStats.swift, start at the top by adding this import:

import Charts

You won’t be able to create any charts without including the Charts library in your project.

And now, you get to draw your first chart. In body, replace Text(gameReport) with:

// 1
Chart(gameStatsPoints) { point in
  // 2
  BarMark(
    // 3
    x: .value("Count", point.value),
    // 4
    y: .value("Name", point.name))
  // 5
  // bar modifiers here
}
// chart modifiers here

There’s a lot of new code there:

  1. Initialize a Chart, passing in the array of data points. This loops through them and each time through, point contains the current ChartPoint.
  2. For each point, initialize a BarMark. This is the structure that creates a single bar for a bar chart.
  3. Set the x property for the BarMark to a PlottableValue. A PlottableValue has a label and a value. In this case, the label is Count and the value is the ChartPoint’s value property.
  4. Set the y property to another PlottableValue, using Name for the label and ChartPoint’s name for its value.
  5. You’ll add modifiers to style each bar and the chart as a whole later.

Press Command-R to build and run the app. Play a few games and open the Statistics window:

A basic bar chart
A basic bar chart

And there it is — a bar chart showing how many games you’ve won and how many you’ve lost.

Note: When you provided the gameStatsPoints array to Chart you used a convenient shortcut. In the long form, you start with a Chart and then use a ForEach to step through the array. This is such a common use case that the SwiftUI Charts team allows us to combine the two. You’ll see the longer version later in the chapter.

Now that you have a basic chart, there are things you can add to make it look even better.

Providing Preview Content

It gets a bit tedious having to run the app and play a few games to see any statistics data. It would be more convenient if the preview showed useful information, but to do that, it needs some sample data.

Open Game.swift, scroll to the end, past the final closing brace, and add this:

// 1
extension Game {
  // 2
  static var sampleGames: [Game] {
    // 3
    var game1 = Game(id: 1)
    game1.word = "SNOWMAN"
    game1.gameStatus = .lost

    // 4
    var game2 = Game(id: 2)
    game2.word = "FROST"
    game2.gameStatus = .won

    var game3 = Game(id: 3)
    game3.word = "ANTARCTICA"
    game3.gameStatus = .won

    // 5
    return [game1, game2, game3]
  }
}

What’s happening here?

  1. Create an extension to Game. This code is part of Game, but it’s separated for organizational reasons.
  2. A static property or method is one that belongs to the class or structure, not to an instance of that class or structure. This allows you to use Game.sampleGames to query Game for an array of games.
  3. Initialize a Game with an id and then set its word and gameStatus properties.
  4. Repeat this to make two more sample games.
  5. Assemble them into an array and return it.

To use this property in the GameStats preview, return to GameStats.swift and change the contents of #Preview to:

GameStats(games: Game.sampleGames)

Resume the preview to see this:

Previewing the bar chart.
Previewing the bar chart.

And now it’s much faster to see the result of any changes you make to the chart.

Styling the Bar Chart

The bars in the chart are blue by default. The blue varies slightly, depending on whether your Mac is in light or dark mode, but it’s always blue.

To change this color, stay in GameStats.swift, and replace // bar modifiers here with:

.foregroundStyle(.red)

Check the preview now to see two red bars.

Both bars are the same color, whether you set a foregroundStyle or not, but having each bar use a different color would be more effective.

Varying the Colors

There are a couple of ways you can do this. For the first, replace the current foregroundStyle modifier with:

.foregroundStyle(by: .value("Name", point.name))

Checking the preview now, you see a blue and a green bar. And as the bars are now visually different, the Charts library has added a legend at the bottom:

Colored bars and legend
Colored bars and legend

So it worked, but how? By default, a BarMark has a foregroundStyle of Color.blue. This version of the modifier tells the bar to vary its color based on a PlottableValue. Here it uses the value of its data point’s name property, so every time name changes, the color also changes. The legend shows the name properties for the two ChartPoint objects you used to populate the chart.

The Charts library allocates up to seven different colors. If your chart has more than seven bars, the colors start to repeat.

This is a great method if you want the bars different, but don’t care what colors they use. But in the main window, you use green text to symbolize a win and orange text for a loss, so it would be more consistent to repeat those colors here.

This requires a modifier for the chart itself. Replace // chart modifiers here with:

// 1
.chartForegroundStyleScale([
  // 2
  "Wins": Color.green.gradient,
  "Losses": Color.orange.gradient
])

What does this do?

  1. chartForegroundStyleScale is a modifier that takes a set of key-value pairs which is effectively a dictionary.
  2. The keys represent the data points. When you set the y property for the BarMark, you linked it to the name property of the ChartPoint, so this is what identifies each bar. The values are the ShapeStyle to use for each bar. This time, as well as having colors, they each include a gradient. In SwiftUI, you can add .gradient to any color to add a subtle gradient.

And now the preview looks like this:

Defining bar colors.
Defining bar colors.

Note: This Chart modifier overrides the individual BarMark modifier. Leave the foregroundStyle modifier in as a reference and a placeholder, but it no longer does anything.

Adding More Style

There are a few more modifiers that’ll make your chart stand out. These all go after the chartForegroundStyleScale modifier.

First, you don’t want to let the user make the chart too small, so add this:

.frame(minWidth: 350, minHeight: 300)

You’re familiar with frame modifiers already. This one lets the Chart get as big as the user wants, but stops it getting any smaller than 250 x 300.

After that, the next thing to add is:

.padding()

The chart was filling all the available space and pushing it right to the edges. Adding some padding makes it look better.

Finally, to make those bars really stand out, add this:

.shadow(radius: 5, x: 5, y: 5)

This adds a shadow to each bar with a radius of 5. The shadow is offset to the right and down to give a more 3D effect.

Bars with shadows and padding.
Bars with shadows and padding.

Look back to see where you started with this chart. By adding a few modifiers, you’ve made a great looking chart.

Annotating the Bars

Your chart looks impressive, but there’s more you can do. Annotations are a way to add more textual information to the chart.

Still in GameStats.swift, add this after BarMark’s foregroundStyle modifier:

// 1
.annotation(
  // 2
  position: .overlay,
  // 3
  alignment: .leading,
  // 4
  spacing: 20) {
    // 5
    Text("\(point.name): \(point.value)")
      .font(.title2)
  }

Taking this line by line:

  1. The annotation modifier adds a view to each BarMark.
  2. There are a lot of position options. To see them all, delete overlay and press Escape with the cursor after the period. Then, choose overlay again to place the annotation view over the bar.
  3. Like position, alignment has many alternatives. leading puts the annotation at the start of the bar.
  4. With no spacing, the annotation would start right at the edge of the bar, but the spacing argument insets it.
  5. The content of the annotation is a Text view which uses string interpolation to combine the name and value. The font modifier makes this text larger than usual.

With this in place, the legend is now unnecessary, so add this modifier after the shadow:

.chartLegend(.hidden)

And after all that work, you end with this:

Final bar chart design
Final bar chart design

Now that you’ve finished the GameStats chart, it’s time to move on to WordStats and draw a different style of chart.

Preparing Line Chart Data

You know how to create a bar chart, but SwiftUI can create many different types of charts. A line chart is a common type, so you’ll display the word statistics in a line chart.

Conveniently, the SwiftUI Charts library works much the same for every type of chart.

Open WordStats.swift and add this computed property:

// 1
var wordStatsPoints: [ChartPoint] {
  // 2
  let completedGames = games.filter { game in
    game.gameStatus != .inProgress
  }

  // 3
  let chartPoints = completedGames.map { game in
    // 4
    ChartPoint(
      name: "#\(game.id)",
      value: game.word.count)
  }

  // 5
  return chartPoints
}

This prepares the line chart data points:

  1. Create a computed property to return an array of ChartPoints.
  2. As with the text version, get an array of completed games.
  3. Use map to convert the completed games into a array of data points.
  4. Initialize each ChartPoint with the id of the game as part of the name and the length of the word as its value.

The bar chart only ever has two data points, but this chart has a data point for every completed game.

Now you have an array of ChartPoints, you can draw the chart. As before, add this at the top of the file:

import Charts

So you can see some results, scroll down to the PreviewProvider and change the contents of #Preview to:

WordStats(games: Game.sampleGames)

And with the data in place, you’re ready to draw some lines.

Drawing a Line Chart

In body, replace Text(wordCountReport) with:

// 1
Chart {
  // 2
  ForEach(wordStatsPoints) { point in
    // 3
    LineMark(
      // 4
      x: .value("Game ID", point.name),
      // 5
      y: .value("Word Count", point.value))
    // line modifiers here
  }
  
  // another mark here
}
// chart modifiers here

Most of this is familiar:

  1. Create a Chart. This time, you aren’t using the shorthand method of passing data directly to it.
  2. Pass the array of ChartPoints to a ForEach loop. Each time through the loop, point contains the data point to draw.
  3. For each data point, initialize a LineMark.
  4. Like a BarMark, this needs a PlottableValue for x which uses the point’s name.
  5. And for y, use the point’s value.

Resume the preview to see this:

Basic line chart
Basic line chart

You have a basic line chart, so now you can style it.

Modifying the Line Chart

The chart goes right to the edge of the view and crops some numbers, so to start, add these after // chart modifiers here:

.frame(minWidth: 350, minHeight: 300)
.padding()

This sets the same minimum height and width as the bar chart which is a good technique because it stops the window changing size as the user switches tabs.

The padding moves the content away from the edges so that all the numbers are now visible.

The Y-axis starts at zero. This is perfect for a lot of charts, but for this one, you know that the minimum number of letters per word is three or higher, so there’s a lot of wasted space at the bottom of the chart.

The Charts library adjusts the axes automatically to fit, but you can tell it not to include the zero point for the Y-axis.

Replace // chart modifiers here with:

.chartYScale(domain: .automatic(includesZero: false))

This is a strange line of code:

  1. The chartYScale modifier configures the scale of the Y-axis.
  2. It can take various arguments, but you’re using domain.
  3. The domain argument is a ScaleDomain, which is a protocol that sets the span for the axis.
  4. The domain is always automatic, which means that the Charts library works it out for itself based on the data, but you can set various conditions.
  5. The includesZero argument is true by default, but setting it to false tells the library that it does not have to show the zero point on the axis if the data doesn’t warrant it.

Note: There are a lot of modifiers that are similar for both axes. You’d use chartXScale to configure the scale on the X-axis.

Checking the preview now, it already looks better:

Line chart with padding and axis setting.
Line chart with padding and axis setting.

You’ve configured the chart, so it’s time to improve the lines.

Configuring the Lines

By default, the line has a thickness of 2. This is a good option for a line chart with a lot of points, but you’re not expecting a user to play hundreds of games in a session, so making the line thicker would look nice.

Replace // line modifiers here with:

.lineStyle(StrokeStyle(lineWidth: 4))

Another line that packs in a lot of detail:

  1. The lineStyle modifier styles each LineMark.
  2. Its argument is a StrokeStyle.
  3. The StrokeStyle initializer can take a lot of optional parameters, but the only one you’re changing here is the line width.
  4. Specifying a lineWidth of 4 makes each line twice as thick as usual.

So far so good, but in this chart, it’s the points between each line that are important, not the lines themselves. You can make them stand out by adding a symbol.

After the lineStyle modifier, add:

.symbol(.diamond)

The symbol modifier sets the shape that Charts draws at each point. There are a bunch of basic shape options. Delete diamond and press Escape with the cursor after the period to see them all. Select diamond when you’ve finished looking.

Look at the preview now and you’ll see a small blob at each point. They’re too small to distinguish any shape, so you need to make them bigger.

Add another modifier after symbol:

.symbolSize(200)

The symbolSize dictates the size of every symbol. This seems like a very large number, but it specifies the area of the symbol and not its width or height. An area of 200 means it covers a square roughly 14 high and 14 wide.

And now the preview shows this:

Styling the lines.
Styling the lines.

This looks good, but is there any way to include win/loss data?

Adding Some Color

It’s not possible to vary the symbols or colors of individual points — you get separated lines. But you can use a conditional to adjust the color of the entire chart.

Add this new computed property:

// 1
var lineChartColor: Color {
  // 2
  let wonGamesCount = games.filter {
    $0.gameStatus == .won
  }.count
  // 3
  let lostGamesCount = games.filter {
    $0.gameStatus == .lost
  }.count

  // 4
  if wonGamesCount > lostGamesCount {
    return .green
  } else if wonGamesCount < lostGamesCount {
    return .orange
  }
  
  // 5
  return .blue
}

Working through this:

  1. The computed property returns a Color.
  2. Use count to count the number of games the player has won.
  3. Do the same for the lost games.
  4. If the player has won more than they’ve lost, return green, which is the color you’ve used already to indicate a win.
  5. If the player has lost more than they won, use the orange failure color.
  6. If the program flow makes it to here, the player has won and lost the same number of games, so use a neutral blue.

To apply this new property, add another modifier after symbolSize:

.foregroundStyle(lineChartColor)

In the preview, the color changes to green. Open Game.swift and check sampleGames. Two of these are wins and one is a loss, so the green is correct. Change game3.gameStatus to .lost and go back to WordStats.swift.

When the preview resumes, you’ll see:

Line colors
Line colors

Within the limitations of the line chart, you can still use color to provide information to the user. And that’s the key to any chart — design to enhance the readability of data, not to confuse or deceive.

Showing More Data

So far, the charts have displayed a single data set, but you can add more than one. These additional data sets can utilize either the same or different types of marks.

In the bar chart, you used the shorthand method of passing data to Chart. This is convenient, but restricts you to a single data set. The line chart uses the expanded method with ForEach and that lets you to add more data sets.

For this example, you’ll add a horizontal line showing the midpoint between the maximum and minimum word lengths.

Replace // another mark here with:

RuleMark(y: .value("Average", 7.5))

A RuleMark is a special type of mark that can draw a straight line on the chart. Using this initializer draws a horizontal line at the specified y value.

The words are all between 3 and 12 characters long, so the midpoint is 7.5.

And now, the preview shows:

Chart with a RuleMark.
Chart with a RuleMark.

Where you added RuleMark is where you could add another ForEach loop to insert lines, bars or any of the other mark types.

Accessibility

The SwiftUI Charts team made the Charts library accessible by default. To hear this in action, open System Settings. Go to Accessibility ▸ VoiceOver and turn on VoiceOver:

Turn on VoiceOver
Turn on VoiceOver

You’ll see a black box with white text that will describe what’s inside its focus box.

To test the default accessibility reports, run the app, play a few games and then go to Statistics ▸ Length of Words. Use Control-Option with the arrow keys to move the VoiceOver focus box around the window and hear what it says. When over a chart point you’ll hear something like “7 #2”.

Tip: If the focus box gets stuck on the tab buttons at the top, press Control-Option-Escape to change it. The VoiceOver box also provides hints about usage if you give it a second.

This speech gives the coordinates of each point, but you can adjust it to suit the app better.

To improve the VoiceOver content, insert this after the foregroundStyle line:

// 1
.accessibilityValue("Game \(point.name)")
// 2
.accessibilityLabel("had \(point.value) letters in the word")

This adjusts the speech by:

  1. Adding an accessibilityValue to each point. Think of this as being the text version of the x value.
  2. Setting an accessibilityLabel to each point that describes the y value.

Run the app again and after playing a few games, you’ll hear something like “Game number two had six letters in the word”, which provides a lot more information.

It’s great that charts are accessible by default, but sometimes the defaults need a bit of tweaking to make them more useful.

Turn off VoiceOver now and have a go at the challenges.

Challenges

Challenge 1: Flip the bars

The GameStats bar chart has horizontal bars. Can you change it so the bars are vertical?

Hint: x becomes y and y becomes x.

When you’ve done that, adjust the position of the annotation to suit the new orientation.

Challenge 2: Use different marks

You’ve used BarMark, LineMark and RuleMark but there are others. Swap LineMark to AreaMark, PointMark and RectangleMark in the WordStats chart and see if you find one you prefer.

Try to work these out for yourself, but if you get stuck, look in the challenge folder for this chapter.

Key Points

  • The SwiftUI Charts library allows you to display data graphically.
  • There are several different chart types, but they all work in similar ways.
  • Once you’ve drawn the chart, you can style the chart or the data points.
  • Accessibility is built-in, but you can customize it.

Where to Go From Here

There are some great videos from WWDC 2022 introducing the Charts library and discussing how to use it effectively:

In WWDC 2023 and 2024, Swift Charts got even more features:

After this diversion into charting, which isn’t Mac-specific, the next chapter takes you back into the Mac world. You’ll handle toolbars and menus and find out how to get your app out of Xcode and into your Applications folder.

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.