18.
Drawing & Custom Graphics
Written by Bill Morefield
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.
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.
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.
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.
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:
-
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
firstStoreOffsetto 140.0 for the right side of the image and -140 for the left side of the image. You also setdirectionto negative one for Terminal A and one for Terminal B. You’ll see how you use this in step four. -
You loop through the
indicesof thestorescomputed property. You use the indices instead of accessing the objects directly because you’ll use theindexlater. -
You get the store object at position
index. Note that if you didn’t need the index, you could iterate overstoresand passstoreas the parameter to the closure. -
You specify the location of shapes relative to the top-leading corner. To calculate each store’s horizontal position, convert the
indexto a double and multiply it by thedirectionabove. Whendirectionis one (Terminal B,) this calculation will give you a sequence of 0, 1, 2, etc. as the index increases, and 0, -1, -2, etc. whendirectionis negative one (Terminal A). You multiply this by85.0to offset each successive store an additional 85 points in steadily decreasing (Terminal A) or increasing (Terminal B) values. You then addfirstStoreOffsetto place the first store in the desired location. -
The
RoundedRectangleshape produces a rectangle with rounded corners. Here you provide acornerRadiusparameter to tell SwiftUI the radius of the curve in each corner. -
The
howBusyproperty of thestoreobject 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 theColorinitializer that takes a hue, saturation, and brightness instead of the more common red, blue, and green components. Ahuevalue of 120 represents green. You then set thesaturationandbrightnessto one minus thehowBusyproperty 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. -
You use an
overlay(_:alignment:)modifier to add a view on top of theRoundedRectangle. Here, you add a Text view showing theshortNameof the store inwhitein thefootnotefont. Theshadow(color:radius:x:y:)modifier adds dark color around the text to help it stand out against lighter backgrounds. -
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 theoffset(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.
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:
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.
First, wrap the entire body of the TerminalStoresView with:
GeometryReader { proxy in
// Current body of the view
}
This code places the entire body of the view in a GeometryReader. Wrapping a view within a GeometryReader causes two immediately noticeable effects. The view now takes up the entire available space within the container view and shifts the origin from the center to the top-leading point of the view. This shift of the origin changes the locations of the shapes, as you can see in the preview.
The parameter to the closure of the GeometryReader named proxy is of type GeometryProxy and provides access to the view’s size. The proxy gives you access to the view size, and you can use it to adapt your shapes to the view size. Add the following after comment one and before the existing two variables:
let width = proxy.size.width
let height = proxy.size.height
let storeWidth = width / 6
let storeHeight = storeWidth / 1.75
let storeSpacing = width / 5
This code stores the width and height of the view from the size property of the GeometryProxy. You use these values to calculate storeWidth, a width for each store that’s one-sixth the width of the view. You determine a storeHeight to match the previous 70/40 width-to-height ratio. Finally, you calculate a storeSpacing at one-fifth of the view width to represent the separation between adjacent stores.
Now update firstStoreOffset to:
let firstStoreOffset = flight.terminal == "A" ?
width - storeSpacing :
storeSpacing - storeWidth
To calculate firstStoreOffset, you have separate cases for each terminal. For Terminal A, you want the store to appear at the left of the image, so you set it to the full width of the view and subtract storeSpacing so it will appear on the view’s right side. For Terminal B, you want to offset the view by the difference between storeWidth and storeSpacing between stores.
Notice that you made no change to the direction variable because it’s not reliant on the view’s size. Next, change the xOffset under comment four to:
let xOffset = Double(index) * storeSpacing * direction + firstStoreOffset
This change replaces the 85.0 with storeSpacing calculated above. Now replace the modifiers under comment eight to:
.frame(width: storeWidth, height: storeHeight)
.offset(x: xOffset, y: height * 0.4)
These values now adapt the size and location of each shape to fit the view. Run the app, Tap Slight Status, and tap any flight in Terminal A. The results now look appropriate here as they did in the preview because your code adjusts for the view size.
Run the app on an iPad, and the shapes adjust to match the larger view.
In this section, you learned to adapt your shapes to work on any view using a GeometryReader. While shapes work well, sometimes you need more flexibility than shapes can provide. For those drawings, 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 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.
Earlier editions of this book demonstrated drawing by building charts. Along with SwiftUI 4.0, Apple introduced a Swift Charts API that handles many of these cases, and you’ll use it in Chapter 20: View Transitions & Charts to implement most of the same charts. However, it does not include support for pie charts, and in this section, you’ll use Paths to add a pie chart that shows the breakdown of flight delays into broad categories. The categories you’ll use are:
- On-time: Flights that are on time or early.
- Short delay: A delay of 15 minutes or less.
- Significant delay: A delay of 15 minutes or more.
- 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
)
First, you need a struct to define the information for each pie chart segment. 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:
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 you’ll display. Add the following computed property after canceledCount:
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 }
}
Here’s what this code is doing to define the segments:
- You start by getting the number of FlightHistory elements in the array.
- You use the previously created methods to count the flights matching each category. You divide that number by the array’s total number of elements to get a fraction of the flights that meet the criteria.
- You create an array where each element represents the indicated portion of flights matching the criteria.
- You return the array after filtering out any segments with no matching values.
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)
}
}
There’s a lot here. This view loops through the segments of the pie and draws each. You draw each segment after the previous segment ends. A complication arises in that, angles inside a path used with an arc increase counterclockwise. You 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:
-
You need to determine the size of the pie chart using the
GeometryProxy. You start by finding the smaller measurement 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. -
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.
-
You can define variables inside a
GeometryReader. Here, you create astartAnglevariable that will remain in scope for the rest of the view. The default angle of zero is along the direction the x value increases in the view. As mentioned above, you start at 360, so you can subtract angles, making the segments flow clockwise. -
You loop through the segments taking advantage of
PieSegmentimplementing theIdentifiableprotocol. -
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 sweep counterclockwise. -
The drawing begins. Declaring
Pathcreates an enclosure you use to build the path. -
The
move(to:)method on the path sets the starting location for the path, which, here, is the center of the view; amove(to:)call moves the current position but doesn’t add anything to the path. -
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
clockwiseparameter tells SwiftUI the arc begins at thestartAngleand moves clockwise to theendAngle. Note that you can use degrees or radians by using the corresponding initializer. -
You close the path, which adds a line from the current back to the path’s starting position.
-
Inside the path, you can update and set variables. The following pie segment should appear at the end of this one, so you update the
startAnglevariable to match this segment’s ending angle. -
Lastly, 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’ll see the pie chart at the bottom of the history chart:
You have a clear pie chart, but it’s unclear at a glance what the color of the segments represents. In the next section, you’ll add a legend to the chart.
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.
Open HistoryPieChart.swift. Wrap the GeometryReader that makes up the view inside an 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 using the footnote font.
Run the app, and you’ll see the legend makes it clear what each color represents:
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:) modifier:
.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:
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 more straightforward way is to rotate the finished path. Add the following modifier after the .foregroundColor(segment.color) call after comment 11:
.rotationEffect(.degrees(-90))
Run the app, and you’ll see the chart rotated one-quarter rotation counterclockwise. Yes, the direction of the angle when rotating in the view is the opposite of those used when drawing arcs.
Having created a pair of complex views using shapes and paths to create graphics, you’ll now look at 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. 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.
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 in rendering complex views. Note that offscreen composition adds overheard, resulting in slower performance for simple graphics. Using many gradients, shadows, and other effects will most likely result in performance problems.
Wait until you have a performance problem before turning to drawingGroup(). Remember that the drawingGroup() modifier only works for graphics — shapes, images, text, etc.
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.
You draw using the GraphicsContext passed into the closure. The closure to the view also receives a CGSize parameter describing the dimensions of the Canvas.
While view builder based views aren’t directly supported, SwiftUI provides a mechanism to reference and use SwiftUI views within a Canvas. In this section, you’ll build a simple Canvas view on the Awards page that uses a passed in SwiftUI view.
Create a new SwiftUI view named AwardStars.swift inside the AwardsView group. The view will have one property for the number of stars to display.
Add the following property to the top of the view:
var stars: Int = 3
This parameter will provide the number of stars to draw. Now change the view body to:
// 1
Canvas { gContext, size in
// 2
} symbols: {
// 3
Image(systemName: "star.fill")
.resizable()
.frame(width: 15, height: 15)
// 4
.tag(0)
}
You define a Canvas view that uses the symbols parameter of the initializer to pass a SwiftUI view into the Canvas:
- The closure receives a
GraphicsContextyou use to draw along with aCGSizeparameter with the dimensions of theCanvas. - The closure of the
Canvasisn’t a view builder, but the closure to thesymbolsparameter is. You pass SwiftUI views into the closure to reference them from inside theCanvasdrawing commands. - You define a SwiftUI
Imageof the star.fill symbol resized to 15 points square. - To access a SwiftUI view from within the
Canvasclosure, you must use thetag(_:)modifier to assign each view a unique identifier.
Now you can begin to fill in the closure of the Canvas. Replace comment two with the following code:
guard let starSymbol = gContext.resolveSymbol(id: 0) else {
return
}
You use the resolveSymbol(id:) method on the GraphicsContext to look for a symbol with the 0 tag. The guard means the method returns if no matching symbol exists. You can just return because the inside of the Canvas closure executes in immediate mode instead of creating a view builder, meaning all parts of the Swift language are available.
Next, add the following code to the Canvas closure:
// 1
let centerOffset = (size.width - (20 * Double(stars))) / 2.0
// 2
gContext.translateBy(x: centerOffset, y: size.height / 2.0)
These adjust the canvas to center the stars that you’ll draw.
- The
widthproperty on thesizeparameter contains the width of the canvas. You subtract from the width the number of stars multiplied by 20 as each star will take 20 points of space, including padding. The result will give you the remaining space left in the view outside the stars. Dividing this by two splits the space in half, giving you the position to place the first star to center them horizontally. - The
translateBy(x:y:)shifts the origin of the canvas by the provides points in each direction. The default origin lies in the top right of theCanvas. You move the horizontal position by the number of points calculated in step one horizontally and half the height of the canvas vertically.
With the drawing position shifted you can now loop and draw the stars. Add the following code to the end of closure:
// 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)
}
Here are the steps to draw the stars:
-
Since you’re not in a view builder, you use a standard
for-inSwift loop instead of aForEachview. -
You’ll offset each view by 20 points. Remember you sized the symbol to 15 points, so this gives five points of space between stars.
-
In a
Canvas, you’ll use Core Graphics drawing data types and structures. You create aCGPointwith thexposition from step two and zero for theyposition. With the translated offset from earlier, these values are relative to the new origin. -
The
draw(_:at:anchor:)method takes the resolved symbol to draw, the point you want to draw the symbol and how that point relates to the symbol’s origin. Usingleadingmeans the point you pass acts as the leading point of the drawn symbol or at the vertical center of the leading edge. By default, SwiftUI would draw the symbol with the point as the center.
With the view completed, you can now add it to the Awards. Open AwardCardView.swift. Before the final spacer, add the following view:
AwardStars(stars: award.stars)
.foregroundColor(.yellow)
.shadow(color: .black, radius: 5)
.offset(x: -5.0)
You’ll set all awards to three stars. You set the foreground color of the view to yellow and add a small black shadow under the view to help the stars stand out. You apply a horizontal offset to account for the padding used on the view. Now run the app and navigate to the Your Awards page to view the results:
Key Points
- Shapes provide a quick way to draw simple controls. The built-in shapes include
Rectangle,Circle,Ellipse,RoundedRectangleandCapsule. - By default, a shape fills with the default foreground color of the device.
-
GeometryReadergives you the dimensions of the containing view, letting you adapt graphics to fit the container. -
Pathsgives 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
Canvasview provides a view focused on high-performance graphics. You can pass SwiftUI views to use in aCanvas, 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.
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.kodeco.com/6398124-swiftui-tutorial-for-ios-creating-charts.