SwiftUI Scrollable Charts in IOS16
Asked Answered
P

3

5

Using the new SwiftUI Charts framework, we can make a chart bigger than the visible screen and place it into a ScrollView to make it scrollable. Something like this:

var body : some View {
    
    GeometryReader { proxy in

        ScrollView(.horizontal, showsIndicators: false) {

            Chart {

                ForEach(data) { entry in

                    // ...
                }
            }
            .frame(width: proxy.size.width * 2)
        }
    }
}

Does anybody know if it is possible to programmatically move the scroll to display a certain area of the chart?

I've tried using ScrollViewReader, setting the IDs at the x-axis labels, and trying to use the scrollTo function to navigate to any of those positions with no luck:

Chart {

    /// ...
    
}
.chartXAxis {

    AxisMarks(values: .stride(by: .day)) { value in
    
        if let date : Date = value.as(Date.self) {
            Text(date, style: .date)
                .font(.footnote)
        }
    }
}
Plata answered 6/11, 2022 at 10:38 Comment(0)
R
6

This cheesy workaround seems to do the trick. I put the chart in a ZStack with an HStack overlaying the chart. The HStack contains a bunch of invisible objects that conform to the Identifiable protocol. The quantity, ids, and positions of the invisible objects match the charted data.

Since the ZStack view now contains identifiable elements, ScrollViewReader works as expected.

import SwiftUI
import Charts

struct ChartData: Identifiable {
    var day: Int
    var value: Int
    var id: String { "\(day)" }
}

struct ContentView: View {
    @State var chartData = [ChartData]()
    @State var scrollSpot = ""
    let items = 200
    let itemWidth: CGFloat = 30
    
    var body: some View {
        VStack {
            ScrollViewReader { scrollPosition in
                ScrollView(.horizontal) {
                    
                    // Create a ZStack with an HStack overlaying the chart.
                    // The HStack consists of invisible items that conform to the
                    // identifible protocol to provide positions for programmatic
                    // scrolling to the named location.
                    ZStack {
                        // Create an invisible rectangle for each x axis data point
                        // in the chart.
                        HStack(spacing: 0) {
                            ForEach(chartData) { item in
                                Rectangle()
                                    .fill(.clear)

                                    // Setting maxWidth to .infinity here, combined
                                    // with spacing:0 above, makes the rectangles
                                    // expand to fill the frame specified for the
                                    // chart below.
                                    .frame(maxWidth: .infinity, maxHeight: 0)

                                    // Here, set the rectangle's id to match the
                                    // charted data.
                                    .id(item.id)
                            }
                        }
                        
                        Chart(chartData) {
                            BarMark(x: .value("Day", $0.day),
                                    y: .value("Amount", $0.value),
                                    width: 20)
                        }
                        .frame(width: CGFloat(items) * itemWidth, height: 300)
                    }
                }
                .padding()
                .onChange(of: scrollSpot, perform: {x in
                    if (!x.isEmpty) {
                        scrollPosition.scrollTo(x)
                        scrollSpot = ""
                    }
                })
            }
            .onAppear(perform: populateChart)
        
            Button("Scroll") {
                if let x = chartData.last?.id {
                    print("Scrolling to item \(x)")
                    scrollSpot = x
                }
            }
            
            Spacer()
        }
    }

    func populateChart() {
        if !chartData.isEmpty { return }
        for i in 0..<items {
            chartData.append(ChartData(day: i, value: (i % 10) + 2))
        }
    }
}

IMHO this should work out of the SwiftUI box. Apple's comments for the initializer say it creates a chart composed of a series of identifiable marks. So... if the marks are identifiable, it is not a stretch to expect ScrollViewReader to work with the chart's marks.

But noooooo!

One would hope this is an oversight on Apple's part since the framework is new, and they will expose ids for chart marks in an upcoming release.

Rainout answered 23/12, 2022 at 1:11 Comment(5)
Nice workaround. Hopefully Apple will improve the framework in future releases!Plata
One downside to using a ScrollView with Swift Charts is the vertical axis labels are not visible if you are not positioned at the end of the chart. I worked around this gotcha by hiding the vertical axis labels and embedding the whole VStack in another HStack then manually creating the vertical axis labels within the HStack. Less than ideal since the vertical spacing of the labels is hand rolled and disconnected from the chart.Rainout
Another way to do it is with a ZStack embedding first an empty Chart that shows the vertical axis on the right (no horizontal axis) and then adding into the same ZStack the ScrollView in which you have the Chart with the actual data (no vertical axis in this one). You can use the same Scale for both charts to keep them in sync. It is not perfect and you have to struggle with the widths and heights using a GeometryReader but the final result is not that bad.Plata
Interesting approach, I wouldn't have thought of that. One thing's for sure, this functionality should be supported in the framework and all this fiddling should not be necessary.Rainout
It's been awhile since I could put some more time into this, and after reviewing the new scroll features now available, I found some performance issues when dealing when large sets of data so I decided to keep working on a custom solution like the one mentioned above using two overlapping Charts with a ZStack and a custom pager to simulate the scroll. I posted that into this repository just in case somebody wants to have a look: github.com/fcollf/MeasurementChartsUIPlata
E
1

My approach was not to use a scroll view, but rather filter my data as the user drags their finger across the screen.

Effectively, it is a sliding window problem. You have a window size and an offset. Furthermore, you can add animation/transition on the chart view itself to replicate the feeling of scroll.

The animation is not perfect perfect for me, but acceptable. Might work well or worse for you depending on your data and chart. Frequency of data points seems to be an important factor. Im still playing around with this and will update this answer once I optimise it.

I like this solution because it has no hacky stuff like multiple charts in a Zstack. All axis are always visible.

Expand answered 18/4, 2023 at 6:38 Comment(0)
W
1

In case someone is looking for the answer, it can be found in this other post.

The clue is that the ScrollView only contains one item, the Chart itself. So that's what you have to pass to scrollTo().

And with the anchor parameter, you can indicate to go the trailing end of the chart.

Walkon answered 18/5, 2023 at 17:46 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.