SwiftUI Charts: poor performance when synchronizing scrolling among multiple charts

386 views Asked by At

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?

1

There are 1 answers

1
Jack Goossen On

I don't think you're doing anything wrong. SwiftUI is just not suitable for high performance syncing of scroll positions. But you can probably get your desired (performant) behaviour by making your Charts fixed width, non-scrollable and adding them to a single Scrollview.

import SwiftUI
import Charts
private let SAMPLES = 10000

let testVector1: [DataPoint] = generateRandomDataPoints(count: SAMPLES)
let testVector2: [DataPoint] = generateRandomDataPoints(count: SAMPLES)

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 {
    
    var body: some View {
        GeometryReader { geo in
            ScrollView(.horizontal) {
                VStack {
                    chart(for: testVector1, with: geo.size.width)
                    chart(for: testVector2, with: geo.size.width)
                }
            }
        }
    }
    
    func chart(for data: [DataPoint], with width: CGFloat) -> some View {
        Chart(testVector2) {
            LineMark(
                x: .value("Time", $0.xValue),
                y: .value("Speed", $0.yValue)
            )
        }
        .frame(width: (width / 15)  * CGFloat(data.count))
        .scrollDisabled(true)
    }
}