swiftuiswiftcharts

In Swift Charts, how to place a RuleMark behind a BarMark with animations enabled


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()
}

Solution

  • 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)
                    }
                }
            }
        }
    }