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

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.

For this chart, you know you have a fixed range. Flights will always be between 15 early and 60 minutes late, with anything beyond those values truncated at the limit. That gives you a range of 75 minutes (60 - -15). If you did not know the data range beforehand, you would need to scan it to determine the range required.

Add the following code above the minuteOffset(_:) method you previously added:

//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
}

This code should look similar to the minuteOffset(_:) method. It calculates the length of the bar to represent the minutes. Here’s how:

  1. You’re adding a constant that holds the range that the chart will graph. If you didn’t know this from the data, you would need to calculate it by examining it.
  2. In addition to the minutes you want the bar to represent, you also pass in a GeometryProxy. The GeometryReader passed in this structure to the closure. It provides access to the size and coordinate space of the GeometryReader.
  3. The size property of the proxy provides access to the size of the container view. Here you take the width of that view and divide by the range of the chart values. The result gives you the number of points you can allocate for each minute to fill the view.
  4. The first part of this multiplication works as before. It takes the magnitude of the minutes and converts it to a GFloat value. It then multiplies that by the amount calculated in the previous step to get the number of points representing the number of minutes passed into the method.

Now change minuteOffset(_:) to:

func minuteOffset(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
  let pointsPerMinute = proxy.size.width / minuteRange
  let offset = minutes < 0 ? 15 + minutes : 15
  return CGFloat(offset) * pointsPerMinute
}

Nothing different here. You’ve added the same calculation to determine the number of points to represent a minute on the chart. Then you calculate the offset as before and multiply it to convert the minutes to a number of points. Now to use these calculations. Change the HStack within the ForEach to:

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

You’re adding the GeometryReader and changing the Rectangle modification to use the new calculated values for the frame and offset.

  1. You set the GeometryReader after the Text view. If you included the Text within the reader, it would expand to fill the entire HStack, and you would need to change your calculations to take into account the space occupied by the text. You use the GeometryProxy passed into the closure as proxy to access information about the view.
  2. You’ve changed the frame and offset for the view to use the new methods. Note you pass the proxy into both so the method can determine the size of the view and calculate accordingly.
  3. More what’s missing. The Spacer() view isn’t needed anymore. Remember that a GeometryReader fills the container meaning it will fill all space in the HStack after the text.

Run the app, and you’ll see the graph now better fills the available space.

Geometry chart
Geometry chart

With the bars better fitting the view, you’ll add a bit more color to the graph.

Using gradients

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

You want each bar to gradually change from green to the final color, which may also be green meaning no change. Add the following method after the minuteOffset method:

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

This method returns a gradient consisting of colors from green through the other colors for different types of delays. Having different types of gradients won’t matter when you add the gradient to the rectangle. Change the rectangle for the bar to:

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))
  1. Changing to use the fill method allows you to specify a gradient.
  2. A linear gradient provides a smooth transition between colors along a straight line through an object — in this case, the rectangle. SwiftUI provides other gradients that change from a central point or sweeping around a central point.
  3. The values for startPoint and endPoint use a UnitPoint struct. This struct scales a range of values into a zero to one range, making it easier to define a range without worrying about the exact values. UnitPoints origin coordinate is at (0, 0) in the top-left corner of the shape and increases to the right and downward. The .leading and .trailing static types correspond to points at (0, 0.5) and (1.0, 0.5).

Run the app, and your bars now transition from green to the appropriate color to match the delay. The information is the same, but the gradient makes it feel a bit more dynamic.

Chart gradient
Chart gradient

With the bars looking a bit more dynamic, you’ll next add chart marks to help the user better see the value each bar represents.

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.

To determine the location of the grid mark for a given minute, you’ll need another method. Add the following code after the chartGradient(_:) method:

func minuteLocation(_ minutes: Int, proxy: GeometryProxy) -> CGFloat {
  let minMinutes = -15
  let pointsPerMinute = proxy.size.width / minuteRange
  let offset = CGFloat(minutes - minMinutes) * pointsPerMinute
  return offset
}

