###### tags: `第14屆IT邦鐵人賽文章` # 【在 iOS 開發路上的大小事2-Day25】來自 Apple 爸爸的最新力作 - Swift Charts 之 LineMark 實作篇 上一篇介紹了 AreaMark 的實作,今天要來介紹的是 Swift Charts 的 LineMark LineMark 一共提供了兩種 init 的方法,讓開發者可以繪製不同樣式的圖表 ```swift public init<X, Y>(x: PlottableValue<X>, y: PlottableValue<Y>) where X : Plottable, Y : Plottable public init<X, Y, S>(x: PlottableValue<X>, y: PlottableValue<Y>, series: PlottableValue<S>) where X : Plottable, Y : Plottable, S : Plottable ``` 我們就以護國神山台積電跟友達的股票收盤價來當作範例 ## Model ```swift import SwiftUI struct StockEntity: Identifiable { var id = UUID().uuidString let name: String // 公司名稱 let highestPrice: Double // 當日最高點 let lowestPrice: Double // 當日最低點 let endPrice: Double // 當日收盤價 let date: Date // 日期 init(name: String, highestPrice: Double, lowestPrice: Double, endPrice: Double, month: Int, day: Int) { self.name = name self.highestPrice = highestPrice self.lowestPrice = lowestPrice self.endPrice = endPrice let calender = Calendar.autoupdatingCurrent self.date = calender.date(from: DateComponents(month: month, day: day))! } } ``` ## ViewModel ```swift import SwiftUI class StockEntityViewModel { var stockData: [StockEntity] = [ // MARK: TSMC Stock Price .init(name: "TSMC", highestPrice: 523.00, lowestPrice: 517.00, endPrice: 519.00, month: 8, day: 19), .init(name: "TSMC", highestPrice: 514.00, lowestPrice: 510.00, endPrice: 510.00, month: 8, day: 22), .init(name: "TSMC", highestPrice: 506.00, lowestPrice: 502.00, endPrice: 504.00, month: 8, day: 23), .init(name: "TSMC", highestPrice: 508.00, lowestPrice: 503.00, endPrice: 503.00, month: 8, day: 24), .init(name: "TSMC", highestPrice: 510.00, lowestPrice: 504.00, endPrice: 508.00, month: 8, day: 25), .init(name: "TSMC", highestPrice: 515.00, lowestPrice: 511.00, endPrice: 512.00, month: 8, day: 26), .init(name: "TSMC", highestPrice: 502.00, lowestPrice: 496.00, endPrice: 498.50, month: 8, day: 29), .init(name: "TSMC", highestPrice: 500.00, lowestPrice: 496.00, endPrice: 496.00, month: 8, day: 30), .init(name: "TSMC", highestPrice: 505.00, lowestPrice: 492.00, endPrice: 505.00, month: 8, day: 31), .init(name: "TSMC", highestPrice: 495.50, lowestPrice: 490.00, endPrice: 490.50, month: 9, day: 1), .init(name: "TSMC", highestPrice: 489.50, lowestPrice: 485.00, endPrice: 485.00, month: 9, day: 2), .init(name: "TSMC", highestPrice: 488.00, lowestPrice: 484.00, endPrice: 486.00, month: 9, day: 5), .init(name: "TSMC", highestPrice: 491.50, lowestPrice: 486.50, endPrice: 489.00, month: 9, day: 6), .init(name: "TSMC", highestPrice: 478.00, lowestPrice: 472.00, endPrice: 472.50, month: 9, day: 7), .init(name: "TSMC", highestPrice: 475.00, lowestPrice: 472.00, endPrice: 475.00, month: 9, day: 8), // MARK: AUO Stock Price .init(name: "AUO", highestPrice: 17.15, lowestPrice: 15.70, endPrice: 16.95, month: 8, day: 19), .init(name: "AUO", highestPrice: 16.95, lowestPrice: 16.60, endPrice: 16.95, month: 8, day: 22), .init(name: "AUO", highestPrice: 16.80, lowestPrice: 15.75, endPrice: 16.00, month: 8, day: 23), .init(name: "AUO", highestPrice: 16.40, lowestPrice: 16.05, endPrice: 16.15, month: 8, day: 24), .init(name: "AUO", highestPrice: 16.30, lowestPrice: 15.95, endPrice: 16.10, month: 8, day: 25), .init(name: "AUO", highestPrice: 16.25, lowestPrice: 16.00, endPrice: 16.15, month: 8, day: 26), .init(name: "AUO", highestPrice: 16.05, lowestPrice: 15.40, endPrice: 15.75, month: 8, day: 29), .init(name: "AUO", highestPrice: 16.70, lowestPrice: 16.15, endPrice: 16.40, month: 8, day: 30), .init(name: "AUO", highestPrice: 16.75, lowestPrice: 16.15, endPrice: 16.75, month: 8, day: 31), .init(name: "AUO", highestPrice: 17.25, lowestPrice: 16.45, endPrice: 17.10, month: 9, day: 1), .init(name: "AUO", highestPrice: 17.35, lowestPrice: 16.70, endPrice: 16.90, month: 9, day: 2), .init(name: "AUO", highestPrice: 17.45, lowestPrice: 16.80, endPrice: 17.40, month: 9, day: 5), .init(name: "AUO", highestPrice: 17.70, lowestPrice: 17.25, endPrice: 17.50, month: 9, day: 6), .init(name: "AUO", highestPrice: 17.60, lowestPrice: 17.10, endPrice: 17.40, month: 9, day: 7), .init(name: "AUO", highestPrice: 17.60, lowestPrice: 17.30, endPrice: 17.35, month: 9, day: 8), ] } ``` ## View 這邊要記得 ```import Charts```,因為我們要顯示 LineMark 在畫面上 然後這邊宣告了一個 ViewModel 的變數 vm 並在前面加上 ```@State``` 修飾字,讓 SwiftUI 來幫我們管理 ViewModel 狀態 接著是 Charts 的語法,語法也是很簡單,像是下面這樣 ```swift @State private var vm = StockEntityViewModel() // 1:vm.stockData,圖表的資料來源 Chart(vm.stockData) { LineMark( x: .value("Date", $0.date), // 2:x 軸要顯示的資料 y: .value("End Price", $0.endPrice) // 3:y 軸要顯示的資料 ) .foregroundStyle(by: .value("Stock Name", $0.name)) // 4:左下角的圖例樣式 or 圖表的外觀樣式 } ``` 或者你也可以透過 ForEach 來寫,只是就會要讓 Model 繼承 ```Identifiable``` 並宣告 UUID() 變數在 Model 裡面,像是這樣 ```var id = UUID().uuidString``` ```swift @State private var vm = StockEntityViewModel() Chart { // 1:vm.stockData,圖表的資料來源 ForEach(vm.stockData) { data in LineMark( x: .value("Date", data.date), // 2:x 軸要顯示的資料 y: .value("End Price", data.endPrice) // 3:y 軸要顯示的資料 ) .foregroundStyle(by: .value("Stock Name", data.name)) // 4:左下角的圖例樣式 or 圖表的外觀樣式 } } ``` ## 來幫每筆資料加上圖案吧 現在的圖應該會長得像是下面這樣 ![](https://i.imgur.com/ik5PaG9.png) 但這樣看起來,感覺有點單調,來幫每筆資料加上圖案好了 加圖案的語法也很簡單,像是下面這樣 ```swift @State private var vm = StockEntityViewModel() Chart { ForEach(vm.stockData) { data in LineMark( x: .value("Date", data.date), y: .value("End Price", data.endPrice) ) .foregroundStyle(by: .value("Stock Name", data.name)) .symbol(.square) // 圖案類型 .symbolSize(100) // 圖案大小 } } ``` 加完以後,現在會長的像是這樣 ![](https://i.imgur.com/tMtEUYE.png) 那總共有哪些圖案呢?讓我們來一起看一下~ ```swift @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) extension ChartSymbolShape where Self == BasicChartSymbolShape { /// 圓形樣式 public static var circle: BasicChartSymbolShape { get } /// 矩形樣式 public static var square: BasicChartSymbolShape { get } /// 三角形樣式 public static var triangle: BasicChartSymbolShape { get } /// 菱形樣式 public static var diamond: BasicChartSymbolShape { get } /// 五邊形樣式 public static var pentagon: BasicChartSymbolShape { get } /// 加號樣式 public static var plus: BasicChartSymbolShape { get } /// 十字樣式 public static var cross: BasicChartSymbolShape { get } /// 雪花樣式 public static var asterisk: BasicChartSymbolShape { get } } ``` ## 讓圖案樣式可以自由選擇 ### 基本版 首先我們先宣告一個 symbol 變數,以及建立一個 Menu ```swift @State private var symbol: BasicChartSymbolShape = .square ``` ```swift Menu { } label: { } ``` 然後在 ```label:``` 這個 Closure 裡面顯示一個 ```Text```,用來當作提示使用者的文字,像是下面這樣 ```swift Menu { } label: { Text("Choose Symbol") } ``` 接著,我們要來製作 Menu 裡面要顯示的選項,製作之前,先來看一下 Apple 是怎麼定義 Menu 的 ![](https://i.imgur.com/4eOrQfz.png) 在 content 這個 Closure 裡面要放的是 Menu 的選項,而選項都是透過 Button 來宣告的 所以如果要建立所有圖案樣式的 Menu 的話,可以像下面這樣寫 ```swift Menu { Button { symbol = .square } label: { Label("Square", systemImage: "square") } Button { symbol = .circle } label: { Label("Circle", systemImage: "circle") } Button { symbol = .triangle } label: { Label("Triangle", systemImage: "triangle") } Button { symbol = .diamond } label: { Label("Diamond", systemImage: "diamond") } Button { symbol = .pentagon } label: { Label("Pentagon", systemImage: "pentagon") } Button { symbol = .plus } label: { Label("Plus", systemImage: "plus") } Button { symbol = .cross } label: { Label("Cross", systemImage: "cross") } Button { symbol = .asterisk } label: { Label("Asterisk", systemImage: "asterisk") } } label: { Text("Choose Symbol") } ``` 現在畫面上看起來會長的像這樣 ![](https://i.imgur.com/N8pFuDE.png) ### 優化版 但這樣看起來,Code 實在太醜了,讓我們來做點修改吧 首先先建立一個叫做 MenuSymbolButton 的 SwiftUI View,像是下面這樣 ```swift struct MenuSymbolButton: View { var body: some View { } } ``` 然後在 body 裡面,宣告一個 Button 元件,像是下面這樣 ```swift struct MenuSymbolButton: View { var body: some View { Button { } label: { } } } ``` 接著在宣告要用來顯示每個圖案選項的 圖案文字 跟 圖案的圖片名稱 的變數 ```swift var symbolName: String // 圖案文字 var symbolImageName: String // 圖案的圖片名稱 ``` 接下來是最重要的一步,將連結兩個 View 之間的橋樑就在這裡了 宣告一個 ```@Binding``` 的 symbol 變數,用來跟另外一個 View 裡面的 ```@State``` symbol 變數進行綁定 ```swift @Binding var symbol: BasicChartSymbolShape ``` 到這邊 MenuSymbolButton 所需的變數都宣告好了,就讓我們組合起來吧 現在的 MenuSymbolButton 應該會長得像下面這樣 ```swift struct MenuSymbolButton: View { @Binding var symbol: BasicChartSymbolShape var symbolName: String var symbolImageName: String var body: some View { Button { } label: { } } } ``` 我們先來組合 ```label:``` 裡面要顯示的內容 ```swift Button { } label: { Label(symbolName, systemImage: symbolImageName) } ``` 接著再組合 ```action:``` 裡面要做的事情 這裡是透過 symbolImageName 來進行 switch ```swift Button { symbol = { switch symbolImageName { case "square": return .square case "circle": return .circle case "triangle": return .triangle case "diamond": return .diamond case "pentagon": return .pentagon case "plus": return .plus case "cross": return .cross case "asterisk": return .asterisk default: return .square } }() } label: { Label(symbolName, systemImage: symbolImageName) } ``` ### 將原本的 Menu 改寫 剛才將 MenuSymbolButton 設計好了,就要來將原本的一長串 Button 進行改寫了~ 改寫完,就長的像是下面這樣 ```swift Menu { MenuSymbolButton(symbol: $symbol, symbolName: "Square", symbolImageName: "square") MenuSymbolButton(symbol: $symbol, symbolName: "Circle", symbolImageName: "circle") MenuSymbolButton(symbol: $symbol, symbolName: "Triangle", symbolImageName: "triangle") MenuSymbolButton(symbol: $symbol, symbolName: "Diamond", symbolImageName: "diamond") MenuSymbolButton(symbol: $symbol, symbolName: "Pentagon", symbolImageName: "pentagon") MenuSymbolButton(symbol: $symbol, symbolName: "Plus", symbolImageName: "plus") MenuSymbolButton(symbol: $symbol, symbolName: "Cross", symbolImageName: "cross") MenuSymbolButton(symbol: $symbol, symbolName: "Asterisk", symbolImageName: "asterisk") } label: { Text("Choose Symbol") } ``` ### 將 Charts 的 symbol 修飾字改寫 到這邊,就只剩下最後一步要做了 現在 Charts 的 symbol 還是透過直接給值的方式,來做設定 現在就要來將這邊做改寫!改寫完會長的像下面這樣 ```swift @State private var vm = StockEntityViewModel() Chart { ForEach(vm.stockData) { data in LineMark( x: .value("Date", data.date), y: .value("End Price", data.endPrice) ) .foregroundStyle(by: .value("Stock Name", data.name)) .symbol(symbol) // 原本是直接給 .square,現在改成給 symbol 變數,讓 SwiftUI 自動更新變數狀態 .symbolSize(100) } } ``` ## 完整程式碼 ```swift import SwiftUI import Charts struct LineMarkView: View { @State private var symbol: BasicChartSymbolShape = .square @State private var vm = StockEntityViewModel() var body: some View { VStack { Chart { ForEach(vm.stockData) { data in LineMark( x: .value("Date", data.date), y: .value("End Price", data.endPrice) ) .foregroundStyle(by: .value("Stock Name", data.name)) .symbol(symbol) .symbolSize(100) } } .chartXAxisLabel("Date (2022/8/19~2022/9/8)", alignment: .leading) .chartYAxisLabel("Price (NTD)", alignment: .trailing) .frame(height: 300) .padding() Menu { MenuSymbolButton(symbol: $symbol, symbolName: "Square", symbolImageName: "square") MenuSymbolButton(symbol: $symbol, symbolName: "Circle", symbolImageName: "circle") MenuSymbolButton(symbol: $symbol, symbolName: "Triangle", symbolImageName: "triangle") MenuSymbolButton(symbol: $symbol, symbolName: "Diamond", symbolImageName: "diamond") MenuSymbolButton(symbol: $symbol, symbolName: "Pentagon", symbolImageName: "pentagon") MenuSymbolButton(symbol: $symbol, symbolName: "Plus", symbolImageName: "plus") MenuSymbolButton(symbol: $symbol, symbolName: "Cross", symbolImageName: "cross") MenuSymbolButton(symbol: $symbol, symbolName: "Asterisk", symbolImageName: "asterisk") } label: { Text("Choose Symbol") } } } } struct LineMarkView_Previews: PreviewProvider { static var previews: some View { LineMarkView() } } struct MenuSymbolButton: View { @Binding var symbol: BasicChartSymbolShape var symbolName: String var symbolImageName: String var body: some View { Button { symbol = { switch symbolImageName { case "square": return .square case "circle": return .circle case "triangle": return .triangle case "diamond": return .diamond case "pentagon": return .pentagon case "plus": return .plus case "cross": return .cross case "asterisk": return .asterisk default: return .square } }() } label: { Label(symbolName, systemImage: symbolImageName) } } } ``` ## 總結 這篇簡單實作了 Swift Charts 中的 LineMark 明天會來介紹 Swift Charts 中的 PointMark,讓我們繼續看下去吧~ ## 參考資料 > 1. [https://developer.apple.com/documentation/charts/areamark](https://developer.apple.com/documentation/charts/linemark)