Charts performance issue

Hi, I want to recreate a chart from Apple Health and I have code like this. When I scroll - especially the week and month charts, there are performance issues. If I remove .chartScrollPosition(x: $scrollChartPosition), it runs smoothly, but I need to know which part of the chart is currently displayed. Can you help me?

import Charts                                                
import SwiftUI

struct MacroChartView: View {
    var selectedRange: ChartRange
    var binnedPoints: [MacroBinPoint]
    @State private var scrollChartPosition: Date = .now
    var body: some View {
        VStack {
            Text("\(selectedRange.rangeLabel(for: scrollChartPosition))")
            Chart(binnedPoints) { point in
                BarMark(
                    x: .value("Date", point.date, unit: selectedRange.binComponent),
                    y: .value("Calories", point.calories)
                )
            }
            .frame(height: 324)
            .chartXVisibleDomain(length: selectedRange.visibleDomainLength())
            .chartScrollableAxes(.horizontal)
            .chartScrollPosition(x: $scrollChartPosition)
            .chartScrollTargetBehavior(.valueAligned(matching: selectedRange.scrollAlignmentComponents))
            .chartXAxis {
                switch selectedRange {
                case .week:
                    AxisMarks(values: .stride(by: .day)) { date in
                        AxisGridLine()
                        AxisTick()
                        AxisValueLabel(format: .dateTime.weekday(.abbreviated))
                    }
                case .month:
                    AxisMarks(values: .stride(by: .weekOfYear)) { date in
                        AxisGridLine()
                        AxisTick()
                        AxisValueLabel(format: .dateTime.day())
                    }
                case .halfYear:
                    AxisMarks(values: .stride(by: .month)) { date in
                        AxisGridLine()
                        AxisTick()
                        AxisValueLabel(format: .dateTime.month(.abbreviated))
                    }
                case .year:
                    AxisMarks(values: .stride(by: .month)) { date in
                        AxisGridLine()
                        AxisTick()
                        AxisValueLabel(format: .dateTime.month(.abbreviated))
                    }
                }
            }
        }
    }
}
enum MeasurementHistoryMode {
    case macros
    case comparisons
}

enum MacroKindToDisplay {
    case protein, fat, carbs
}

enum MacrosDisplayMode: Equatable {
    case all
    case single(MacroKindToDisplay)
}

enum ChartRange: String, CaseIterable {
    case week = "T"
    case month = "M"
    case halfYear = "6M"
    case year = "R"
    
    var binComponent: Calendar.Component {
        switch self {
        case .week, .month: return .day
        case .halfYear: return .weekOfYear
        case .year: return .month
        }
    }
    
    var scrollAlignmentComponents: DateComponents {
        switch self {
        case .week:
            return DateComponents(hour: 0, minute: 0, second: 0)
        case .month:
            return DateComponents(hour: 0)
        case .halfYear:
            return DateComponents(weekday: 1)
        case .year:
            return DateComponents(day: 1)
        }
    }
    
    func visibleDomainLength() -> Int {
        switch self {
        case .week:
            return 7 * 24 * 60 * 60
        case .month:
            return 31 * 24 * 60 * 60
        case .halfYear:
            return 6 * 31 * 24 * 60 * 60
        case .year:
            return 12 * 31 * 24 * 60 * 60
        }
    }
    
    func start(for date: Date) -> Date {
        let cal = Calendar.current
        switch self {
        case .week, .month:
            return cal.startOfDay(for: date)
        case .halfYear:
            return cal.dateInterval(of: .weekOfYear, for: date)?.start ?? cal.startOfDay(for: date)
        case .year:
            return cal.dateInterval(of: .month, for: date)?.start ?? cal.startOfDay(for: date)
        }
    }
    
    func rangeLabel(for start: Date) -> String {
        let end = start.addingTimeInterval(TimeInterval(visibleDomainLength()))
        let f = DateFormatter()
        f.dateFormat = Calendar.current.isDate(start, inSameDayAs: end) ? "MMM d" : "MMM d"
        return Calendar.current.isDate(start, inSameDayAs: end) ? f.string(from: start) : "\(f.string(from: start)) – \(f.string(from: end))"
    }
}

struct MacrosPoint: Identifiable {
    var id: Date { date }
    let date: Date
    let calories: Double
    let proteinInGrams: Double
    let carbsInGrams: Double
    let fatInGrams: Double
}

struct MacroBinPoint: Identifiable {
    var id: Date { date }
    let date: Date
    let calories: Double
    let proteinKcal: Double
    let carbsKcal: Double
    let fatKcal: Double
}

func bin(points: [MacrosPoint], for period: ChartRange) -> [MacroBinPoint] {
    let grouped = Dictionary(grouping: points) { point in
        period.start(for: point.date)
    }
    
    let bins = grouped.map { (start, items) -> MacroBinPoint in
        var calories = items.reduce(0) { $0 + $1.calories }
        var proteinKcal = items.reduce(0) { $0 + $1.proteinInGrams * 4 }
        var carbsKcal = items.reduce(0) { $0 + $1.carbsInGrams * 4 }
        var fatKcal = items.reduce(0) { $0 + $1.fatInGrams * 9 }
        
        calories /= Double(items.count)
        proteinKcal /= Double(items.count)
        carbsKcal /= Double(items.count)
        fatKcal /= Double(items.count)
        
        return MacroBinPoint(date: start, calories: calories, proteinKcal: proteinKcal, carbsKcal: carbsKcal, fatKcal: fatKcal)
    }
        .sorted { $0.date < $1.date }
    return bins
}

