Apple 在 2019 年推出了 SwiftUI,為我們提供了一個輕巧易用的工具,來創建使用者界面。這系列的教學文章,會讓大家看看如何利用 SwiftUI 框架,構建簡單而漂亮的數據視覺化工具 (data visualization tool)。在第三篇文章中,我們會介紹如何構建圓餅圖 (pie chart)。
什麼是圓餅圖?
圓餅圖(亦稱圓形圖)是一種數據可視化工具,它將每個數值按比例繪製一個圓形。就像長條圖一樣,圓餅圖可以處理分類數據,也可以展示不同類別之間的比率。
這種視覺化工具適用於以下情況:
- 看看你公司年度預算中,分配給各個部門的比例。
- 了解自己如何分配月薪,像是支付房租、購買食物、和購買編程的書籍等方面。
- 統計地區中不同種類房屋的出租比例。
我們需要什麼?
要製作一個圓餅圖,我們需要使用 SwiftUI 的 Path
、Shape
、和 ZStack
型別。由於我們在前兩篇文章已經介紹過這些概念,因此讓我們直接開始實作吧!
如何用程式碼構建圓餅圖?
讓我們先看一下主要組件:PieChart
結構 (struct)。
import SwiftUI
struct PieChart: View {
@Binding var data: [Double]
@Binding var labels: [String]
private let colors: [Color]
private let borderColor: Color
private let sliceOffset: Double = -.pi / 2
init(data: Binding<[Double]>, labels: Binding<[String]>, colors: [Color], borderColor: Color) {
self._data = data
self._labels = labels
self.colors = colors
self.borderColor = borderColor
}
var body: some View {
GeometryReader { geo in
ZStack(alignment: .center) {
ForEach(0 ..< data.count) { index in
PieSlice(startAngle: startAngle(for: index), endAngle: endAngle(for: index))
.fill(colors[index % colors.count])
PieSlice(startAngle: startAngle(for: index), endAngle: endAngle(for: index))
.stroke(Color.white, lineWidth: 1)
PieSliceText(
title: "\(labels[index])",
description: String(format: "$%.2f million", data[index])
)
.offset(textOffset(for: index, in: geo.size))
.zIndex(1)
}
}
}
}
private func startAngle(for index: Int) -> Double {
switch index {
case 0:
return sliceOffset
default:
let ratio: Double = data[..<index].reduce(0.0, +) / data.reduce(0.0, +)
return sliceOffset + 2 * .pi * ratio
}
}
private func endAngle(for index: Int) -> Double {
switch index {
case data.count - 1:
return sliceOffset + 2 * .pi
default:
let ratio: Double = data[..<(index + 1)].reduce(0.0, +) / data.reduce(0.0, +)
return sliceOffset + 2 * .pi * ratio
}
}
private func textOffset(for index: Int, in size: CGSize) -> CGSize {
let radius = min(size.width, size.height) / 3
let dataRatio = (2 * data[..<index].reduce(0, +) + data[index]) / (2 * data.reduce(0, +))
let angle = CGFloat(sliceOffset + 2 * .pi * dataRatio)
return CGSize(width: radius * cos(angle), height: radius * sin(angle))
}
}
這個結構包含了許多輔助函式 (helper function),其中一些函式會做一些數學運算,乍看之下似乎有些嚇人,但讓我們逐一看看吧!
由於我們將數據以扇形 (circle sector) 表示,因此需要計算每個數據起始和結束的角度。我們可以使用 startAngle(for:)
和 endAngle(for:)
方法來計算,它們會回傳弧度數值。
textOffset(for:in:)
方法看起來更複雜,但實際上,它與角度方法非常相似。它的任務是計算圓形中的一個點,讓我們在這個點上放置標籤,來描述不同扇形所代表的內容。在這些計算中,有兩個需要注意的屬性:
- 標籤會被放置在圓心和圓周之間三分之二的位置上。
- 標籤會被放置在扇形角度的中心。
現在,讓我們看一下 body
屬性和結構。首先,我們將所有內容包裝在一個 GeometryReader
中。這個組件讓我們可以取得包含矩形 (containing rectangle) 的資訊,讓我們可以正確地放置標籤。
GeometryReader
中是 ZStack
,讓我們可以將多個組件一層層疊上。ZStack
包含了一個 ForEach
視圖,為我們所有數據點創建 PieSlice
組件和PieSliceText
。請注意,ForEach
每次運行時都會實例化兩個 PieSlice
物件。在撰寫這篇文章時,SwiftUI 還沒有一個方便的方法,讓我們同時為 Shape
型別繪畫外型並填滿顏色,因此我們需要創建兩個物件,一個用來填滿顏色,另一個用來繪畫外型。
最後要注意的是,我們指定了 PieSliceText
的 Z index,以確保它們在其他組件之上呈現。
繪製數據
在計算所有封閉組件的角度和位置方面,PieChart
視圖負責了大部分的工作。因此,實作 PieSlice
Shape
和 PieSliceText
標籤就十分輕鬆。讓我們看看 PieSlice
的程式碼:
import SwiftUI
struct PieSlice: Shape {
let startAngle: Double
let endAngle: Double
func path(in rect: CGRect) -> Path {
var path = Path()
let radius = min(rect.width, rect.height) / 2
let alpha = CGFloat(startAngle)
let center = CGPoint(
x: rect.midX,
y: rect.midY
)
path.move(to: center)
path.addLine(
to: CGPoint(
x: center.x + cos(alpha) * radius,
y: center.y + sin(alpha) * radius
)
)
path.addArc(
center: center,
radius: radius,
startAngle: Angle(radians: startAngle),
endAngle: Angle(radians: endAngle),
clockwise: false
)
path.closeSubpath()
return path
}
}
PieSlice
以起始和結束角度為實例化的參數,依三角學 (trigonometry) 繪製了一個在兩個角度之間的扇形。之後的步驟很簡單,只是普通直角三角形計算。
最後,讓我們看看 PieSliceText
組件,它唯一的工作就是精準地將兩個文本標籤堆疊。
import SwiftUI
struct PieSliceText: View {
let title: String
let description: String
var body: some View {
VStack {
Text(title)
.font(.headline)
Text(description)
.font(.body)
}
}
}
整合圖表和數據
這篇文章整合好的 PieChart
視圖已經可以使用了,我們要做的就只是將檔案複製到專案中,並實例化一個新物件。快點試試使用這些組件、添加新的組件,並在圖表加入你的個人元素。我也很期待你分享一下你的作品!
在下一篇文章發佈之前,你可以先重溫這系列的第一篇和第二篇教學文章。
你也看看這篇文章,看看該如何解決複雜的問題。