fcollf
fcollf

Reputation: 171

SwiftUI Scrollable Charts in IOS16

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)
        }
    }
}

Upvotes: 5

Views: 3763

Answers (3)

Steve Roy
Steve Roy

Reputation: 131

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.

Upvotes: 1

SimeonRumy
SimeonRumy

Reputation: 403

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.

Upvotes: 1

JJJSchmidt
JJJSchmidt

Reputation: 880

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.

Upvotes: 6

Related Questions