Search code examples
iosswiftuiswiftui-animationswiftui-charts

How can I animate programmatically scrolling to a specific x point in SwiftUI Charts


Hey I'm using the new SwiftUI Charts to display a graph to display a line graph for a user's weight. The issue that I am running into specifically is trying to animate the scroll position of the chart. Below is a snippet of the code that I've tried to achieve this.

@State var scrollPosition: Date

var body: some View {
  Button {
    withAnimation {
      // update scrollPosition property to some date
      var current = scrollPosition
      current.addTimeInterval(-86400)
      scrollPosition = current
    }
  } label: {
    Text("scroll to another date")
  }
  Chart {
    Foreach(data) { dataPoint in
      LineMark(x: .value("Day", dataPoint.data), y: .value("Value", dataPoint.value))
    }
  }
    .chartScrollableAxes(.horizontal)
    .chartScrollPosition(x: $scrollPosition)
}

So when I tap on the button and update the scrollPosition variable with a new date, the chart essentially refreshes and updates to the correct location on the x axis, however, there is no animation triggered. I was hoping that wrapping the scrollPosition within the animation block would achieve the animation that I was hoping for but no such luck.

Is this the correct way to programmatically scroll to a position in the chart? I've looked at other solutions where (iOS 16 and under) where they wrapped the Chart view behind a scrollView and use the scrollView's function to scroll but then that introduces a host of other problems (such as losing the y Axis labels when scrolling) so I'd prefer to not do that.

So yeah is there any way to programmatically animate the scroll position to make it look smooth? Also is it possible to disable user's touch on the Chart? I'd prefer to have buttons to display a set date range rather than let users scroll themselves. Thanks


Solution

  • I don't think chartScrollPosition supports animations yet.

    You can give the scroll position a .constant binding, and animate the constant using an Animatable conformance.

    struct ScrollModifier: ViewModifier, Animatable {
        var scrollPosition: Date
        
        var animatableData: Double {
            // here I convert between Double and Date, because Date doesn't conform to VectorArithmetic
            // if the x axis values already conform to VectorArithmetic, you don't need to do any conversion
            get { scrollPosition.timeIntervalSince1970 }
            set { scrollPosition = Date(timeIntervalSince1970: newValue) }
        }
        
        func body(content: Content) -> some View {
            content.chartScrollPosition(x: .constant(scrollPosition))
        }
    }
    

    Because you are using a .constant binding, you will not be able to read what x value the user has scrolled to, but in your case this is fine, since you don't want the user to be able to scroll the chart at all. You can disable scrolling with .scrollDisabled(true).

    Here is an example using that:

    struct ChartData: Identifiable, Hashable {
        let date: Date
        let y: Double
        
        var id: Date { date }
    }
    
    // random data for the next 100 days
    let data: [ChartData] = (0..<100).map { i in
        ChartData(date: .now.addingTimeInterval(Double(i) * 86400), y: .random(in: 0..<1))
    }
    
    struct ContentView: View {
        
        @State private var scrollPosition = Date.now
    
        var body: some View {
            Text(scrollPosition, format: .dateTime.year().month().day())
            Button("Scroll to random element") {
                withAnimation {
                    scrollPosition = data.randomElement()!.date
                }
            }
            
            Chart(data) { datum in
                LineMark(x: .value("X", datum.date, unit: .day), y: .value("Y", datum.y))
            }
            .chartXVisibleDomain(length: 86400 * 7)  // display 7 days at a time
            .chartScrollableAxes(.horizontal)
            .modifier(ScrollModifier(scrollPosition: scrollPosition))
            .scrollDisabled(true)
        }
    }