利用 SwiftUI Path 輕鬆建立漂亮的折線圖!
SwiftUI 框架於 WWDC 2019 推出,讓 iOS 社群十分興奮。這個以 Swift 所寫的宣告式 (declarative) API 容易使用,讓開發者可以快速建構 UI 原型 (prototype)。
儘管我們可以利用 Shapes 協定從零開始建構直條圖 (Bar Chart),但卻無法以同樣方式建構折線圖。幸好,我們有 Paths
結構來達成目的。
SwiftUI 的 Path 類似與 Core Graphics 框架的 CGPaths
,我們可以利用 Paths 來組合線條和曲線,以建構漂亮的 Logo 和形狀。
Path 遵循宣告式方法來編寫 UI,由一組指令組成。在下文中,我們將討論這句話是甚麼意思。
目標
- 探索 SwiftUI 的 Path API,並創建簡單的形狀。
- 利用 Combine 和 URLSession 來獲取歷史庫存數據。我們將使用 Alpha Vantage 的 API 檢索股票信息。
- 在 SwiftUI 創建折線圖,來顯示一段時間內的股票價格。
讀完本篇文章後,你應該可以建構一個像這樣的 iOS App:

建構一個簡單的 SwiftUI Path
以下是利用 SwiftUI Path 創建一個直角三角型的例子:
var body: some View { Path { path in path.move(to: CGPoint(x: 100, y: 100)) path.addLine(to: CGPoint(x: 100, y: 300)) path.addLine(to: CGPoint(x: 300, y: 300)) }.fill(Color.green) }
Path API 由許多函式組成。move
負責設置 Path 的起點,而 addLine
負責繪製到指定目標點的直線。
而 addArc
、addCurve
、addQuadCurve
、addRect
、和 addEllipse
這些方法,讓我們可以利用 Path 創建圓弧 (circular arc) 或貝茲曲線 (Bezier curve)。
我們可以使用 addPath
來附加兩個或以上的 Path。
下圖顯示了一個三角形和一個圓餅圖:

