Hello Swift Charts – Part 4

想要在圖表上加手勢,不是一件特別容易的事情,主要原因在於圖表上顯示的各種圖形,因為效率關係,其實已渲染成一張圖,所以手勢是加在整個圖表上,而不是加在每筆資料畫出來的個別圖上。所以我們想要點選圖表上的某個部分,讓該部分換個顏色或加上特別標記,就變的不是那麼容易。WWDC22的影片中有特別針對這部分給出範例,但說實在不是很容易懂,而且若座標軸的資料是 String 型態時,處理方式又跟給出的範例不一樣。

這裡以長條圖為例,並特別將 x 軸資料設定為 String 型態,來看看如何透過手勢來跟圖表互動。雖然程式碼略多,但處理邏輯是先取得手指在長條圖上的座標,然後取得長條圖上每個長條的左側座標與右側座標,接著判斷手指的座標落在哪一個長條範圍內,當確定手指落在哪一個長條後就重新渲染圖形一次,讓該長條改顏色,或是加點別的東西。

儲存資料的結構如下。

struct Product: Identifiable {
    var id = UUID()
    var color = Color.blue
    var name: String
    var count: Int
}

資料內容如下。這裡在變數前面加上 @State,主要目的是為了之後的程式碼可以直接修改每個元素中屬性 color 值。

struct BarChart: View {
    @State var products: [Product] = [
        .init(name: "A", count: 7),
        .init(name: "B", count: 10),
        .init(name: "C", count: 12),
        .init(name: "D", count: 15),
        .init(name: "E", count: 20),
        .init(name: "F", count: 23),
        .init(name: "G", count: 18),
        .init(name: "H", count: 17),
        .init(name: "I", count: 15),
        .init(name: "J", count: 11),
        .init(name: "K", count: 9)
    ]

先把圖畫出來。

var body: some View {
    Chart {
        ForEach(products) { item in
            BarMark(
                x: .value("Name", item.name),
                y: .value("Count", item.count)
            )
            .foregroundStyle(item.color)
        }
    }
}

接下來要加上手勢,但實際上手勢無法直接加在圖表上,所以必須先在圖表上頭疊一個圖層,圖層中畫上一個透明矩形,矩形大小與位置都跟圖表一模一樣,手勢是加在這個矩形上。然後將矩形放到 GeometryReader 中,其目的就是為了要取得手勢觸發時手指點選在圖表上的座標位置。矩形的 contentShape 修飾器是為了讓手勢可以作用,若沒有這個修飾器,即便加了手勢也不會有反應。

Chart {
    ...
}
.chartOverlay { chartProxy in
    GeometryReader { geoProxy in
        Rectangle().fill(.clear).contentShape(Rectangle())
    }
}

接下來要在 Rectangle 上增加拖放手勢。變數 xPosition 取得手指在圖表上實際繪圖的位置,也就是扣除了 x、y 座標軸上的標記以及邊線大小…等跟實際圖形無關的部分。接下來的 for 迴圈會計算長條圖上每個長條的左側座標與右側座標,這個就是 positionRange(atX:) 函數的功能。知道每個長條的範圍後,接著檢查手指的位置落在哪一個長條範圍內,確定後改變負責該長條資料的 color 屬性,並將該資料所在的陣列索引值記錄到 coloredIndex 變數中,稍後說明這個變數的宣告方式。

Chart {
    ...
}
.chartOverlay { chartProxy in
    GeometryReader { geoProxy in
        Rectangle().fill(.clear).contentShape(Rectangle())
            .gesture(DragGesture()
                .onChanged { value in
                    let xPosition = value.location.x - geoProxy[chartProxy.plotAreaFrame].origin.x
                    // 復原顏色
                    if let coloredIndex {
                        products[coloredIndex].color = .blue
                    }
                    // 檢查每一個長條的座標是否符合手指的座標,若符合則設定顏色
                    for index in products.indices {
                        if let range = chartProxy.positionRange(forX: products[index].name) {
                            if range.contains(xPosition) {
                                products[index].color = .orange
                                coloredIndex = index
                                break
                            }
                        }
                    }
                }
                .onEnded { _ in
                    if let coloredIndex {
                        products[coloredIndex].color = .blue
                    }
                }
            )
    }
}

最後宣告 coloredIndex 變數,如下。

@State var coloredIndex: Int? = nil

到此大功告成,執行看看。

發表迴響