struct ExampleData {
static let macrosPoints: [MacrosPoint] = [
    MacrosPoint(date: Date(timeIntervalSince1970: 1687949774), calories: 1895, proteinInGrams: 115, carbsInGrams: 192, fatInGrams: 72),...
    ]

Hi everyone, I observe similar performance issue. They performance is degraded when the x axis domain is enlarged. I add 5 bars per day, and around 150 days there is a visible jitter and tearing while dragging.

I've tried various techniques to improve it such as large xAxis and displaying events only around the scroll position, however as noted above using it lowers the performance. I've also tried prepending and apending days, without having a large x axis domain, but this makes the scroll position jump.

Here is another code sample.

import Charts
import SwiftUI

struct PerformanceBarChart: View {

    struct DataPoint: Identifiable {
        let id = UUID()
        let date: Date
        let x: Int
        let yStart: Date
        let yEnd: Date
        let category: String
    }

    private var today = Date.now.startOfDay()

    @State private var data: [DataPoint] = []
    @State private var scrollPosition: Date = Date.now
    @State private var loading = false

    @State var xAxisDomain: ClosedRange<Date> = Date.now...Date.now

    private let calendar = Calendar.autoupdatingCurrent

    private let visibleRange: TimeInterval = 7 * 24 * 60 * 60
    private let chunkSize: Int = 30

    var body: some View {
        VStack {
            Text("X Axis Domain Length: \(data.count / 5)")
            Text("Until 90 it is okay, after 150 lag is noticable.")
            HStack {
                Button("prepend") { generate() }
                    .buttonStyle(.bordered)

                Text(
                    "Scroll jumps if not at end. It cannot be controlled. There is no value change, only visual."
                )
            }
            .padding()

            HStack {
                Button("set large x domain") {
                    var startComponents = DateComponents()
                    startComponents.year =
                        calendar.component(.year, from: today) - 1
                    startComponents.month = 1
                    startComponents.day = 1
                    startComponents.hour = 0
                    startComponents.minute = 0
                    startComponents.second = 0
                    let start = calendar.date(from: startComponents)!

                    xAxisDomain = start...data.last!.date
                }
                .buttonStyle(.bordered)
                Text(
                    "X Axis stats from Jan 1st last year. Even if not data is added scrolling is slow."
                )
            }
            .padding()

            Text(scrollPosition, format: .dateTime)
            Chart(data) {
                BarMark(
                    x: .value("Date", $0.date, unit: .day),
                    yStart: .value("Start", $0.yStart),
                    yEnd: .value("End", $0.yEnd)
                )
                .foregroundStyle(by: .value("Category", $0.category))
            }
            .chartScrollableAxes(.horizontal)
            .chartXVisibleDomain(length: 7 * 24 * 3600)
            .chartXAxis {
                AxisMarks(values: .stride(by: .day)) { value in
                    AxisGridLine()
                    AxisTick()
                    AxisValueLabel(format: .dateTime.day().month())
                }
            }
            .chartXScale(domain: xAxisDomain)
            .chartYScale(domain: today.startOfDay()...today.endOfDay())
            .chartScrollPosition(x: $scrollPosition)
            .chartScrollPosition(initialX: today)
            .frame(height: 450)
            .padding()
            .onAppear {
                generate()
            }
        }
    }

    private func generate() {
        let first = data.first
        let date = first?.date ?? today
        let x = first?.x ?? 1

        var newPoints = [DataPoint]()
        newPoints.reserveCapacity(3 * chunkSize)

        for i in 1...chunkSize {
            let date = calendar.date(byAdding: .day, value: -i, to: date)!

            newPoints.append(
                DataPoint(
                    date: date,
                    x: x - i,
                    yStart: todayAt(hour: 2, minute: 0),
                    yEnd: todayAt(hour: 3, minute: 0),
                    category: String(Int.random(in: 1...3))
                )
            )

            newPoints.append(
                DataPoint(
                    date: date,
                    x: x - i,
                    yStart: todayAt(hour: 4, minute: 30),
                    yEnd: todayAt(hour: 6, minute: 30),
                    category: String(Int.random(in: 1...3))
                )
            )

            newPoints.append(
                DataPoint(
                    date: date,
                    x: x - i,
                    yStart: todayAt(hour: 9, minute: 30),
                    yEnd: todayAt(hour: 10, minute: 30),
                    category: String(Int.random(in: 1...3))
                )
            )

            newPoints.append(
                DataPoint(
                    date: date,
                    x: x - i,
                    yStart: todayAt(hour: 15, minute: 30),
                    yEnd: todayAt(hour: 17, minute: 30),
                    category: String(Int.random(in: 1...3))
                )
            )

            newPoints.append(
                DataPoint(
                    date: date,
                    x: x - i,
                    yStart: todayAt(hour: 20, minute: 0),
                    yEnd: todayAt(hour: 21, minute: 30),
                    category: String(Int.random(in: 1...3))
                )
            )
        }

        data.insert(contentsOf: newPoints.reversed(), at: 0)

        xAxisDomain = data.first!.date...data.last!.date
    }

    private func todayAt(hour: Int, minute: Int) -> Date {
        return calendar.date(
            bySettingHour: hour,
            minute: minute,
            second: 0,
            of: today
        )!
    }
}

#Preview {
    PerformanceBarChart()
}

The solution is describe here: https://developer.apple.com/forums/thread/763757.

The x axis marks are causing the issue. I was able to implement a solution by following the suggested code. Now I don't have a performance issue when scrolling in charts.

Charts performance issue
 
 
Q