現在,我們已經瞭解了如何在 SwiftUI 中創建 Path,讓我們來看看 SwiftUI 中的折線圖吧!
SwiftUI 折線圖
以下是用來解碼 API JSON 回應的模型:
struct StockPrice : Codable{ let open: String let close: String let high: String let low: String private enum CodingKeys: String, CodingKey { case open = "1. open" case high = "2. high" case low = "3. low" case close = "4. close" } } struct StocksDaily : Codable { let timeSeriesDaily: [String: StockPrice]? private enum CodingKeys: String, CodingKey { case timeSeriesDaily = "Time Series (Daily)" } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) timeSeriesDaily = try (values.decodeIfPresent([String : StockPrice].self, forKey: .timeSeriesDaily)) } }
讓我們創建一個 ObservableObject
類別。我們將使用 URLSession 的 Combine Publisher 執行 API 請求,並使用 Combine 運算符 (operator) 轉換結果。
class Stocks : ObservableObject{ @Published var prices = [Double]() @Published var currentPrice = "...." var urlBase = "https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=NSE:YESBANK&apikey=demo&datatype=json" var cancellable : Set<AnyCancellable> = Set() init() { fetchStockPrice() } func fetchStockPrice(){ URLSession.shared.dataTaskPublisher(for: URL(string: "\(urlBase)")!) .map{output in return output.data } .decode(type: StocksDaily.self, decoder: JSONDecoder()) .sink(receiveCompletion: {_ in print("completed") }, receiveValue: { value in var stockPrices = [Double]() let orderedDates = value.timeSeriesDaily?.sorted{ guard let d1 = $0.key.stringDate, let d2 = $1.key.stringDate else { return false } return d1 < d2 } guard let stockData = orderedDates else {return} for (_, stock) in stockData{ if let stock = Double(stock.close){ if stock > 0.0{ stockPrices.append(stock) } } } DispatchQueue.main.async{ self.prices = stockPrices self.currentPrice = stockData.last?.value.close ?? "..." } }) .store(in: &cancellable) } } extension String { static let shortDate: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" return formatter }() var stringDate: Date? { return String.shortDate.date(from: self) } }
API 結果由 nested 的 JSON 和作為日期的鍵組成。因為它們在字典中沒有排序,所以我們需要進行排序。為此,我們宣告了一個 extension,將字串轉換為日期,並在 sort
函式中進行比較。
現在我們已經在 Published
屬性中獲取了價格和股票數據,下一步我們需要將它們傳遞給 LineView
── 我們接下來會看到的客製化 SwiftUI 視圖。
struct LineView: View { var data: [(Double)] var title: String? var price: String? public init(data: [Double], title: String? = nil, price: String? = nil) { self.data = data self.title = title self.price = price } public var body: some View { GeometryReader{ geometry in VStack(alignment: .leading, spacing: 8) { Group{ if (self.title != nil){ Text(self.title!) .font(.title) } if (self.price != nil){ Text(self.price!) .font(.body) .offset(x: 5, y: 0) } }.offset(x: 0, y: 0) ZStack{ GeometryReader{ reader in Line(data: self.data, frame: .constant(CGRect(x: 0, y: 0, width: reader.frame(in: .local).width , height: reader.frame(in: .local).height)), minDataValue: .constant(nil), maxDataValue: .constant(nil) ) .offset(x: 0, y: 0) } .frame(width: geometry.frame(in: .local).size.width, height: 200) .offset(x: 0, y: -100) } .frame(width: geometry.frame(in: .local).size.width, height: 200) } } } }
上面的視圖是由 SwiftUI ContentView 呼叫的,ContentView 就是我們傳遞名稱、價格、和價格歷史記錄陣列的地方。我們使用 GeometryReader 將框架的寬度和高度傳遞給 Line
結構,在這裡我們將使用 SwiftUI Path,將這些點連接起來:
struct Line: View { var data: [(Double)] @Binding var frame: CGRect let padding:CGFloat = 30 var stepWidth: CGFloat { if data.count < 2 { return 0 } return frame.size.width / CGFloat(data.count-1) } var stepHeight: CGFloat { var min: Double? var max: Double? let points = self.data if let minPoint = points.min(), let maxPoint = points.max(), minPoint != maxPoint { min = minPoint max = maxPoint }else { return 0 } if let min = min, let max = max, min != max { if (min <= 0){ return (frame.size.height-padding) / CGFloat(max - min) }else{ return (frame.size.height-padding) / CGFloat(max + min) } } return 0 } var path: Path { let points = self.data return Path.lineChart(points: points, step: CGPoint(x: stepWidth, y: stepHeight)) } public var body: some View { ZStack { self.path .stroke(Color.green ,style: StrokeStyle(lineWidth: 3, lineJoin: .round)) .rotationEffect(.degrees(180), anchor: .center) .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) .drawingGroup() } } }
stepWidth
和 stepHeight
可以將圖表限制在框架的指定寬度和高度內,然後我們將它們傳遞給 Path
結構的 extension 函式,以創建折線圖:
extension Path { static func lineChart(points:[Double], step:CGPoint) -> Path { var path = Path() if (points.count < 2){ return path } guard let offset = points.min() else { return path } let p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) path.move(to: p1) for pointIndex in 1..<points.count { let p2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset)) path.addLine(to: p2) } return path } }
最後,SwiftUI App 成功顯示股票價格圖了:

總結
在這篇文章中,我們再次將 SwiftUI 和 Combine 搭配使用,來取得股票價格,並以折線圖表達數據。要學好 SwiftUI Shape 的使用,就先要了解 SwiftUI Path,並嘗試實作 path
函式。
你可以利用以上的 SwiftUI 折線圖,來嘗試實作更多東西,例如用手勢 (gesture) 來突出某個點及其相應的值。如果你有興趣實作,可以看看這個程式庫。
你可以在 GitHub 程式庫 上取得 App 的完整程式碼。
本篇文章到此為止,謝謝你的閱讀。