Apple 在 2019 年推出了 SwiftUI,為我們提供了一個輕巧易用的工具,來創建使用者界面。這系列的教學文章,會讓大家看看如何利用 SwiftUI 框架,構建簡單而漂亮的數據視覺化工具 (data visualization tool),而我們將會從雷達圖 (radar chart) 開始。
什麼是雷達圖?
雷達圖(亦稱網路圖、蜘蛛網圖),是一種統計圖表,以同一點開始向不同方向延伸的軸,來表示不同的變量。讓我們先看看這篇文章會構建的雷達圖:
這種視覺化工具適用於以下情況:
- 你打算整合大學中有多少學生獲不同課程錄取。
- 你打算視覺化不同技能的相對優點和缺點,以確定自己需要練習哪些技能。
- 你打算列出你的公司各個部門的資金,以了解自己的錢用了在什麼地方。
我們需要什麼?
要建立雷達圖,我們需要了解 SwiftUI 如何利用 Shape
和 Path
型別來渲染圖形。Path
與 UIKit 的 UIBezierPath
和 Core Graphic 的 CGPath
用法相似,開發者利用 Path
來描述一個二維曲線,讓渲染系統 (rendering system) 可以繪製該曲線。我們也需要一個符合 Shape
協定的型別,來封裝一個 Path
,並確保渲染描述與繪製區域完全匹配。
Shapes
和 Paths
在這篇文章中非常重要。現在我們了解了 Shapes
和 Paths
,是時候開始編寫程式碼了。
如何用程式碼構建雷達圖?
讓我們從這個類似蜘蛛網的標誌性背景開始,這是 Shape
型別的完美範例。在建立這個背景時,我們需要考慮兩件事:
- 我們可能想重用這段程式碼,來為各種 App 創建圖表。而我們的圖表需要為每組數據集 (data set) 添加正確數量的軸。
- 我們可能要更改網格的數量,比如說我們的數據只能會是 1 到 5 之間的整數值,我們就不需要添加太多網格線是沒有好處的。
struct RadarChartGrid: Shape {
let categories: Int
let divisions: Int
func path(in rect: CGRect) -> Path {
let radius = min(rect.maxX - rect.midX, rect.maxY - rect.midY)
let stride = radius / CGFloat(divisions)
var path = Path()
for category in 1 ... categories {
path.move(to: CGPoint(x: rect.midX, y: rect.midY))
path.addLine(to: CGPoint(x: rect.midX + cos(CGFloat(category) * 2 * .pi / CGFloat(categories) - .pi / 2) * radius,
y: rect.midY + sin(CGFloat(category) * 2 * .pi / CGFloat(categories) - .pi / 2) * radius))
}
for step in 1 ... divisions {
let rad = CGFloat(step) * stride
path.move(to: CGPoint(x: rect.midX + cos(-.pi / 2) * rad,
y: rect.midY + sin(-.pi / 2) * rad))
for category in 1 ... categories {
path.addLine(to: CGPoint(x: rect.midX + cos(CGFloat(category) * 2 * .pi / CGFloat(categories) - .pi / 2) * rad,
y: rect.midY + sin(CGFloat(category) * 2 * .pi / CGFloat(categories) - .pi / 2) * rad))
}
}
return path
}
}
RadarChartGrid
型別的程式碼做了以下事情:
- 它實作了
Shape
協定,讓它可以調整Path
的描述,以適合在其中繪製的長方形。 - 它讓開發者可以控制數據中類別的數量,以及在軸中需要劃分的數量。
- 它根據可用的維度構建了一個
Path
物件。
讓我們想像軸為輪 (wheel) 的輪輻 (spokes)。它們從中心的共同原點開始,而且長度相同。我們可以將其視為一個圓形的半徑,並利用三角函數繪製它們。如果你想深入了解這一點,可以看看 path(in :)
方法中的第一個 for
循環。
我們也會使用三角函數在兩個軸之間繪製線條。這些計算就在 path(in :)
方法最尾嵌套的 for
循環裡。
最後要注意的是,我們將三角函數偏移了 -π/2。偏移量會旋轉圖表,讓圖表看起來更加平衡好看。
繪製數據
我們繪製數據的方式,與繪製背景網的方式非常相似:使用三角函數。看看下面的程式碼:
struct RadarChartPath: Shape {
let data: [Double]
func path(in rect: CGRect) -> Path {
guard
3 <= data.count,
let minimum = data.min(),
0 <= minimum,
let maximum = data.max()
else { return Path() }
let radius = min(rect.maxX - rect.midX, rect.maxY - rect.midY)
var path = Path()
for (index, entry) in data.enumerated() {
switch index {
case 0:
path.move(to: CGPoint(x: rect.midX + CGFloat(entry / maximum) * cos(CGFloat(index) * 2 * .pi / CGFloat(data.count) - .pi / 2) * radius,
y: rect.midY + CGFloat(entry / maximum) * sin(CGFloat(index) * 2 * .pi / CGFloat(data.count) - .pi / 2) * radius))
default:
path.addLine(to: CGPoint(x: rect.midX + CGFloat(entry / maximum) * cos(CGFloat(index) * 2 * .pi / CGFloat(data.count) - .pi / 2) * radius,
y: rect.midY + CGFloat(entry / maximum) * sin(CGFloat(index) * 2 * .pi / CGFloat(data.count) - .pi / 2) * radius))
}
}
path.closeSubpath()
return path
}
}
我們的 RadarChartPath
在繪製任何內容之前,會檢查一些先決條件 (precondition)。如果檢查失敗,我們將返回一個空的 Path
物件,也就是物件在螢幕上不可見。如果通過檢查,我們就會將數據規格化為 [0, 1]
範圍,並使用規格化的值以之前的方式繪製 Path
。
整合圖表和數據
現在我們已經準備好了,是時候把所有內容放在一起,以製作實際的圖表視圖了:
struct RadarChart: View {
var data: [Double]
let gridColor: Color
let dataColor: Color
init(data: [Double], gridColor: Color = .gray, dataColor: Color = .blue) {
self.data = data
self.gridColor = gridColor
self.dataColor = dataColor
}
var body: some View {
ZStack {
RadarChartGrid(categories: data.count, divisions: 10)
.stroke(gridColor, lineWidth: 0.5)
RadarChartPath(data: data)
.fill(dataColor.opacity(0.3))
RadarChartPath(data: data)
.stroke(dataColor, lineWidth: 2.0)
}
}
}
以上的程式碼非常直接,我們把背景的網格和數據形狀放到 body
性質內的 ZStack
。SwiftUI 還沒有一個方便的方法,讓我們同時為 Shape
型別繪畫外型並填滿顏色,因此我們需要添加 RadarChartPath
兩次,一次用來填滿顏色,另一次用來繪畫外型。
利用以上短而簡單的程式碼,我們就可以視覺化數據了!
謝謝你的閱讀。