Search code examples
animationswiftuicharts

How do I animate an Swift Linemark chart


I have a view that displays a chart. It works. I would like to add animation such that the line is drawn over the course of several seconds. I have tried to use animation and change the associated value on appear but it does not work. I have also looked at with animation but I could not get it to work either. Any ideas would be appreciated. Below is my code:

struct ClosingValuesChart: View {
    @State private var selectedDate: Date? = nil
    @State private var selectedClose: Float? = nil
    @State private var xAxisLabels: [Date] = []
    @State private var startAnimation: CGFloat = 1
    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))
                }
            }
            .animation(.linear, value: startAnimation)
            .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())
        .onAppear {startAnimation = 2}
    }
}

Solution

  • Turns out I missed the obvious. Anytime you change the value of a state variable the associated view is rendered with the new value. So changing a state variable on appear over time creates the desired animation. In this case the state variable is an array of booleans. Initially no data points are drawn because the array, with as many elements as data points, only contains false (see if statement in chart block). In the on appear each element in the array is changed to true which as stated above causes the view to be redrawn with the new data. Because the changes happen over time via the dispatch.main.asyncAfter statement, the net result is animation of the line being displayed. Below is the updated 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 = ""
        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
                            print("\(i)")
                        }
                    }
                }
                
                .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())
        }
    }