Search code examples
swiftswiftuiswiftui-charts

Swift UI charts Annotation is slow


I have a SwitUI chart with about 180 points. I have an annotation as part of the chart. The annotation tracks the mouse movement slowly, not fast enough. Is there anything I need to do differently in the code below to speed up the tracking of the annotation to the mouse/finger?

It is basically a Line and Area chart combo with simulated values. thanks in advance

Below is the main ContentView code which basically creates the LineArea chart. The annotation is attached to the LineMark

Dummy data is generated in .task{ }

struct ContentView: View {
    @State var selectedDate: Date?
    @State private var isLoading: Bool = true
    @State private var data : [DateValue] = []
    
    var body: some View {
        VStack {
            Chart {
                ForEach(data, id:\.date) { point in
                    LineMark(
                        x: .value("Month", point.date, unit: .month),
                        y: .value("Value", point.value)
                    )
                    .lineStyle(StrokeStyle(lineWidth: 3))
                    if let selectedDate {
                        RuleMark(x: .value("Month", selectedDate.endOfMonth()))
                            .annotation(position: .automatic, alignment: .bottom,
                                        overflowResolution: .init(x: .fit, y: .fit)
                            ) {
                                VStack {
                                    let value = getValue(for: selectedDate.endOfMonth())
                                    Text("Month: \(value.0)")
                                    Text("Value: \(value.1)")
                                }
                                .foregroundColor(.white)
                                .padding()
                                .background {
                                    RoundedRectangle(cornerRadius: 5)
                                }
                            }
                    }
                    AreaMark(
                        x: .value("Month", point.date, unit: .month),
                        y: .value("Value", point.value)
                    )
                }
                .interpolationMethod(.catmullRom)
            }
            .chartXSelection(value: $selectedDate)
            .chartXAxis {
                AxisMarks(values: .automatic) { month in
                    AxisTick()
                    AxisGridLine()
                    AxisValueLabel {
                        if let m = month.as(Date.self) {
                            Text("\(m.toString(format: "MM/yy"))")
                        }
                    }
                }
            }
            .chartYAxis {
                AxisMarks(values: .automatic) { value in
                    AxisTick(centered: false)
                    AxisGridLine()
                    AxisValueLabel {
                        if let v = value.as(Double.self) {
                            Text("\(v/1000.0, specifier: "%0.0fK")")
                        }
                    }
                }
            }
        }
        .task {
            guard isLoading else { return }
            let start = Calendar.current.date(from: DateComponents(year: 2007, month: 1, day: 10))!
            let end = Calendar.current.date(from: DateComponents(year: 2023, month: 12, day: 10))!
            let months = end.months(from: start)

            self.data = (0 ..< months).map{index -> DateValue in
                return DateValue(date: start.plusMonths(index).endOfMonth(), value: Double.random(in: 1000...1600))
            }
            isLoading.toggle()
        }
    }
    
    private func getValue(for date: Date) -> (String, String) {
        let value = data.first(where: {Calendar.current.isDate($0.date, equalTo: date, toGranularity: .month) == true})?.value ?? 0
        return (date.toString(format: "MM/yyyy"), String(format: "%.2f", value))
    }
}

struct DateValue {
    var date: Date
    var value: Double
}

extension Date {
    func startOfMonth() -> Date {
        var components = Calendar.current.dateComponents([.year, .month], from: self)
        components.day = 1
        return Calendar.current.date(from: components)!
    }
    func endOfMonth() -> Date {
        Calendar.current.date(byAdding: DateComponents(month: 1, day: -1), to: startOfMonth())!
    }
    func plusMonths(_ plus: Int) -> Date {
        Calendar.current.date(byAdding: .month, value: plus, to: self)!
    }
    func months(from date: Date) -> Int {
        Calendar.current.dateComponents([.month], from: date, to:self).month ?? 0
    }
    func toString(format: String = "yyyy-MM-dd") -> String {
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.dateFormat = format
        return formatter.string(from: self)
    }
}

Solution

  • You have put the RuleMark inside the ForEach. This means that when selectedDate is not nil, there will be about 180 RuleMarks all overlapping each other on the chart. It's no surprise that this is slow.

    You only need one RuleMark. Put it outside the ForEach.

    Chart {
        ForEach(data, id:\.date) { point in
            LineMark(
                x: .value("Month", point.date, unit: .month),
                y: .value("Value", point.value)
            )
            .lineStyle(StrokeStyle(lineWidth: 3))
            AreaMark(
                x: .value("Month", point.date, unit: .month),
                y: .value("Value", point.value)
            )
        }
        .interpolationMethod(.catmullRom)
        
        
        if let selectedDate {
            RuleMark(x: .value("Month", selectedDate.endOfMonth()))
                .annotation(position: .automatic, alignment: .bottom,
                            overflowResolution: .init(x: .fit, y: .fit)
                ) {
                    VStack {
                        let value = getValue(for: selectedDate.endOfMonth())
                        Text("Month: \(value.0)")
                        Text("Value: \(value.1)")
                    }
                    .foregroundColor(.white)
                    .padding()
                    .background {
                        RoundedRectangle(cornerRadius: 5)
                    }
                }
        }
    }