###### tags: `第14屆IT邦鐵人賽文章`
# 【在 iOS 開發路上的大小事2-Day26】來自 Apple 爸爸的最新力作 - Swift Charts 之 PointMark 實作篇
上一篇介紹了 LineMark 的實作,今天要來介紹的是 Swift Charts 的 PointMark
PointMark 一共提供了三種 init 的方法,讓開發者可以繪製不同樣式的圖表
```swift
public init<X, Y>(x: PlottableValue<X>,
y: PlottableValue<Y>) where X : Plottable, Y : Plottable
public init<X>(x: PlottableValue<X>, y: CGFloat? = nil) where X : Plottable
public init<Y>(x: CGFloat? = nil, y: PlottableValue<Y>) where Y : Plottable
```
我們就以護國神山台積電跟友達的股票收盤價來當作範例
## Model
```swift
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
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),
.init(name: "TSMC", highestPrice: 491.00, lowestPrice: 485.00, endPrice: 486.00, month: 9, day: 12),
.init(name: "TSMC", highestPrice: 495.00, lowestPrice: 491.00, endPrice: 493.00, month: 9, day: 13),
.init(name: "TSMC", highestPrice: 482.50, lowestPrice: 476.00, endPrice: 480.00, month: 9, day: 14),
.init(name: "TSMC", highestPrice: 480.00, lowestPrice: 476.00, endPrice: 476.50, month: 9, day: 15),
.init(name: "TSMC", highestPrice: 472.00, lowestPrice: 469.00, endPrice: 472.00, month: 9, day: 16),
.init(name: "TSMC", highestPrice: 473.00, lowestPrice: 466.00, endPrice: 467.00, month: 9, day: 19),
.init(name: "TSMC", highestPrice: 478.00, lowestPrice: 470.00, endPrice: 476.50, month: 9, day: 20),
.init(name: "TSMC", highestPrice: 475.50, lowestPrice: 468.50, endPrice: 471.00, month: 9, day: 21),
.init(name: "TSMC", highestPrice: 468.00, lowestPrice: 459.00, endPrice: 464.50, month: 9, day: 22),
.init(name: "TSMC", highestPrice: 460.50, lowestPrice: 455.00, endPrice: 455.00, month: 9, day: 23),
.init(name: "TSMC", highestPrice: 454.00, lowestPrice: 443.00, endPrice: 446.50, month: 9, day: 26),
.init(name: "TSMC", highestPrice: 451.50, lowestPrice: 446.00, endPrice: 448.00, month: 9, day: 27),
.init(name: "TSMC", highestPrice: 449.00, lowestPrice: 438.00, endPrice: 438.00, month: 9, day: 28),
.init(name: "TSMC", highestPrice: 443.50, lowestPrice: 432.00, endPrice: 435.00, month: 9, day: 29),
.init(name: "TSMC", highestPrice: 427.50, lowestPrice: 422.00, endPrice: 422.00, month: 9, day: 30),
// 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),
.init(name: "AUO", highestPrice: 17.60, lowestPrice: 17.25, endPrice: 17.25, month: 9, day: 12),
.init(name: "AUO", highestPrice: 17.50, lowestPrice: 17.20, endPrice: 17.35, month: 9, day: 13),
.init(name: "AUO", highestPrice: 17.20, lowestPrice: 16.95, endPrice: 17.05, month: 9, day: 14),
.init(name: "AUO", highestPrice: 18.00, lowestPrice: 17.25, endPrice: 18.00, month: 9, day: 15),
.init(name: "AUO", highestPrice: 18.15, lowestPrice: 17.80, endPrice: 18.10, month: 9, day: 16),
.init(name: "AUO", highestPrice: 18.10, lowestPrice: 17.65, endPrice: 17.95, month: 9, day: 19),
.init(name: "AUO", highestPrice: 18.05, lowestPrice: 17.10, endPrice: 17.10, month: 9, day: 20),
.init(name: "AUO", highestPrice: 17.45, lowestPrice: 16.30, endPrice: 16.80, month: 9, day: 21),
.init(name: "AUO", highestPrice: 16.70, lowestPrice: 16.05, endPrice: 16.25, month: 9, day: 22),
.init(name: "AUO", highestPrice: 16.35, lowestPrice: 15.80, endPrice: 15.80, month: 9, day: 23),
.init(name: "AUO", highestPrice: 15.60, lowestPrice: 15.05, endPrice: 15.10, month: 9, day: 26),
.init(name: "AUO", highestPrice: 15.40, lowestPrice: 15.00, endPrice: 15.05, month: 9, day: 27),
.init(name: "AUO", highestPrice: 15.00, lowestPrice: 14.60, endPrice: 14.70, month: 9, day: 28),
]
}
```
## View
這邊要記得 ```import Charts```,因為我們要顯示 PointMark 在畫面上
然後這邊宣告了一個 ViewModel 的變數 vm
並在前面加上 ```@State``` 修飾字,讓 SwiftUI 來幫我們管理 ViewModel 狀態
接著是 Charts 的語法,語法也是很簡單,像是下面這樣
```swift
@State private var vm = StockEntityViewModel()
// 1:vm.stockData,圖表的資料來源
Chart(vm.stockData) {
PointMark(
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
PointMark(
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/UjPTeqU.png)
那除了圓形樣式的 symbol,還可以更換成其他的圖案嗎?答案是可以的!
加圖案的語法也很簡單,像是下面這樣
```swift
Chart {
ForEach(vm.stockData) { data in
PointMark(
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/jLtr1Z5.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/qQk6mQ2.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
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 PointMarkView: View {
@State private var symbol: BasicChartSymbolShape = .square
@State private var vm = StockPriceViewModel()
var body: some View {
VStack {
Chart {
ForEach(vm.stockData) { stock in
PointMark(
x: .value("Date", stock.date),
y: .value("End Price", stock.endPrice)
)
.foregroundStyle(by: .value("Stock Name", stock.name))
.symbol(symbol)
.symbolSize(100)
}
}
.chartXAxisLabel("Date (2022/8/19~2022/9/30)", 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 PointMarkView_Previews: PreviewProvider {
static var previews: some View {
PointMarkView()
}
}
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 中的 PointMark
明天會來介紹 Swift Charts 中的 RectangleMark,讓我們繼續看下去吧~
## 參考資料
> 1. [https://developer.apple.com/documentation/charts/pointmark](https://developer.apple.com/documentation/charts/pointmark)