Categories
iOS Swift SwiftUI

Interactive pie chart in SwiftUI

Apple’s Charts library supports many chart types: bar, line, area, point, rectangle, but proper support for pie charts only came with iOS 17. It was possible to create pie charts before as well, but it required to take a long road and draw each of the sector yourself. iOS 17 brings us a new SectorMark mark type for pie charts. SectorMark represents a slice of the pie and comes with various properties for configuring the visual look of it. What we’ll be building is a pie chart on which we can tap, and then the sector or slice is animating to a larger size.

Before we start setting up the chart, let’s have a look at how the data is laid out. We’ll use a struct which has two properties: country and bags where bags represent a count of 60 kg bags of coffee beans exported from that country.

struct CoffeeExport: Equatable {
let country: String
let bags: Int
}
extension CoffeeExport {
static var data: [CoffeeExport] = [
CoffeeExport(country: "Brazil", bags: 44_200_000),
CoffeeExport(country: "Vietnam", bags: 27_500_000),
CoffeeExport(country: "Colombia", bags: 13_500_000),
CoffeeExport(country: "Indonesia", bags: 11_000_000),
CoffeeExport(country: "Honduras", bags: 9_600_000),
CoffeeExport(country: "Ethiopia", bags: 6_400_000),
CoffeeExport(country: "India", bags: 5_800_000),
CoffeeExport(country: "Uganda", bags: 4_800_000),
CoffeeExport(country: "Mexico", bags: 3_900_000),
CoffeeExport(country: "Guatemala", bags: 3_400_000),
CoffeeExport(country: "Others", bags: 19_209_000),
]
}
view raw Data.swift hosted with ❤ by GitHub

The chart implementation is pretty compact in SwiftUI. It involves creating a Chart and adding SectorMarks with different configuration based on if the sector is selected or not.

Chart(viewModel.countries, id: \.country) { element in
let isSelected = viewModel.isSelected(element)
SectorMark(angle: .value("Bag", element.bags),
outerRadius: .ratio(isSelected ? 1 : 0.9),
angularInset: isSelected ? 2 : 0)
.foregroundStyle(by: .value("Country", element.country))
.cornerRadius(3)
}
.chartAngleSelection(value: $viewModel.rawSelection)
.chartForegroundStyleScale(domain: .automatic, range: Self.chartColors)
.onChange(of: viewModel.rawSelection, viewModel.updateCountrySelection)
.frame(height: 300)
.animation(.bouncy, value: viewModel.selectedCountry)
view raw Chart.swift hosted with ❤ by GitHub

Above, we can see that we loop over an array of countries (CoffeeExport type) and create a SectorMark where the angle is set to the number of bags. The chart normalizes the data and calculates the corresponding angle for the sector. Therefore, the term angle might be slightly confusing at first since we pass in large numbers instead of the actual angle. SectorMark’s outerRadius in combination with angularInset is used for creating the selection effect where the sector has a spacing around it and is also drawn larger. If the sector is selected, then we use the full available plot area for drawing it, otherwise we add an inset to the available plot area which is the ratio of 0.9. The foregroundStyle(by:) view modifier ties the country name to the value.

User interaction is handled through the chartAngleSelection(value:) view modifier and whenever we tap on the chart, it sets the current value to the passed in binding. The value is a raw value used for creating SectorMarks. In our case it can be, for example, 1_000_000 or 123_456, depends on where we tap. The chart converts the actual angle to the number of bags for that angle for our data set. This means that we need to convert the raw value to one of the CoffeeExport types for figuring out which SectorMark is currently selected. We can just loop over the data and find the intersecting country. If the view model’s selectedCountry changes, SwiftUI view is refreshed with a bouncy spring animation.

@Observable final class ViewModel {
// …
var rawSelection: Int?
private(set) var selectedCountry: CoffeeExport?
func updateCountrySelection() {
guard let rawSelection else { return }
let country = self.selectedCountry(for: rawSelection)
guard country != selectedCountry else { return }
selectedCountry = country
}
private func selectedCountry(for value: Int) -> CoffeeExport {
var total = 0
for element in countries {
total += element.bags
if value <= total {
return element
}
}
return countries.last!
}
// …
}
view raw ViewModel.swift hosted with ❤ by GitHub

Last thing to note about the chart implementation is that we are passing in a list of custom colors since the default color list is not suitable for 11 sectors and colors would start to repeat. The chartForegroundStyleScale(domain:range:type:) just takes in a list of colors and these colors are then applied one by one to each of the sector mark. Since I know that there are going to be 11 sectors, then I defined 11 colors.

// …
.chartForegroundStyleScale(domain: .automatic, range: Self.chartColors)
}
// …
private static let chartColors: [Color] = [
.red, .green, .blue, .yellow, .purple, .indigo, .brown, .mint, .orange, .pink, .cyan
]
view raw Colors.swift hosted with ❤ by GitHub

SwiftChartPieExample (GitHub, Xcode 15.0.1)

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.