How do I animate a Swift line mark chart

I have a view that displays a chart based on an array. Each element in the array is all of the attributes associated with a core data entity. The data is properly displayed. I would like to animate the rendering of the charts data but cannot seem to figure out how to do it. If someone could point me in the right direction it would be appreciated. Below is the code.

struct ClosingValuesChart: View {

    @State private var selectedDate: Date? = nil
    @State private var selectedClose: Float? = nil
    @State private var xAxisLabels: [Date] = []
    var closingValues: [TradingDayClose] = []
    var heading: String = ""
    
    init(fundName: String, numYears: Int, closingValues: [TradingDayClose]) {
        self.heading = fundName + String(" - \(numYears) Year")
        self.closingValues = closingValues
    }

    var body: some View {
        
        GroupBox (heading) {
            let xMin = closingValues.first?.timeStamp
            let xMax = closingValues.last?.timeStamp
            let yMin = closingValues.map { $0.close }.min()!
            let yMax = closingValues.map { $0.close }.max()!
            let xAxisLabels: [Date] = GetXAxisLabels(xMin: xMin!, xMax: xMax!)
            var yAxisLabels: [Float] {
                stride(from: yMin, to: yMax + ((yMax - yMin)/7), by: (yMax - yMin) / 7).map { $0 }
            }
            
            Chart {
                ForEach(closingValues) { value in

                    LineMark(
                        x: .value("Time", value.timeStamp!),
                        y: .value("Closing Value", value.close)
                    )
                    .foregroundStyle(Color.blue)
                    .lineStyle(StrokeStyle(lineWidth: 1.25))
                }
            }
            
            .chartXScale(domain: xMin!...xMax!)
            .chartXAxisLabel(position: .bottom, alignment: .center, spacing: 25) {
                Text("Date")
                    .textFormatting(fontSize: 14)
            }
            .chartXAxis {
                AxisMarks(position: .bottom, values: xAxisLabels) { value in
                    AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1))
                    AxisValueLabel(anchor: .top) {
                        if value.as(Date.self) != nil {
                            Text("")
                        }
                    }
                }
            }
            .chartYScale(domain: yMin...yMax)
            .chartYAxisLabel(position: .leading, alignment: .center, spacing: 25) {
                Text("Closing Value")
                    .font(Font.custom("Arial", size: 14))
                    .foregroundColor(.black)
            }
            .chartYAxis {
                AxisMarks(position: .leading, values: yAxisLabels) { value in
                    AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1))
                    AxisValueLabel() {
                        if let labelValue = value.as(Double.self) {
                            Text(String(format: "$ %.2f", labelValue))
                                .textFormatting(fontSize: 12)
                        }
                    }
                }
            }
            .chartOverlay { proxy in
                GeometryReader { geometry in
                    ChartOverlayRectangle(selectedDate: $selectedDate, selectedClose: $selectedClose, proxy: proxy, geometry: geometry, xMin: xMin!, xMax: xMax!, closingValues: closingValues)
                }
            }
            .overlay {
                XAxisDates(dateLabels: xAxisLabels)
            }
            .overlay {
                DateAndClosingValue(selectedDate: selectedDate ?? Date(), selectedClose: selectedClose ?? 0.0)
            }
        }
        .groupBoxStyle(ChartGroupBoxStyle())
    }
}

  • We miss a lot of definitions of struct or func to be able to test.

    Note: GetXAxisLabels should start with lowercase getXAxisLabels

Add a Comment

Accepted Reply

I could not find a simple way to do this. But I've found a possible workaround. I've not tested with Charts, but here would be the principle that works for a simple ForEach.

  • create an array of Bool with as many items as values, initiated to false
@State private var showData : [Bool]

In onAppear or when you hit a refresh button (where you first reset all showValue to false), execute this code:

            var counter = 0
            for value in closingValues {
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.2 * Double(counter)) { // adjust speed by changing 0.2
                        showData[counter] = true
                    }
                    counter += 1
                }
            }

Use it in ForEach

                ForEach(Array(closingValues.enumerated()), id: \.offset) { (index, value) in
                    if showData[index] {
                        LineMark(
                            x: .value("Time", value.timeStamp!),
                            y: .value("Closing Value", value.close)
                        )
                        .foregroundStyle(Color.blue)
                        .lineStyle(StrokeStyle(lineWidth: 1.25))
                    }
                }
  • Unable to initial showData with false. Used @State var showData: [Bool] = [] and in init: self.showData = Array(repeating: false, count: closingValues.count).showData is still empty. closingValues.count = 251

Add a Comment

Replies

I could not find a simple way to do this. But I've found a possible workaround. I've not tested with Charts, but here would be the principle that works for a simple ForEach.

  • create an array of Bool with as many items as values, initiated to false
@State private var showData : [Bool]

In onAppear or when you hit a refresh button (where you first reset all showValue to false), execute this code:

            var counter = 0
            for value in closingValues {
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.2 * Double(counter)) { // adjust speed by changing 0.2
                        showData[counter] = true
                    }
                    counter += 1
                }
            }

