Swift Charts: Changing chartXVisibleDomain changes chartScrollPosition

I'm building a Swift Chart that displays locations in a horizontal timeline. The chart is scrollable.

Unfortunately, when chartScrollPosition is offset by some amount (i.e. the user has scrolled the chart), changing chartXVisibleDomain results in chartScrollPosition jumping backwards by some amount.

This results in bad user experience.

A minimum reproducible example is below. To reproduce:

  1. Run the code below
  2. Using the picker, change chartXVisibleDomain. ThechartScrollPosition remains the same, as expected.
  3. Scroll the chart on the horizontal axis.
  4. Using the picker, change chartXVisibleDomain. ThechartScrollPosition changes unexpectedly.

You can verify this by watching the labels at the bottom of the chart. The chart simply ends up showing a different area of the domain. Tested on various iPhone and iPad simulators and physical devices, the issue appears everywhere.

Is this a SwiftUI bug, or am I doing something wrong?

struct ScrollableChartBug: View {

  /// Sample data
  let data = SampleData.samples
  let startDate = SampleData.samples.first?.startDate ?? Date()
  let endDate = Date()
  /// Scroll position of the chart, expressed as Date along the x-axis.
  @State var chartPosition: Date = SampleData.samples.first?.startDate ?? Date()
  /// Sets the granularity of the shown view.
  @State var visibleDomain: VisibleDomain = .year

  var body: some View {

    Chart(data, id: \.id) { element in
      BarMark(xStart: .value("Start", element.startDate),
              xEnd: .value("End", element.endDate),
              yStart: 0,
              yEnd: 50)
      .foregroundStyle(by: .value("Type", element.type.rawValue))
      .clipShape(.rect(cornerRadius: 8, style: .continuous))
    }
    .chartScrollableAxes(.horizontal) // enable scroll
    .chartScrollPosition(x: $chartPosition) // track scroll offset
    .chartXVisibleDomain(length: visibleDomain.seconds)
    .chartXScale(domain: startDate...endDate)
    .chartForegroundStyleScale { typeName in
      // custom colors for bars and for legend
      SampleDataType(rawValue: typeName)?.color ?? .clear
    }
    .chartXAxis {
      AxisMarks(values: .stride(by: .month, count: 1)) { value in
        if let date = value.as(Date.self) {
          AxisValueLabel {
            Text(date, format: .dateTime.year().month().day())
              .bold()
          }
          AxisTick(length: .label)
        }
      }
    }
    .frame(height: 90)
    .padding(.bottom, 40) // for overlay picker
    .overlay {
      Picker("", selection: $visibleDomain.animation()) {
        ForEach(VisibleDomain.allCases) { variant in
          Text(variant.label)
            .tag(variant)
        }
      }
      .pickerStyle(.segmented)
      .frame(width: 240)
      .padding(.trailing)
      .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
    } //: overlay
  } //: body
} //: struct

// MARK: - Preview
#Preview {
  ScrollableChartBug()
}

// MARK: - Data
enum SampleDataType: String, CaseIterable {
  case city, wood, field

  var color: Color {
    switch self {
    case .city:
        .gray
    case .wood:
        .green
    case .field:
        .brown
    }
  }

  var label: String {
    switch self {
    case .city:
      "City"
    case .wood:
      "Wood"
    case .field:
      "Field"
    }
  }
}

enum VisibleDomain: Identifiable, CaseIterable {

  case day
  case week
  case month
  case year

  var id: Int {
    self.seconds
  }

  var seconds: Int {
    switch self {
    case .day:
      3600 * 24 * 2
    case .week:
      3600 * 24 * 10
    case .month:
      3600 * 24 * 40
    case .year:
      3600 * 24 * 400
    }
  }

  var label: String {
    switch self {
    case .day:
      "Days"
    case .week:
      "Weeks"
    case .month:
      "Months"
    case .year:
      "Years"
    }
  }
}

struct SampleData: Identifiable {
  let startDate: Date
  let endDate: Date
  let name: String
  let type: SampleDataType
  var id: String { name }

