利用 SwiftUI 簡單構建傳統的數據視覺化工具:長條圖 (Bar Chart)


本篇原文(標題:Data Visualization With SwiftUI: Bar Charts)刊登於作者 Medium,由 Jimmy M Andersson 所著,並授權翻譯及轉載。

Apple 在 2019 年推出了 SwiftUI,為我們提供了一個輕巧易用的工具,來創建用戶界面。這系列的教學文章,會讓大家看看如何利用 SwiftUI 框架,構建簡單而漂亮的數據視覺化工具 (data visualization tool)。這是第二篇教學文章,我們將會介紹傳統耐用的長條圖 (bar chart)。

什麼是長條圖?

長條圖(亦稱條圖 (bar graph))是一種數據可視化工具,用於處理分類數據。這種圖表為所有變數 (variable) 渲染一個長方形,而長方形的長度與其變數的值成正比。

上面的定義聽起來不錯,但在我們普通人的語言來說,是什麼意思呢?

分數變數 (categorical variable),是一種讓我們可以按一組標籤來進行分類的變數。標籤之間並沒有次序之分,而且可以與零個、一個、或多個數據點有關聯。比如說,我們可以按發收據的商店對收據進行分類,由於商店本身並沒有次序之分,在沒有其他比較指標的情況下,我們無法比較商店的大小。

而長條圖的概念,就是要可視化與標籤有關聯的次序變數 (ordinal variable)。我們可以選擇從每間商店收到的收據數量、或是花在每間商店的總金額為變數,因為這些變數符合次序量表 (ordinal measurement scale),我們就可以把數據互相比較。就長條圖來說,變量數值越大,長方形就越高。

swiftui-bar-chart

這種視覺化工具適用於以下情況:

  • 比較你公司每年的利潤。
  • 整合你在衣物、鞋子、食物等方面所花費的金錢。
  • 視覺化你某一年度的每月工作時數。

我們需要什麼?

就如我在前一篇雷達圖的教學文章所說,我們需要對 Paths 和 Shapes 有一定的理解。另外,我們也需要了解 Stacks 的概念。因為前一篇文章已經介紹了 Paths 和 Shapes,我們這次就直接說說 Stacks 吧!

SwiftUI 提供了不同的 Stacks,像是 VStackHStack、和 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),讓最小值的長條柱不會短得不可見。

讓我們也來看看 BarChartGridBarChartAxes

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 就可以了!

在下一篇文章發佈之前,你可以先重溫這系列的第一篇教學文章

你也看看這篇文章,看看該如何解決複雜的問題。

或是閱讀一下這篇文章,讓你可以在在家工作時保持理智。

本篇原文(標題:Data Visualization With SwiftUI: Bar Charts)刊登於作者 Medium,由 Jimmy M Andersson 所著,並授權翻譯及轉載。

作者簡介:Jimmy M Andersson 是一名軟件開發人員,在活躍於汽車行業的 NIRA Dynamics 中負責數據採集。他開發了監控和日誌記錄 App,來演示及可視化公司的產品組合和其功能。他目前正在進修資訊科技碩士學位,目標是以數據科學專業畢業。他每週都會在 Medium 發表軟件開發文章。你也可以在 Twitter 或 Email 聯絡他。

譯者簡介:Kelly Chan-AppCoda 編輯小姐。


此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This