Add horizontal scroll to charts with SwiftUI Charts in iOS 16
Apple introduced SwiftUI Charts at WWWDC 2022, which makes it incredibly easy to create charts within SwiftUI views. The default chart presents the entire data in the chart plot regardless of how much data is available. It can be beneficial to show more details on a chart and add the ability to scroll through the chart.
Related articles on Swift Charts:
Create sample data
Define a data model that will generate random data for a property like body weight measured in pounds in a range between 175 and 200. The generation of the data gives minor changes each day to make the data more realistic.
1struct Weight: Identifiable {
2 let id = UUID()
3 let day: Date
4 let pounds: Int
5
6 init(date: Date, weight: Int) {
7 self.day = date
8 self.pounds = weight
9 }
10
11 var weekdayString: String {
12 let dateFormatter = DateFormatter()
13 dateFormatter.dateFormat = "yyyyMMdd"
14 dateFormatter.dateStyle = .long
15 dateFormatter.timeStyle = .none
16 dateFormatter.locale = Locale(identifier: "en_US")
17 return dateFormatter.string(from: day)
18 }
19}
1struct WeightData {
2 private(set) var allWeights: [Weight]?
3
4 static let weightInitial = 180
5 static let weightInterval = 2
6 static let weightMin = 175
7 static let weightMax = 200
8
9 init() {
10 createWeightData(days: 100)
11 }
12
13 private mutating func createWeightData(days: Int) {
14 // Generate sample weight date between 175 and 200 pounds (+ or - 0-3 pounds daily)
15 self.allWeights = []
16 var selectedWeight = WeightData.weightInitial
17 var add = true
18 for interval in 0...days {
19 switch selectedWeight {
20 case (WeightData.weightMax - WeightData.weightInterval + 1)..<Int.max:
21 add = false
22 case 0..<(WeightData.weightMin + WeightData.weightInterval):
23 add = true
24 default:
25 add = (Int.random(in: 0...4) == 3) ? !add : add
26 }
27
28 selectedWeight = add ? selectedWeight + Int.random(in: 0...WeightData.weightInterval) : selectedWeight - Int.random(in: 0...WeightData.weightInterval)
29 let selectedDate = Calendar.current.date(byAdding: .day, value: (-1 * interval), to: Date())!
30 self.allWeights!.append(Weight(date: selectedDate, weight: selectedWeight))
31 }
32 }
33}
Generate weight model data
Display weight data on Line Chart
Create a ViewModel to bridge between the Model and the View as discussed in MVVM in
SwiftUI. A function is added to WeightViewModel
to reinitialise the model with a
different number of days. Buttons are added to the view to change the number of days
so the graph can be changed to display the data. It can be seen that the scales on
the chart adjust automatically to display all the data. This is a great feature of
Swift Charts and it allows charts to be created quickly that adjust to the
available space on screen. However, when there is a large number of data samples, the
chart can appear a bit squashed and make it difficult to see the details.
Model
1struct WeightData {
2 private(set) var allWeights: [Weight]?
3
4 static let weightInitial = 180
5 static let weightInterval = 2
6 static let weightMin = 175
7 static let weightMax = 200
8
9 mutating func createWeightData(days: Int) {
10 // Generate sample weight date between 175 and 200 pounds (+ or - 0-3 pounds daily)
11 self.allWeights = []
12 var selectedWeight = WeightData.weightInitial
13 var add = true
14 for interval in 0...days {
15 switch selectedWeight {
16 case (WeightData.weightMax - WeightData.weightInterval + 1)..<Int.max:
17 add = false
18 case 0..<(WeightData.weightMin + WeightData.weightInterval):
19 add = true
20 default:
21 add = (Int.random(in: 0...4) == 3) ? !add : add
22 }
23
24 selectedWeight = add ? selectedWeight + Int.random(in: 0...WeightData.weightInterval) : selectedWeight - Int.random(in: 0...WeightData.weightInterval)
25 let selectedDate = Calendar.current.date(byAdding: .day, value: (-1 * interval), to: Date())!
26 self.allWeights!.append(Weight(date: selectedDate, weight: selectedWeight))
27 }
28 }
29}
ViewModel
1class WeightViewModel: ObservableObject {
2 @Published private var weightModel = WeightData()
3
4 init() {
5 weightModel.createWeightData(days: 100)
6 }
7
8 var allWeights: [Weight]? {
9 weightModel.allWeights
10 }
11
12 func generateWeightData(numberOfDays: Int) {
13 weightModel.createWeightData(days: numberOfDays)
14 }
15}
View
1struct WeightChartView1: View {
2 @ObservedObject private var weightVm = WeightViewModel()
3
4 var body: some View {
5 ZStack {
6 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
7 .edgesIgnoringSafeArea(.all)
8
9 VStack() {
10 GroupBox ("Daily weight (pounds)") {
11 if let weights = weightVm.allWeights {
12 Chart {
13 ForEach(weights) { weight in
14 LineMark(
15 x: .value("Week Day", weight.day),
16 y: .value("Step Count", weight.pounds)
17 )
18 .foregroundStyle(ViewConstants.color1)
19 .accessibilityLabel("\(weight.day.toString())")
20 .accessibilityValue("\(weight.pounds) pounds")
21 }
22 }
23 .chartYScale(domain: ViewConstants.minYScale...ViewConstants.maxYScale)
24 .chartYAxis() {
25 AxisMarks(position: .leading)
26 }
27 }
28 }
29 .groupBoxStyle(YellowGroupBoxStyle())
30 .frame(width: ViewConstants.chartWidth, height: ViewConstants.chartHeight)
31
32 Text("Generate Data")
33 .font(.title2)
34 HStack {
35 Button("10") {
36 weightVm.generateWeightData(numberOfDays: 10)
37 }
38 Button("50") {
39 weightVm.generateWeightData(numberOfDays: 50)
40 }
41 Button("100") {
42 weightVm.generateWeightData(numberOfDays: 100)
43 }
44 Button("1000") {
45 weightVm.generateWeightData(numberOfDays: 1000)
46 }
47 }
48
49 Spacer()
50 }
51 .padding()
52 }
53 }
54
55 private struct ViewConstants {
56 static let color1 = Color(hue: 0.33, saturation: 0.81, brightness: 0.76)
57 static let minYScale = 150
58 static let maxYScale = 240
59 static let chartWidth: CGFloat = 350
60 static let chartHeight: CGFloat = 400
61 }
62}
Line chart with 1,000 data points
Line chart with 10, 50, 100 and 1,000 data points
Displaying weight data in a Bar Chart
It is relatively to change the line chart into a bar chart with the use of BarMark
for each data point. It does not make sense to cut down the y-axis scale with a bar
chart so the default y-axis scale is used. The bar chart looks much worse than the
line chart when the individual bars cannot be seen.
1struct WeightChartView2: View {
2 @ObservedObject private var weightVm = WeightViewModel()
3
4 var body: some View {
5 ZStack {
6 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
7 .edgesIgnoringSafeArea(.all)
8
9 VStack() {
10 GroupBox ("Daily weight (pounds)") {
11 if let weights = weightVm.allWeights {
12 Chart {
13 ForEach(weights) { weight in
14 BarMark(
15 x: .value("Week Day", weight.day),
16 y: .value("Step Count", weight.pounds)
17 )
18 .foregroundStyle(ViewConstants.color1)
19 .accessibilityLabel("\(weight.day.toString())")
20 .accessibilityValue("\(weight.pounds) pounds")
21 }
22 }
23 .chartYAxis() {
24 AxisMarks(position: .leading)
25 }
26 }
27 }
28 .groupBoxStyle(YellowGroupBoxStyle())
29 .frame(width: ViewConstants.chartWidth, height: ViewConstants.chartHeight)
30
31 Text("Generate Data")
32 .font(.title2)
33 HStack {
34 Button("10") {
35 weightVm.generateWeightData(numberOfDays: 10)
36 }
37 Button("50") {
38 weightVm.generateWeightData(numberOfDays: 50)
39 }
40 Button("100") {
41 weightVm.generateWeightData(numberOfDays: 100)
42 }
43 Button("1000") {
44 weightVm.generateWeightData(numberOfDays: 1000)
45 }
46 }
47
48 Spacer()
49 }
50 .padding()
51 }
52 }
53
54 private struct ViewConstants {
55 static let color1 = Color(hue: 0.33, saturation: 0.81, brightness: 0.76)
56 static let minYScale = 150
57 static let maxYScale = 240
58 static let chartWidth: CGFloat = 350
59 static let chartHeight: CGFloat = 400
60 }
61}
Bar chart with 1,000 data points
Bar chart with 10, 50, 100 and 1,000 data points
Add scroll to line chart
The solution is relatively straight forward, simply wrap the chart in a ScrollView and set the axis to horizontal. It is also necessary to set the desired width of the chart. This is set to a width for each point multiplied by the number of points in the data, so that a chart with 100 data points will be twice as wide as a chart with 50 data points. The second element that needs to be configured is the X-axis labels and gridlines, these are set using AxisMarks on chartXAxis so that name of the month is displayed for the first of each month. Some extensions are added to the Date type to easily check if a date is the first of a month and to get name of the month.
1struct WeightChartView3: View {
2 @ObservedObject private var weightVm = WeightViewModel()
3
4 var body: some View {
5 ZStack {
6 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
7 .edgesIgnoringSafeArea(.all)
8
9 VStack() {
10 GroupBox ("Daily weight (pounds)") {
11 if let weights = weightVm.allWeights {
12 ScrollView(.horizontal) {
13 Chart {
14 ForEach(weights) { weight in
15 LineMark(
16 x: .value("Week Day", weight.day),
17 y: .value("Step Count", weight.pounds)
18 )
19 .foregroundStyle(ViewConstants.color1)
20 .accessibilityLabel("\(weight.day.toString())")
21 .accessibilityValue("\(weight.pounds) pounds")
22 }
23 }
24 .chartYScale(domain: ViewConstants.minYScale...ViewConstants.maxYScale)
25 .chartYAxis() {
26 AxisMarks(position: .leading)
27 }
28 .chartXAxis {
29 AxisMarks(preset: .extended,
30 position: .bottom,
31 values: .stride (by: .day)) { value in
32 if value.as(Date.self)!.isFirstOfMonth() {
33 AxisGridLine()
34 .foregroundStyle(.black.opacity(0.5))
35 let label = "01\n\(value.as(Date.self)!.monthName())"
36 AxisValueLabel(label).foregroundStyle(.black)
37 } else {
38 AxisValueLabel(
39 format: .dateTime.day(.twoDigits)
40 )
41 }
42 }
43 }
44 .frame(width: ViewConstants.dataPointWidth * CGFloat(weights.count))
45 }
46 }
47 }
48 .groupBoxStyle(YellowGroupBoxStyle())
49 .frame(width: ViewConstants.chartWidth, height: ViewConstants.chartHeight)
50
51 Text("Generate Data")
52 .font(.title2)
53 HStack {
54 Button("10") {
55 weightVm.generateWeightData(numberOfDays: 10)
56 }
57 Button("50") {
58 weightVm.generateWeightData(numberOfDays: 50)
59 }
60 Button("100") {
61 weightVm.generateWeightData(numberOfDays: 100)
62 }
63 Button("1000") {
64 weightVm.generateWeightData(numberOfDays: 1000)
65 }
66 }
67
68 Spacer()
69 }
70 .padding()
71 }
72 }
73
74 private struct ViewConstants {
75 static let color1 = Color(hue: 0.33, saturation: 0.81, brightness: 0.76)
76 static let minYScale = 150
77 static let maxYScale = 240
78 static let chartWidth: CGFloat = 350
79 static let chartHeight: CGFloat = 400
80 static let dataPointWidth: CGFloat = 20
81 }
82}
Extensions on date
1extension Date {
2 func toString() -> String {
3 let dateFormatter = DateFormatter()
4 dateFormatter.dateStyle = .long
5 dateFormatter.timeStyle = .none
6 dateFormatter.locale = Locale(identifier: "en_US")
7 return dateFormatter.string(from: self)
8 }
9
10 func isFirstOfMonth() -> Bool {
11 let components = Calendar.current.dateComponents([.day], from: self)
12 return components.day == 1
13 }
14
15 func monthName() -> String {
16 let dateFormatter = DateFormatter()
17 dateFormatter.dateFormat = "MMMM"
18 dateFormatter.locale = Locale(identifier: "en_US")
19 return dateFormatter.string(from: self)
20 }
21}
Line chart with horizontal scrolling to display large number of data points
Line chart with horizontal scrolling to display large number of data points
Add scroll to bar chart
Adding horizontal scrolling to the bar chart is similar as the line chart. The width of each bar can ve set as the width for each data point. The value for each bar is displayed as an annotation.
1struct WeightChartView4: View {
2 @ObservedObject private var weightVm = WeightViewModel()
3
4 var body: some View {
5 ZStack {
6 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
7 .edgesIgnoringSafeArea(.all)
8
9 VStack() {
10 GroupBox ("Daily weight (pounds)") {
11 if let weights = weightVm.allWeights {
12 ScrollView(.horizontal) {
13 Chart {
14 ForEach(weights) { weight in
15 BarMark(
16 x: .value("Week Day", weight.day),
17 y: .value("Step Count", weight.pounds),
18 width: ViewConstants.barWidth
19 )
20 .annotation(position: .top) {
21 Text("\(weight.pounds)").font(.footnote)
22 }
23 .foregroundStyle(ViewConstants.color1)
24 .accessibilityLabel("\(weight.day.toString())")
25 .accessibilityValue("\(weight.pounds) pounds")
26 }
27 }
28 .chartYScale(domain: 0...ViewConstants.maxYScale)
29 .chartYAxis() {
30 AxisMarks(position: .leading)
31 }
32 .chartXAxis {
33 AxisMarks(preset: .extended,
34 position: .bottom,
35 values: .stride (by: .day)) { value in
36 if value.as(Date.self)!.isFirstOfMonth() {
37 AxisGridLine()
38 .foregroundStyle(.black.opacity(0.5))
39 let label = "01\n\(value.as(Date.self)!.monthName())"
40 AxisValueLabel(label).foregroundStyle(.black)
41 } else {
42 AxisValueLabel(
43 format: .dateTime.day(.twoDigits)
44 )
45 }
46 }
47 }
48 .frame(width: ViewConstants.dataPointWidth * CGFloat(weights.count))
49 }
50 }
51 }
52 .groupBoxStyle(YellowGroupBoxStyle())
53 .frame(width: ViewConstants.chartWidth, height: ViewConstants.chartHeight)
54
55 Text("Generate Data")
56 .font(.title2)
57 HStack {
58 Button("10") {
59 weightVm.generateWeightData(numberOfDays: 10)
60 }
61 Button("50") {
62 weightVm.generateWeightData(numberOfDays: 50)
63 }
64 Button("100") {
65 weightVm.generateWeightData(numberOfDays: 100)
66 }
67 Button("1000") {
68 weightVm.generateWeightData(numberOfDays: 1000)
69 }
70 }
71 Spacer()
72 }
73 .padding()
74 }
75 }
76
77 private struct ViewConstants {
78 static let color1 = Color(hue: 0.33, saturation: 0.81, brightness: 0.76)
79 static let minYScale = 150
80 static let maxYScale = 240
81 static let chartWidth: CGFloat = 350
82 static let chartHeight: CGFloat = 400
83 static let dataPointWidth: CGFloat = 30
84 static let barWidth: MarkDimension = 22
85 }
86}
Bar chart with horizontal scrolling to display large number of data points
Bar chart with horizontal scrolling to display large number of data points
Conclusion
The default behavior for Swift Charts is to display all the data points in the plot area of the chart. This can result in larger volumes of data being squashed and the chart difficult to interpret. One solution is to wrap the chart in a ScrollView, which requires setting the width of the chart based on the number of sample points to plot. One possible issue with the above code is that the entire chart scrolls, with the result that the y-axis scrolls off screen. It might be better to scroll the plot data but keep the y-axis visible so the scale remains visible.
The code for ScrollingChartsApp is available on GitHub.