  static let samples: [SampleData] = [
    .init(startDate: Date.from(year: 2022, month: 3, day: 1),
          endDate: Date.from(year: 2022, month: 3, day: 10),
          name: "New York",
          type: .city),
    .init(startDate: Date.from(year: 2022, month: 3, day: 20, hour: 6),
          endDate: Date.from(year: 2022, month: 5, day: 1),
          name: "London",
          type: .city),
    .init(startDate: Date.from(year: 2022, month: 5, day: 4),
          endDate: Date.from(year: 2022, month: 7, day: 5),
          name: "Backcountry ABC",
          type: .field),
    .init(startDate: Date.from(year: 2022, month: 7, day: 5),
          endDate: Date.from(year: 2022, month: 10, day: 10),
          name: "Field DEF",
          type: .field),
    .init(startDate: Date.from(year: 2022, month: 10, day: 10),
          endDate: Date.from(year: 2023, month: 2, day: 10),
          name: "Wood 123",
          type: .wood),
    .init(startDate: Date.from(year: 2023, month: 2, day: 10),
          endDate: Date.from(year: 2023, month: 3, day: 20),
          name: "Paris",
          type: .city),
    .init(startDate: Date.from(year: 2023, month: 3, day: 21),
          endDate: Date.from(year: 2023, month: 10, day: 5),
          name: "Field GHI",
          type: .field),
    .init(startDate: Date.from(year: 2023, month: 10, day: 5),
          endDate: Date.from(year: 2024, month: 3, day: 5),
          name: "Wood 456",
          type: .wood),
    .init(startDate: Date.from(year: 2024, month: 3, day: 6),
          endDate: Date(),
          name: "Field JKL",
          type: .field)
  ]
}

extension Date {
  /**
   Constructs a Date from a given year (Int). Use like `Date.from(year: 2020)`.
   */
  static func from(year: Int? = nil, month: Int? = nil, day: Int? = nil, hour: Int? = nil, minute: Int? = nil) -> Date {
    let components = DateComponents(year: year, month: month, day: day, hour: hour, minute: minute)
    guard let date = Calendar.current.date(from: components) else {
      print(#function, "Failed to construct date. Returning current date.")
      return Date()
    }
    return date
  }
}

Feedback submitted at FB14091989.

I have this problem too

Thank you for the report, and for submitting and linking your feedback.

While the chartScrollPosition value doesn't change, it appears to do so, as the chart jumps.

This unsatisfactory workaround unfortunately doesn't work out for animated domain changes, see it inside this minimal reproducer:

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
struct VisibleDomainUpdate: View {
    @State var scrollPosition = 0.0
    @State var visibleDomain = 1.0

    var body: some View {
        VStack {
            Chart(0 ... 100, id: \Int.self) {
                RuleMark(x: .value("X", $0))
            }
            .chartScrollableAxes(.horizontal)
            .chartScrollPosition(x: $scrollPosition)
            .chartXVisibleDomain(length: visibleDomain)
            .onChange(of: visibleDomain) {
                let exactScrollPosition = scrollPosition
                scrollPosition += 0.0001
                Task(priority: .high) {
                    scrollPosition = exactScrollPosition
                }
            }

            Text("chartScrollPosition: \(scrollPosition)")
            HStack {
                Button("Set to 0") { scrollPosition = 0 }
                Button("Set to 1") { scrollPosition = 1 }
                Button("Set to 4") { scrollPosition = 4 }
                Button("Set to 10") { scrollPosition = 10 }
                Button("Set to 40") { scrollPosition = 40 }
            }
            Picker("Visible domain length", selection: $visibleDomain/*.animation()*/) {
                Text("1").tag(1.0)
                Text("10").tag(10.0)
            }.pickerStyle(.segmented)
        }
        .frame(width: 400, height: 120)
    }
}
Swift Charts: Changing chartXVisibleDomain changes chartScrollPosition
 
 
Q