SwiftUI Charts, very large data sets, and chart overlay

When I drag my finger across the chart here the breakpoint on line 23 will be triggered. Even if I extract the Text(...) and selectedIndex out to a separate view. This causes major ui animation hitches with large data sets. Am I doing something wrong or is there no way to create a chart overlay without redrawing the entire chart for each new selectedIndex?

Just move your Char into a subview, and its body won't be called on selectedIndex change.

Text("\(selectedIndex)")
ChartView()
    .chartOverlay {
        ...
        selectedIndex = ...
    }

Please note that this forum is laser focused on topics related to Swift programming language itself. SwiftUI questions are better discussed elsewhere (stackoverflow, etc).

Thank you for your reply! I have tried that and still the same, the chart always redraws. I can't escape it.

Ok, I will move this to stack overflow. I really think this is a bug with the swiftui charts framework from apple.

The snippet I showed does it slightly differently: with chartOverlay being on the outside, and no selectedIndex (or a binding to it) being passed to ChartView. Try that.

struct ChartView: View {
    var body: some View {
        print("SubView body")
        return Text(verbatim: { print("SubView text");  return "Hello" }())
    }
}

struct TheView: View {
    @State private var selectedIndex = 0
    
    var body: some View {
        print("TheView body")
        return VStack {
            Text(String(selectedIndex))
            ChartView()
                .onTapGesture { // chartOverlay + gesture here instead
                    selectedIndex += 1
                }
        }
    }
}

I see.. tried this and chart still redraws:

I also tried extracting selectedIndex into an observable object. same thing...

//
//  ContentView.swift
//  InteractionTestChart
//
//  Created by nlegorr1 on 5/12/23.
//

import SwiftUI
import Charts

struct MyChart: View {
    @State private var numbers = (0...10).map { _ in
        Int.random(in: 0...10)
    }

    var body: some View {
        Chart {
            ForEach(Array(zip(numbers, numbers.indices)), id: \.0) { number, index in
                LineMark(
                    x: .value("Index", index),
                    y: .value("Value", number)
                )
            }
        }

    }
}

struct ContentView: View {
    @State private var selectedIndex: Int? = nil

