SwiftUI Charts: poor performance when synchronizing scrolling among multiple charts

I'm currently learning Swift and SwiftUI. I'm working at a MacOS app that will need to visualize a number of traces. The user must be able to scroll along the X axis of any of the traces and the other ones should be scrolling synchronously (think about a visualization similar to Apple's own "Instruments" app).

I'm using SwiftUI Charts framework for visualizing the traces, this on MacOS 14.0 (beta at the time of writing) and Xcode 15 beta 6. The app is also built against the latest MacOS target (explicitly to use the new Charts functionalities).

My implementation manages to correctly synchronize the scrolling of the separate charts using a binding in combination with the chartScrollPosition method, however the scrolling performance is really terrible (the scrolling movement is sluggish and jittery).

If I disable the synchronization by removing the binding and chartScrollPosition calls, the charts can be scrolled independently with no performance issues.

I have the feeling that I'm making some mistake in the way I used the binding, which is causing some internal loop in the UI update/drawing process. This is also supported by the logging message I get in the console when I'm scrolling: onChange(of: Optional\<CGRect\>) action tried to update multiple times per frame.

I included below the code for a test app that reproduces the issue:

ContentView.swift

import SwiftUI

struct ContentView: View {
    @State private var markerValue = 0.0
    
    var body: some View {
        VStack {
            DataTraceView(markerValue: $markerValue)
        }
        .padding()
    }
}

DataTraceView.swift

import SwiftUI
import Charts

private let SAMPLES = 10000

func generateRandomDataPoints(count: Int) -> [DataPoint] {
    return (0..<count).map { idx in
        DataPoint(id: idx, xValue: Double(idx), yValue: Double.random(in: 0..<1))
    }
}

struct DataPoint: Identifiable {
    var id: Int
    var xValue: Double
    var yValue: Double
}

struct DataTraceView: View {
    
    @State var testVector1: [DataPoint] = generateRandomDataPoints(count: SAMPLES)
    @State var testVector2: [DataPoint] = generateRandomDataPoints(count: SAMPLES)
    
    @Binding var markerValue: Double
    
    var body: some View {
        VStack {
            Chart(testVector1) {
                LineMark(
                    x: .value("Time", $0.xValue),
                    y: .value("Speed", $0.yValue)
                )
            }
            .chartScrollPosition(x: $markerValue)
            .chartXVisibleDomain(length: 15)
            .chartScrollableAxes(.horizontal)
            Chart(testVector2) {
                LineMark(
                    x: .value("Time", $0.xValue),
                    y: .value("LatG", $0.yValue)
                )
            }
            .chartScrollPosition(x: $markerValue)
            .chartXVisibleDomain(length: 15)
            .chartScrollableAxes(.horizontal)
        }
    }
}

Any ideas about what am I doing wrong?

Post not yet marked as solved Up vote post of ludwig980 Down vote post of ludwig980
1.4k views

Replies

I'm seeing the same issue with iOS when using chart scroll position, I thought it was an issue with my app but testing in a playground I can recreate the issue. Hoping this is just a beta issue.

Same issue with iOS on Xcode 15.0.1. As you say, the cause of the bad performance is the modifier chartScrollPosition(x: Binding<some Plottable>). Hope they fix it soon.

Try to move your Charts into subviews, and their bodies won't be called on markerValue change.

struct SpeedChartView: View {
    var data: [DataPoint]
    
    var body: some View {
        Chart(data) {
            LineMark(
                x: .value("Time", $0.xValue),
                y: .value("Speed", $0.yValue)
            )
        }
    }
}

struct LatChartView: View {
    var data: [DataPoint]
    
    var body: some View {
        Chart(data) {
            LineMark(
                x: .value("Time", $0.xValue),
                y: .value("LatG", $0.yValue)
            )
        }
    }
}

struct DataTraceView: View {
    
    @State var testVector1: [DataPoint] = generateRandomDataPoints(count: SAMPLES)
    @State var testVector2: [DataPoint] = generateRandomDataPoints(count: SAMPLES)
    
    @Binding var markerValue: Double
    
    var body: some View {
        VStack {
            SpeedChartView(data: testVector1)
                .chartScrollPosition(x: $markerValue)
                .chartXVisibleDomain(length: 15)
                .chartScrollableAxes(.horizontal)
            LatChartView(data: testVector2)
                .chartScrollPosition(x: $markerValue)
                .chartXVisibleDomain(length: 15)
                .chartScrollableAxes(.horizontal)
        }
    }
}