Apple 在 2019 年推出了 SwiftUI,為我們提供了一個輕巧易用的工具,來創建用戶界面。這系列的教學文章,會讓大家看看如何利用 SwiftUI 框架,構建簡單而漂亮的數據視覺化工具 (data visualization tool)。這是第二篇教學文章,我們將會介紹傳統耐用的長條圖 (bar chart)。
什麼是長條圖?
長條圖(亦稱條圖 (bar graph))是一種數據可視化工具,用於處理分類數據。這種圖表為所有變數 (variable) 渲染一個長方形,而長方形的長度與其變數的值成正比。
上面的定義聽起來不錯,但在我們普通人的語言來說,是什麼意思呢?
分數變數 (categorical variable),是一種讓我們可以按一組標籤來進行分類的變數。標籤之間並沒有次序之分,而且可以與零個、一個、或多個數據點有關聯。比如說,我們可以按發收據的商店對收據進行分類,由於商店本身並沒有次序之分,在沒有其他比較指標的情況下,我們無法比較商店的大小。
而長條圖的概念,就是要可視化與標籤有關聯的次序變數 (ordinal variable)。我們可以選擇從每間商店收到的收據數量、或是花在每間商店的總金額為變數,因為這些變數符合次序量表 (ordinal measurement scale),我們就可以把數據互相比較。就長條圖來說,變量數值越大,長方形就越高。
這種視覺化工具適用於以下情況:
- 比較你公司每年的利潤。
- 整合你在衣物、鞋子、食物等方面所花費的金錢。
- 視覺化你某一年度的每月工作時數。
我們需要什麼?
就如我在前一篇雷達圖的教學文章所說,我們需要對 Paths
和 Shapes
有一定的理解。另外,我們也需要了解 Stacks
的概念。因為前一篇文章已經介紹了 Paths
和 Shapes
,我們這次就直接說說 Stacks
吧!
SwiftUI 提供了不同的 Stacks
,像是 VStack
、HStack
、和 ZStack
。
這些都是佈局元素,目的是以整齊的堆疊方式,逐個佈局其子級 (children)。HStack
水平佈局子級,VStack
垂直佈局子級,而 ZStacks
就沿 Z 軸佈局子級(也就是將子級一層層疊上)。當我們想渲染多個長條柱、或是在長條柱後放置網格時,這些屬性就可以大派用場了。
如何用程式碼構建長條圖?
讓我們先看看長條圖的基礎 —— BarChart
視圖:
struct BarChart: View {
@Binding var data: [Double]
@Binding var labels: [String]
let accentColor: Color
let axisColor: Color
let showGrid: Bool
let gridColor: Color
let spacing: CGFloat
private var minimum: Double { (data.min() ?? 0) * 0.95 }
private var maximum: Double { (data.max() ?? 1) * 1.05 }
var body: some View {
VStack {
ZStack {
if showGrid {
BarChartGrid(divisions: 10)
.stroke(gridColor.opacity(0.2), lineWidth: 0.5)
}
BarStack(data: $data,
labels: $labels,
accentColor: accentColor,
gridColor: gridColor,
showGrid: showGrid,
min: minimum,
max: maximum,
spacing: spacing)
BarChartAxes()
.stroke(Color.black, lineWidth: 2)
}
LabelStack(labels: $labels, spacing: spacing)
}
.padding([.horizontal, .top], 20)
}
}
這個視圖有很多客製化的地方,讓我們逐一看看吧。
在 body
的開頭,我們先把圖表和其隨附的類別放在垂直堆疊中,如此一來,標籤就會顯示在長條柱的下方。圖表包含了一個可選的 BarChartGrid
、一個 BarStack
、和一些放在 ZStack
裡的 BarChartAxes
,也就是說 BarChartAxes
會一個個疊上。
我們在 body 上定義了兩個計算屬性 (computed property) minimum
和 maximum
,用來計算數據中最大與最小的值;同時也會在數值上預留一點緩衝 (buffer),讓最小值的長條柱不會短得不可見。
讓我們也來看看 BarChartGrid
和 BarChartAxes
:
struct BarChartGrid: Shape {
let divisions: Int
func path(in rect: CGRect) -> Path {
var path = Path()
let stepSize = rect.height / CGFloat(divisions)
(1 ... divisions).forEach { step in
path.move(to: CGPoint(x: rect.minX, y: rect.maxY - stepSize * CGFloat(step)))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - stepSize * CGFloat(step)))
}
return path
}
}
struct BarChartAxes: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
return path
}
}
這個 Shapes
非常簡單直接,BarChartGrid
在整個圖表中渲染了一定數量的橫線,而 BarChartAxes
則繪製了 X 和 Y 軸。
繪製數據
現在讓我們繪製數據吧!先看看 BarStack
的程式碼:
struct BarStack: View {
@Binding var data: [Double]
@Binding var labels: [String]
let accentColor: Color
let gridColor: Color
let showGrid: Bool
let min: Double
let max: Double
let spacing: CGFloat
var body: some View {
HStack(alignment: .bottom, spacing: spacing) {
ForEach(0 ..< data.count) { index in
LinearGradient(
gradient: .init(
stops: [
.init(color: Color.secondary.opacity(0.6), location: 0),
.init(color: accentColor.opacity(0.6), location: 0.4),
.init(color: accentColor, location: 1)
]),
startPoint: .bottom,
endPoint: .top
)
.clipShape(BarPath(data: data[index], max: max, min: min))
}
}
.shadow(color: .black, radius: 5, x: 1, y: 1)
.padding(.horizontal, spacing)
}
}
以上的程式碼中包含了很多內容,讓我們來逐一看看。
我們建立了一個 HStack
來將長條柱佈局成一排。然後,我們利用 ForEach
元件,為每一個數據點創建線性梯度 (linear gradient)。由於線性梯度佔據了所有空間,因此我們使用 BarPath shape 來把它裁剪至適當大小。從下面的程式碼可見,BarPath
只是 RoundedRectangle
的一個包裝 (wrapper)。最後,我們添加了一點陰影 (shadow) 和填充 (padding)。
struct BarPath: Shape {
let data: Double
let max: Double
let min: Double
func path(in rect: CGRect) -> Path {
guard min != max else {
return Path()
}
let height = CGFloat((data - min) / (max - min)) * rect.height
let bar = CGRect(x: rect.minX, y: rect.maxY - (rect.minY + height), width: rect.width, height: height)
return RoundedRectangle(cornerRadius: 5).path(in: bar)
}
}
最後,讓我們來看看 LabelStack
:
struct LabelStack: View {
@Binding var labels: [String]
let spacing: CGFloat
var body: some View {
HStack(alignment: .center, spacing: spacing) {
ForEach(labels, id: \.self) { label in
Text(label)
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal, spacing)
}
}
就像 BarStack
一樣,LabelStack
利用 HStack
來把標籤佈局成一排。因為我們用的是同一個方法,因此只要類別數量與數據點的數據一樣,標籤就會和長條柱好好對齊。
整合圖表和數據
這篇文章要建立的 BarChart 就完成了!你只需要整合數據,按需要客製化圖表,並點掣 run 就可以了!
在下一篇文章發佈之前,你可以先重溫這系列的第一篇教學文章。
你也看看這篇文章,看看該如何解決複雜的問題。
或是閱讀一下這篇文章,讓你可以在在家工作時保持理智。