Use it in ForEach

                ForEach(Array(closingValues.enumerated()), id: \.offset) { (index, value) in
                    if showData[index] {
                        LineMark(
                            x: .value("Time", value.timeStamp!),
                            y: .value("Closing Value", value.close)
                        )
                        .foregroundStyle(Color.blue)
                        .lineStyle(StrokeStyle(lineWidth: 1.25))
                    }
                }
  • Unable to initial showData with false. Used @State var showData: [Bool] = [] and in init: self.showData = Array(repeating: false, count: closingValues.count).showData is still empty. closingValues.count = 251

Add a Comment

It works. My memory was wrong on assigning values to a @State variable in the init but after some research I corrected my error. Below is the updated view. Thank you for your input.

struct ClosingValuesChart: View {
    
    @State private var selectedDate: Date? = nil
    @State private var selectedClose: Float? = nil
    @State private var xAxisLabels: [Date] = []
    var closingValues: [TradingDayClose]
    var heading: String = ""
    var renderRate: Double
    
    @State var showData: [Bool]
    
    init(fundName: String, numYears: Int, closingValues: [TradingDayClose], renderRate: Double) {
        self.heading = fundName + String(" - \(numYears) Year")
        self.closingValues = closingValues
       _showData = State(initialValue: Array(repeating: false, count: closingValues.count))
        self.renderRate = renderRate
    }
    
    var body: some View {
        
        GroupBox (heading) {
            let xMin = closingValues.first?.timeStamp
            let xMax = closingValues.last?.timeStamp
            let yMin = closingValues.map { $0.close }.min()!
            let yMax = closingValues.map { $0.close }.max()!
            let xAxisLabels: [Date] = GetXAxisLabels(xMin: xMin!, xMax: xMax!)
            var yAxisLabels: [Float] {
                stride(from: yMin, to: yMax + ((yMax - yMin)/7), by: (yMax - yMin) / 7).map { $0 }
            }
            
            Chart {
                ForEach(Array(closingValues.enumerated()), id: \.offset ) { (index, value) in
                    if showData[index] {
                        LineMark(
                            x: .value("Time", value.timeStamp!),
                            y: .value("Closing Value", value.close)
                        )
                        .foregroundStyle(Color.blue)
                        .lineStyle(StrokeStyle(lineWidth: 1.25))
                    }
                }
            }
            .onAppear {
                for i in 0...closingValues.count - 1 {
                    DispatchQueue.main.asyncAfter(deadline: .now() + renderRate * Double(i)) {
                        showData[i] = true
                    }
                }
            }
            
            .chartXScale(domain: xMin!...xMax!)
            .chartXAxisLabel(position: .bottom, alignment: .center, spacing: 25) {
                Text("Date")
                    .textFormatting(fontSize: 14)
            }
            .chartXAxis {
                AxisMarks(position: .bottom, values: xAxisLabels) { value in
                    AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1))
                    AxisValueLabel(anchor: .top) {
                        if value.as(Date.self) != nil {
                            Text("")
                        }
                    }
                }
            }
            .chartYScale(domain: yMin...yMax)
            .chartYAxisLabel(position: .leading, alignment: .center, spacing: 25) {
                Text("Closing Value")
                    .font(Font.custom("Arial", size: 14))
                    .foregroundColor(.black)
            }
            .chartYAxis {
                AxisMarks(position: .leading, values: yAxisLabels) { value in
                    AxisGridLine(centered: true, stroke: StrokeStyle(lineWidth: 1))
                    AxisValueLabel() {
                        if let labelValue = value.as(Double.self) {
                            Text(String(format: "$ %.2f", labelValue))
                                .textFormatting(fontSize: 12)
                        }
                    }
                }
            }
            .chartOverlay { proxy in
                GeometryReader { geometry in
                    ChartOverlayRectangle(selectedDate: $selectedDate, selectedClose: $selectedClose, proxy: proxy, geometry: geometry, xMin: xMin!, xMax: xMax!, closingValues: closingValues)
                }
            }
            .overlay {
                XAxisDates(dateLabels: xAxisLabels)
            }
            .overlay {
                DateAndClosingValue(selectedDate: selectedDate ?? Date(), selectedClose: selectedClose ?? 0.0)
            }
        }
        .groupBoxStyle(ChartGroupBoxStyle())
    }
}

Thanks for the feedback.

.

Unable to initial showData with false. Used @State var showData: [Bool] = [] and in init: self.showData = Array(repeating: false, count: closingValues.count).showData is still empty. closingValues.count = 251

In fact, you have solved this issue when initializing a state var.

If you declare

@State var showData: [Bool] = []

then you should set the values in .onAppear, not init.

Or you must do it differently in init as you found:

let temp = Array(repeating: false, count: closingValues.count)
_showData = State(initialValue: temp)    // to change in init, need to change the internal storage _showData  Take care of starting underscore