利用 SwiftUI Path 輕鬆建立漂亮的折線圖!


本篇原文(標題:Create a Line Chart in SwiftUI Using Paths)刊登於作者的 Medium,由 Anupam Chugh 所著,並授權翻譯及轉載。

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-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 負責繪製到指定目標點的直線。

addArcaddCurveaddQuadCurveaddRect、和 addEllipse 這些方法,讓我們可以利用 Path 創建圓弧 (circular arc) 或貝茲曲線 (Bezier curve)。

我們可以使用 addPath 來附加兩個或以上的 Path。

下圖顯示了一個三角形和一個圓餅圖:

swiftui-path-1

現在,我們已經瞭解了如何在 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()
        }
    }
}

stepWidthstepHeight 可以將圖表限制在框架的指定寬度和高度內,然後我們將它們傳遞給 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 成功顯示股票價格圖了:

final-product

總結

在這篇文章中,我們再次將 SwiftUI 和 Combine 搭配使用,來取得股票價格,並以折線圖表達數據。要學好 SwiftUI Shape 的使用,就先要了解 SwiftUI Path,並嘗試實作 path 函式。

你可以利用以上的 SwiftUI 折線圖,來嘗試實作更多東西,例如用手勢 (gesture) 來突出某個點及其相應的值。如果你有興趣實作,可以看看這個程式庫

你可以在 GitHub 程式庫 上取得 App 的完整程式碼。

本篇文章到此為止,謝謝你的閱讀。

本篇原文(標題:Create a Line Chart in SwiftUI Using Paths)刊登於作者的 Medium,由 Anupam Chugh 所著,並授權翻譯及轉載。

作者簡介:Anupam Chugh,深入探索 ML 及 AR 的 iOS Developer。喜愛撰寫關於想法、科技、與程式碼的文章。歡迎到我的 Blog 閱讀更多文章,或在 LinkedIn 上關注我。

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


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

blog comments powered by Disqus
Shares
Share This