The main difference between this and the other methods you’ve used to convert minutes into some drawing dimension is the calculation’s direct nature. You want the number of points that correspond to the number of minutes. First, you define the minimum value that the number of minutes can be, in this case, -15. You then subtract this value from the number of minutes passed into the method, which means the minimal value (-15) will now be zero (-15 - (-15) = -15 + 15 = 0). Afterward, you change from minutes into points by multiplying by the calculated pointsPerMinute value as before.

Now add the following code to the end of the GeometryReader closure:

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

Here’s what you’re doing:

  1. A ForEach loop doesn’t work directly with more complex such as a stride that could create a set from -10 to 50 by steps of ten. So you use a simple range with the integers -1 through 5 and will multiply to get the desired values later.
  2. Instead of filling the rectangle, you will use stroke here. A stroke traces the outline of the shape with the specified color and line width. In this case, for the zero point, you use black to help it stand out. The other grid lines are gray.
  3. Setting the frame’s width to one turns the rectangle into a line since the rectangle will only be one point wide.
  4. You first multiple the value in the loop by ten. This changes the -1, 0, 1, 2, 3, 4, 5 values of the loop to -10, 0, 10, 20, 30, 40, 50. You use the new minuteLocation(_:proxy:) method to determine each mark’s offset.

Run the app, and you’ll see the grid marks show clearly. Notice the grid marks show on top of the bars since you draw them after the bars in the view. There’s no need to wrap the two elements inside a ZStack when using shapes inside a GeometryReader.

Chart grid
Chart grid

You only used one shape for this chart, but SwiftUI provides several more shapes:

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

All scale and other effects work within the framing view, just as the rectangle does. You’ll find that combining these shapes can produce very intricate drawings and results.

When you need more than shapes can provide, 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 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.

In this section, you’re going to use paths to add a pie chart that shows the breakdown of flight delays into broad categories. The categories you’ll use are:

  1. On-time — flights that are on-time or early
  2. Short delay — a delay of 15 minutes or less
  3. Significant delay — a delay of 15 minutes or more
  4. 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
)

You first need a struct to define the information for each segment of the pie chart. 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 of the view:

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 that you’ll display. Add the following computed property after the previous four:

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

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 number of flights that match each category. You divide that number by the array’s total number of elements to get a fraction of the flights that meet that 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 that have no matching values.

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

There’s a lot here. This view loops through the segments of the pie and draws each. You draw segment after the previous segment ends. A complication arises in that angles inside a path used with an arc increase counterclockwise. You though 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 a size of the pie chart using the GeometryProxy. You start by finding the smaller 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 zero angle is along the direction the x value increases in the view. As mentioned above, you start at 360 so you can subtract angles, which will make 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 sweeps 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 — here 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 next segment of the pie should appear at the end of this one, so you update the startAngle variable to match this segment’s ending angle.

  11. Last, 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 will see the pie chart at the bottom of the history chart.

Pie chart
Pie chart

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

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

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.

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

Pie legend
Pie 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:) method:

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

Pie legend
Pie 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 simpler way is to rotate the finished path. Add the following method after the foregroundColor(_:) call:

.rotationEffect(.degrees(-90))

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

Rotated chart
Rotated chart

Having created a pair of complex views using shapes and paths to create graphics, you’ll now look a bit about 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. 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.

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 rendering complex views. Note that offscreen composition adds overheard and results in slower performance for simple graphics. Using many gradients, shadows and other effects to your drawings will most likely result in performance problems.

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

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.

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.raywenderlich.com/6398124-swiftui-tutorial-for-ios-creating-charts

The classic text Computer Graphics: Principles and Practice by John F. Hughes, et al. provides a very nice overview of most graphics topics when you need to build graphics beyond Apple’s frameworks.

The following two chapters will continue to build on this project by adding animations and showing you more ways to make views designed for reuse. See you there!

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.