Categories
Uncategorized

Charts in SwiftUI – Part 2: Pie Chart

by Ahmed Mgua (@_mgua)

Charts are important visual elements that help present data to users in a meaningful way. Depending on the type of data, you may decide to use any of the common chart types, for example, bar charts, pie charts, and line charts. In this tutorial series, we will go over how to create the different types of charts in SwiftUI apps to help visualize different types of data.

In this part of our “Charts in SwiftUI” series, we’ll create a pie chart in SwiftUI. Pie charts are important for visualising data that exists in pieces that together make a whole. For example, we can use a pie chart for our SwiftUI app to analyse sales in a company per model. The total sales would represent the whole, while the contribution of each model represents a piece of the whole. In simple UI terms, what we’re doing here is creating a circle that can be built piece by piece, with each piece representing a specific value. 

Let’s have a look at the pie chart we’ll be creating in this tutorial: 

Let’s go ahead by creating a new SwiftUI project named “MyPieChart”.

Defining the look of a slice 🍕

To start with, let’s define how a slice of the pie chart should look like. 

To do this, let’s add a New-File-SwiftUI View file to our project and name it “PieChartSlice”.

For creating custom shapes, SwiftUI comes with a dedicated Path type. A Path is used for defining the outline of the shape you want.
When you think about it, to create a piece of a circle, we need to know four things

  • Where the centre of the circle is
  • The radius of the circle
  • The angle at which our piece begins
  • And the angle at which our piece ends

With this information, we would be able to create a piece of a circle that looks like this: 

Let’s go ahead and define such a Shape by adding the following properties to our PieChartSlice view.

struct PieChartSlice: View {
     var center: CGPoint
     var radius: CGFloat
     var startDegree: Double
     var endDegree: Double
     var isTouched:  Bool
     var accentColor:  Color
     var separatorColor: Color
     
     var body: some View {
         //...
     }
 }

There are a few properties on our view here, so let’s go over them. The first four properties have been outlined above. 

The isTouched property will be used to equip our pie slices with some “information highlight” functionality. This enables us to attach modifiers that are only active when the slice has been touched. As we did with the bar chart in the last chapter, we will use the .scaleEffect modifier later to enlarge the slice when being touched. Anyway, feel free to experiment with what works for you. 

The accentColor will be used to fill the slice, and the separatorColor to add a visible outline to our slice.

Let’s initialise a preview for our PieChartSlice by passing some dummy data into to PieChartSlice_Previews struct.

struct PieChartSlice_Previews: PreviewProvider {
     static var previews: some View {
         PieChartSlice(center: CGPoint(x: 100, y: 200), radius: 300, startDegree: 30, endDegree: 80, isTouched: true, accentColor: .orange, separatorColor: .black)
     }
 }

To create the slice itself, we need to create a Path instance first.

struct PieChartSlice: View {
     //...
     
     var path: Path {
         var path = Path()
         //...
     }
     
     var body: some View {
         //...
     }
 }

 A Path comes with a few static methods, three of which are of use to us here:

  • func addArc(center: CGPoint, radius: CGFloat, startAngle: Angle, endAngle: Angle, clockwise: Bool) – used to add an arc of a circle specified with the center, radius and angles where it begins and ends. All the parameters are properties of the view already so we can pass them in. The clockwise parameter determines in which direction our arc is drawn
  • func addLine(to: CGPoint) – used to add a line from the current point in our path to the specified point on the screen. We use it to add a line from the end of the arc we have created to the center of our circle
  • func closeSubpath() – used to close and complete the current subpath we have. We use it to draw a line from the center of the circle to the beginning of the arc, thus completing the slice

Let’s implement these functions into our Path property like this:

var path: Path {
     var path = Path()
     path.addArc(center: center, radius: radius, startAngle: Angle(degrees: startDegree), endAngle: Angle(degrees: endDegree), clockwise: false)
     path.addLine(to: center)
     path.closeSubpath()
     return path
 }

Now we are able to use this path for the body of our PieChartSlice view. Let’s add some modifiers to it to style it properly. As mentioned above we use the .scaleEffect modifier later to enlarge the slice when being touched.

var body: some View {
     path
         .fill(accentColor)
         .overlay(path.stroke(separatorColor, lineWidth: 2))
         .scaleEffect(isTouched ? 1.05 : 1)
         .animation(Animation.spring())
 }

Let’s check our preview simulator to see what we got so far:

Building the pie chart 🎂

Let’s build the pie chart step by step as we did with the bar chart in the last chapter. We’ll use the same data type we used in the last chapter, ChartData, which allows us to have organised data with labels and values. For this purpose, let’s create a new File-New-File-Swift file and name it “ChartData”. Let’s add the “ChartData” struct and an array holding some sample entries.

