I have a simple Swift Chart where I am trying to place a RuleMark behind a BarMark.
When the RuleMark is included before the BarMark, the bars become glitchy and move around as the selection moves. Reproducible example below.
import SwiftUI
import Charts
struct datStruct: Identifiable {
let id = UUID()
let label: String
let value: Int
}
struct ContentView: View {
let dat = [
datStruct(label: "A", value: 1),
datStruct(label: "B", value: 2),
datStruct(label: "C", value: 3)
]
@State var rawSelection: String?
var selection: datStruct? {
guard let rawSelection else {return nil}
return dat.first(where: { $0.label == rawSelection })
}
var body: some View {
Chart(){
if let selection {
RuleMark(x: .value("label", selection.label))
.foregroundStyle(.red)
}
ForEach(dat) {dat in
BarMark(x: .value("label", dat.label), y: .value("count", dat.value))
}
}
.chartXSelection(value: $rawSelection)
}
}
#Preview {
ContentView()
}
When the RuleMark is moved to after the BarMark and a negative ZIndex is applied, the rule displays behind the bar as desired. But, when I then add animation to the selection, the rule briefly appears above the bar and then animates to behind it.
How can I alway show the RuleMark behind the BarMark with animation applied?
import SwiftUI
import Charts
struct datStruct: Identifiable {
let id = UUID()
let label: String
let value: Int
}
struct ContentView: View {
let dat = [
datStruct(label: "A", value: 1),
datStruct(label: "B", value: 2),
datStruct(label: "C", value: 3)
]
@State var rawSelection: String?
var selection: datStruct? {
guard let rawSelection else {return nil}
return dat.first(where: { $0.label == rawSelection })
}
var body: some View {
Chart(){
ForEach(dat) {dat in
BarMark(x: .value("label", dat.label), y: .value("count", dat.value))
}
if let selection {
RuleMark(x: .value("label", selection.label))
.foregroundStyle(.red)
.zIndex(-1)
}
}
.chartXSelection(value: $rawSelection.animation())
}
}
#Preview {
ContentView()
}
The bars "move around as the selection moves" is related to how Charts orders discrete X axis values automatically. The default behaviour is just "order them in the order of appearance".
Suppose you are selecting "A", the first X value that appears in the chart is "A", so the X axis goes "A", "B", "C". Then, when you move your finger to "B", the RuleMark
is now at "B". This changes the order of the X values to "B", "A", "C". This causes the bars to move to new positions. As a result of this, your finger is now at "A" again, and so the bars rearrange themselves to "A", "B", "C" again, and the cycle continues.
You can add a chartXScale
modifier to the Chart
, to specify how exactly the x values should be ordered, in order to stop them from being ordered automatically.
For example, here I ordered them in the same order as they appear in dat
.
.chartXScale(domain: .automatic(dataType: String.self) { $0 = dat.map(\.label) })
Alternatively use a chartOverlay
instead of RuleMark
. In the overlay, put a Rectangle
of width 1.
.chartOverlay { chart in
if let frameAnchor = chart.plotFrame {
GeometryReader { geo in
let frame = geo[frameAnchor]
if let rawSelection {
if let x = chart.position(forX: rawSelection) {
Rectangle()
.fill(.red)
.frame(width: 1, height: frame.height)
.offset(x: frame.minX + x, y: frame.minY)
}
}
}
}
}