    var body: some View {
        ZStack {
            Text("\(selectedIndex?.description ?? "")")

            MyChart()
                .chartOverlay { chart in
                            GeometryReader { geometry in
                                Rectangle()
                                    .fill(Color.clear)
                                    .contentShape(Rectangle())
                                    .gesture(
                                        DragGesture()
                                            .onChanged { value in
                                                let currentX = value.location.x - geometry[chart.plotAreaFrame].origin.x
                                                guard currentX >= 0, currentX < chart.plotAreaSize.width else {
                                                    return
                                                }

                                                guard let index = chart.value(atX: currentX, as: Int.self) else {
                                                    return
                                                }
                                                selectedIndex = index
                                            }
                                            .onEnded { _ in
                                                selectedIndex = nil
                                            }
                                    )
                            }
                        }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

This is the problem part. Try this instead:

class MyChartModel: ObservableObject {
    @Published var numbers = (0...10).map { _ in
        Int.random(in: 0...10)
    }
}

struct MyChart: View {
    @StateObject private var model = MyChartModel()
    
    var body: some View {
        Chart {
            ...
            // as before just using model.numbers instead of numbers
        }
    }
}

interesting, that worked! Why is that?

Also I've got one more question for ya then...

Now try:

//
//  ContentView.swift
//  InteractionTestChart
//
//  Created by nlegorr1 on 5/12/23.
//

import SwiftUI
import Charts

struct MyChart: View {
    @StateObject private var model = MyChartModel()
    
    var body: some View {
        Chart {
            ForEach(Array(zip(model.numbers, model.numbers.indices)), id: \.0) { number, index in
                LineMark(
                    x: .value("Index", index),
                    y: .value("Value", number)
                )
            }
        }
    }
}

class MyChartModel: ObservableObject {
    @Published var numbers = (0...10).map { _ in
        Int.random(in: 0...10)
    }
}

struct ContentView: View {
    //    @State private var selectedIndex: Int? = nil
    @State private var isPresented = false
    var body: some View {
        ZStack {
            Button("Show Chart") {
                self.isPresented = true
            }
            //            Text("\(selectedIndex?.description ?? "")")
        }
        .sheet(isPresented: $isPresented) {
            MyChart()
            .chartForegroundStyleScale(["Test": Color.red])
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

open the chart. set a breakpoint on LineMark, dismiss the sheet but swiping down. Break point gets triggered?

I have a stack overflow here: Why does SwiftUI ForEach<>._layoutChartContent get called here? - Stack Overflow

SwiftUI and Charts body properties are reevaluated very frequently, whenever view state properties are updated, and the framework has some logic to determine which views actually need to be redrawn on the screen. But the key point is that this means that your body property needs to be very fast to get the best performance.

When you do something like this:

ForEach(Array(zip(model.numbers, model.numbers.indices)), ...) {
}

You're not just iterating over the elements of that collection, but every time the view is reevaluated, you're zipping the elements and indices (fast, this just returns a wrapper sequence) but then also allocating a new Array and copying all the zipped elements into it (very slow).

This is why @tera's example works; the creation of the (index, element) array only happens once, when the MyChartModel is created. Any time the body is evaluated, it's just iterating over the same array instead of creating a new copy.

State and StateObject differ in the way they are getting initialised.

State:

public init(wrappedValue value: Value)

StateObject:

@inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)

If you make the View initialiser explicit (and View.init is being called very frequently) while the two would look very similar:

State: _numbers = State(wrappedValue: XXX)
StateObject: _numbers = StateObject(wrappedValue: XXX)

the behaviour of the two is quite different in that in case of StateObject XXX is being evaluated just once whilst in case of State it is being evaluated every time.

The difference is not obvious to see (†): even if you write those init's explicitly (they'd look similar) and even harder if you don't have those inits explicit.

BTW, instead of zip you can use a simpler looking:

    ForEach(Array(model.numbers.enumerated()), id: \.offset) { index, number in
        ...
    }

It won't change the behaviour you are talking about (that Chart's body is called when you start interactive dismissal):

Why that happens is harder to pinpoint, the short answer: Chart.body is being called (for whatever reasons framework decided to call it as part of interactive dismissal, perhaps something to do with animation). We'd need the equivalent of Self._printChanges() for the ChartContent to know more, but more than likely it would reveal something cryptic and not particularly useful.


That it is not obvious there's a hidden magic in the line:

_numbers = StateObject(wrappedValue: XXX())

violates the principle of least surprise. I'd prefer to see some explicit marker on the use site, similar to how we denote inout variable usage with & at the call site to make it obvious what's going to happen:

foo(&x) vs foo(x)

Possible "solutions" (if we agree there's a problem):

  • adhoc naming convention: StateObject(wrappedValue_autoclosure: XXX())

  • style guideline: denote autoclosure to be a bad style (with Bool && and || being well known exceptions).

  • introduce some explicit marker on the use site to denote autoclosure parameters. Promote the marker usage and at some future point start requiring it. Bike shedding:

    _numbers = StateObject(wrappedValue: autoclosure XXX())

While I agree to that, I'd point out that currently (†) SwiftUI developer must try their best to minimise the number of times View's body is getting called: every time body is called it's a CPU and battery impact not just because of the body call itself but because of the subsequent steps framework has to do (compare the new view hierarchy to the old one to ensure there's a change (which ironically takes longest if the two views are indeed equal)). I'd even like to have some debugging facility to warn me (in console or a debug assert) when I'm returning the same body, that'd be a good indication the body call was unnecessary and should have been prevented.

† hopefully the new observation machinery will make this task easier.

3 Likes