struct ChartData {
     var label: String
     var value: Double
 }
 

 

 let chartDataSet = [
     ChartData(label: "January 2021", value: 150.32),
     ChartData(label: "February 2021", value: 202.32),
     ChartData(label: "March 2021", value: 390.22),
     ChartData(label: "April 2021", value: 350.0),
     ChartData(label: "May 2021", value: 460.33),
     ChartData(label: "June 2021", value: 320.02),
     ChartData(label: "July 2021", value: 50.98)
 ]

Of course, you can use any type you have in your app which will come with a value and a label of some sort.

At this point, we also want to make sure to use our own custom colours for each PieChartSlice. If you prefer to generate random colours, check the PieChart.swift file in our GitHub project where we commented a way to achieve this. Anyway, let’s add another Swift file to our project and name it “Helper”. Make sure to import the SwiftUI framework.

Let’s implement a Color extension to initialise a certain SwiftUI Color by using the corresponding hex code.

import SwiftUI

extension Color {
     init(hex: String) {
         let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
         var int: UInt64 = 0
         Scanner(string: hex).scanHexInt64(&int)
         let a, r, g, b: UInt64
         switch hex.count {
         case 3: // RGB (12-bit)
             (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
         case 6: // RGB (24-bit)
             (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
         case 8: // ARGB (32-bit)
             (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
         default:
             (a, r, g, b) = (1, 1, 1, 0)
         }
         
         
         self.init(
             .sRGB,
             red: Double(r) / 255,
             green: Double(g) / 255,
             blue:  Double(b) / 255,
             opacity: Double(a) / 255
         )
     }
 }

Now we can add an array where we initialise our preferred slice colours:

let pieColors = [
     Color.init(hex: "#2f4b7c"),
     Color.init(hex: "#003f5c"),
     Color.init(hex: "#665191"),
     Color.init(hex: "#a05195"),
     Color.init(hex: "#d45087"),
     Color.init(hex: "#f95d6a"),
     Color.init(hex: "#ff7c43"),
     Color.init(hex: "#ffa600")
]

This example represents the following color scheme:

Now, let’s create the view holding the actual pie chart by creating a new File-New-File-SwiftUI View and naming it “PieChart”.

Here, we initialise our data set, the title for the chart, the separator color to pass into the slices we create, and an array of accent colors that can be used to fill each slice with a specific color. 

struct PieChart: View {
     
     var title: String
     var data: [ChartData]
     var separatorColor: Color
     var accentColors: [Color]
     
     
     var body: some View {
         
     }
 }

Let’s use a fitting title for our PieChart_Previews struct. To make the spaces between the slices transparent we use the system’s background color as the separatorColor. Finally, we use our chartDataSet and our pieColors we created earlier as the data and accentColors.

struct PieChart_Previews: PreviewProvider {
     static var previews: some View {
         PieChart(title: "MyPieChart", data: chartDataSet, separatorColor: Color(UIColor.systemBackground), accentColors: pieColors)
     }
 }

Next, we add some State properties to keep track of the current value to be shown, the current label to be shown, and the touch location. As we did in the last chapter, we will use the touch location to sense when our pie slices are being touched. 

struct PieChart: View {
     
     var title: String
     var data: [ChartData]
     var separatorColor: Color
     var accentColors: [Color]
     
     @State  private var currentValue = ""
     @State  private var currentLabel = ""
     @State  private var touchLocation: CGPoint = .init(x: -1, y: -1)
     
     var body: some View {
         
     }
 }

In the body of our PieChart view, we need to create a VStack that contains the title, the pie chart itself, and a legend at the bottom. 

The title is straightforward:

VStack {
     Text(title)
         .bold()
         .font(.largeTitle)
 }
     .padding()

The actual chart will be embedded in a ZStack with the views for the current label and value. Furthermore, let’s add a GeometryReader. Later on, we will use this GeometryReader to hold the PieChartSlice instances building the actual pie chart.

VStack {
     Text(title)
         .bold()
         .font(.largeTitle)
     ZStack {
         GeometryReader { geometry in
             //We will insert our actual pie chart here
         }
             .aspectRatio(contentMode: .fit)
         VStack  {
             if !currentLabel.isEmpty   {
                 Text(currentLabel)
                     .font(.caption)
                     .bold()
                     .foregroundColor(.black)
                     .padding(10)
                     .background(RoundedRectangle(cornerRadius: 5).foregroundColor(.white).shadow(radius: 3))
             }
             
             if !currentValue.isEmpty {
                 Text("\(currentValue)")
                     .font(.caption)
                     .bold()
                     .foregroundColor(.black)
                     .padding(5)
                     .background(RoundedRectangle(cornerRadius: 5).foregroundColor(.white).shadow(radius: 3))
             }
         }
             .padding()
     }
 }
     .padding()
 

Let’s talk about the legend for a moment: In a pie chart, the legend is usually the description of what each color in the chart represents. Therefore, we can iterate over the indices of our data and create an HStack that contains an accent color and the label of the data it represents in our data array. 

VStack {
     Text(title)
         //...
     ZStack {
         //...
     }
     VStack(alignment:   .leading)  {
         ForEach(0..<data.count)   {    i in
             HStack {
                 accentColors[i]
                     .aspectRatio(contentMode: .fit)
                     .padding(10)
                 Text(data[i].label)
                     .font(.caption)
                     .bold()
             }
         }
     }
 }
     .padding()

That’s how your PieChart preview should look so far:

Before we can implement our actual pie chart consisting of the combined PieChartSlice’s, there are a few methods we need to implement first. As mentioned in the beginning, pie charts are used to visualise pieces of data that make a whole when combined. Therefore, we need a function that will create a normalised value for each value in our data array. In this function, we will simply represent the value as a fraction of the total of all values in the data array. Let’s add such a function to our Helper.swift file.

func normalizedValue(index: Int, data: [ChartData]) -> Double {
     var total = 0.0
     data.forEach { data in
         total += data.value
     }
     return data[index].value/total
 }

With that function in place, we can then add a new struct called “PieSlice” to our Helper.swift file. We will use this struct to hold the start degree and end degree of each slice according to the normalized value.

struct PieSlice {
     var startDegree: Double
     var endDegree: Double
}

Back to our PieChart view: We can now calculate the pie slices by adding a computed property holding the PieSlice instances inside an array. 

var pieSlices: [PieSlice] {
 

}

To get the pieSlices we create a temporary “slices” array and cycle through our data

var pieSlices: [PieSlice] {
     var slices = [PieSlice]()
     data.enumerated().forEach {(index, data) in
         
     }
 }

If the temporary slices array is empty, we add a slice with a start angle of 0 degrees. The end angle for the slice can be found by using the normalized value to calculate how many degrees we need to add to the start angle of the slice. If the slice we are calculating angles for is not the first in our array, then the start angle should be equal to the end angle of the previous slice. Finally, we use our temporary slices array to initialise the pieSlices.

var pieSlices: [PieSlice] {
     var slices = [PieSlice]()
     data.enumerated().forEach {(index, data) in
         let value = normalizedValue(index: index, data: self.data)
         if slices.isEmpty    {
             slices.append((.init(startDegree: 0, endDegree: value * 360)))
         } else {
             slices.append(.init(startDegree: slices.last!.endDegree,    endDegree: (value * 360 + slices.last!.endDegree)))
         }
     }
     return slices
 }

Now we can generate our actual pie chart. To do this we put all the PieSlice instances into our ZStack’s GeometryReader that’s currently empty. Since the slices have succeeding start and end angles, they will combine to form a pie chart as below: 

VStack {
     //...
     ZStack {
         GeometryReader { geometry in
             ZStack  {
                 ForEach(0..<self.data.count){ i in
                     PieChartSlice(center: CGPoint(x: geometry.frame(in: .local).midX, y: geometry.frame(in:  .local).midY), radius: geometry.frame(in: .local).width/2, startDegree: pieSlices[i].startDegree, endDegree: pieSlices[i].endDegree, isTouched: false, accentColor: accentColors[i], separatorColor: separatorColor)
                 }
             }
         }
         .aspectRatio(contentMode: .fit)
         VStack  {
             //...
         }
             .padding()
     }
     VStack(alignment:   .leading)  {
         ///...
     }
 }
     .padding()

Note: GeometryReader by default takes up as much space as is available in the view. Using the .aspectRatio modifier we ensure that the GeometryReader takes up just the space the content inside it needs. 

Awesome! That’s how your PieChart preview should look so far:

Adding the Highlight Effect 💡

As we did with the bar chart, we’ll attach a gesture modifier to our GeometryReader’s ZStack holding the PieChartSlice instances so we can sense when the user touches a certain slice.

ZStack  {
     //...
 }
     .gesture(DragGesture(minimumDistance: 0)
             .onChanged({ position in
                 //...
             })
             .onEnded({ _ in
                 //...
             })
     )

However, to make good use of this gesture requires some thinking first. Pie charts are circles. Therefore unlike in a bar chart, where we would only be concerned with the drag position along the x-axis, here, we will need the drag position along both the x and y axes. This will enable us to check whether the touch location is within the circle. 

Now, to check whether a specific slice is touched, we will need to calculate the angle at which the touch location is and find the pie slice in which that angle exists. To do this, add the following function to your Helper.swift file.

func angleAtTouchLocation(inPie pieSize: CGRect, touchLocation: CGPoint) ->  Double?  {
     let dx = touchLocation.x - pieSize.midX
     let dy = touchLocation.y - pieSize.midY
     
     let distanceToCenter = (dx * dx + dy * dy).squareRoot()
     let radius = pieSize.width/2
     guard distanceToCenter <= radius else {
         return nil
     }
     let angleAtTouchLocation = Double(atan2(dy, dx) * (180 / .pi))
     if angleAtTouchLocation < 0 {
         return (180 + angleAtTouchLocation) + 180
     } else {
         return angleAtTouchLocation
     }
 }

In this function, we are passing in a parameter that gives us the width (diameter) of our pie chart. We can then use this diameter to find the radius of the pie chart. The dx and dy values give us the distances of the touch location from the x and y axes respectively, thus we now have three sides of a right-angled triangle, so we can use some pythagorean math here to calculate the exact angle of the touch location as well as concentrate on the values of touchLocation that fall within the circle. The atan() method then returns a value that has its units as radians/degrees so we multiply the result by 180/pi to obtain an actual angle value. 

This angle value will run from -180 to 180 degrees, however, we need to convert it to an angle between 0 and 360 degrees. Therefore, for negative values, we will add the value to 180, before adding the result to 180 again. For example, if the angle we have is -40, we will add -40 to 180, getting 140. We can then add this back to 180, getting 320 degrees. 

We can then use this function in another function we insert into our PieChart struct below its body

func updateCurrentValue(inPie   pieSize:    CGRect)  {
     guard let angle = angleAtTouchLocation(inPie: pieSize, touchLocation: touchLocation)    else    {return}
     let currentIndex = pieSlices.firstIndex(where: { $0.startDegree < angle && $0.endDegree > angle }) ?? -1
     
     currentLabel = data[currentIndex].label
     currentValue = "\(data[currentIndex].value)"
 }

We use this function to update the current values by checking for the slice in which this angle is contained and updating the state properties for the current value and the current label. 

We also add a function to our PieChart struct that resets the values.

func resetValues() {
     currentValue = ""
     currentLabel = ""
     touchLocation = .init(x: -1, y: -1)
 }

Now we can call the updateCurrentValue function in the .onChanged modifier for the DragGesture to update the values accordingly, and call the resetValues function in the .onEnded modifier.

.gesture(DragGesture(minimumDistance: 0)
             .onChanged({ position in
                 let pieSize = geometry.frame(in: .local)
                 touchLocation   =   position.location
                 updateCurrentValue(inPie: pieSize)
             })
             .onEnded({ _ in
                 DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                     withAnimation(Animation.easeOut) {
                         resetValues()
                     }
                 }
             })
 )

If we now start a live preview, we can take a look at the information relating to the touched PieChartSlice.

Furthermore, we can add a third function to our PieChart struct that returns a boolean when the touchLocation corresponds to a given index, and use that to detect when a specific slice has been touched. 

func sliceIsTouched(index: Int, inPie pieSize: CGRect) -> Bool {
     guard let angle =   angleAtTouchLocation(inPie: pieSize, touchLocation: touchLocation) else { return false }
     return pieSlices.firstIndex(where: { $0.startDegree < angle && $0.endDegree > angle }) == index
 }

We can now use this function to highlight the specific PieChartSlice by utilising its isTouched property.

ForEach(0..<self.data.count){ i in
     PieChartSlice(center: CGPoint(x: geometry.frame(in: .local).midX, y: geometry.frame(in:  .local).midY), radius: geometry.frame(in: .local).width/2, startDegree: pieSlices[i].startDegree, endDegree: pieSlices[i].endDegree, isTouched: sliceIsTouched(index: i, inPie: geometry.frame(in:  .local)), accentColor: accentColors[i], separatorColor: separatorColor)
 }

Let’s run another live preview to see if this works:

Conclusion 🎊

That’s it! We now have a functional and easily customizable pie chart ready for use in SwiftUI apps. We’ve seen how powerful Path can be for drawing custom shapes and did some thinking exercises for how we can use the touch location to accomplish the same behaviours as the bar chart we made last time. Feel free to experiment and customise it to your liking! 

In the next part [TBA] of our Charts in SwiftUI series, we’ll get into line charts and see how we can use the Path type to create custom lines. Stay tuned! 

As always, we’ve uploaded the source code for the app to GitHub.

You liked the tutorial, and you want to learn more about developing iOS apps with SwiftUI? Then check out our Interactive Mastering SwiftUI Book!