Display top section of Bar Charts with Swift Charts
There are times when data in a bar chart has similar values for a number of the bars and it can be difficult to see the differences. One option is to zoom in on the top section of the bar chart. This article investigates a number of options and identifies the best approach for this in Swift Charts.
Related articles on Swift Charts:
Display a bar chart
SwiftUI Charts, introduced in iOS 16, makes it easy to create charts such as the following bar chart. Define an array of data and pass it to a Chart, creating a BarMark for each element in the array.
1struct Data: Identifiable {
2 var name: String
3 var value: Int
4 var id = UUID()
5}
6
7let chartData: [Data] = [.init(name: "Square", value: 45),
8 .init(name: "Circle", value: 42),
9 .init(name: "Triangle", value: 46),
10 .init(name: "Hexagon", value: 43)]
1struct BarView1: View {
2 var mean: Double {
3 return Double(chartData.reduce(0) { $0 + $1.value }) / Double(chartData.count)
4 }
5
6 var body: some View {
7 ZStack {
8 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
9 .edgesIgnoringSafeArea(.all)
10
11 VStack {
12 GroupBox("Results - full bars displayed") {
13 Chart(chartData) { values in
14 BarMark(
15 x: .value("name", values.name),
16 y: .value("value", values.value)
17 )
18 .foregroundStyle(Colors.barColor)
19 .cornerRadius(10.0)
20 .annotation(position: .overlay, alignment: .top, spacing: 15.0) {
21 Text("\(values.value)")
22 .font(.caption2)
23 .foregroundColor(.white)
24 .fontWeight(.bold)
25 .padding(2)
26 }
27
28 RuleMark(y: .value("mean", mean))
29 .foregroundStyle(Colors.lineColor)
30 .annotation(position: .overlay,
31 alignment: .bottomTrailing,
32 spacing: 20) {
33 Text("mean = \(String(format: "%.1f", mean))")
34 .foregroundColor(Colors.lineColor)
35 }
36 }
37 .chartYAxis {
38 AxisMarks(position: .leading) { _ in
39 AxisValueLabel()
40 }
41 }
42 .chartXAxis {
43 AxisMarks(position: .bottom) { _ in
44 AxisGridLine()
45 AxisValueLabel()
46 }
47 }
48 .padding()
49 }
50 .frame(height: 400)
51 Spacer()
52 }
53 }
54 }
55}
1struct Colors {
2 static let barColor = Color(hue: 0.8, saturation: 0.7, brightness: 0.5)
3 static let lineColor = Color(hue: 0.4, saturation: 0.5, brightness: 0.5)
4 static let bgPlotColor = Color(hue: 0.12, saturation: 0.10, brightness: 0.92)
5
6 static let bgGradient = LinearGradient(
7 gradient: Gradient(colors: [
8 Color(hue: 0.10, saturation: 0.10, brightness: 1.0),
9 Color(hue: 0.10, saturation: 0.20, brightness: 0.95)
10 ]),
11 startPoint: .topLeading,
12 endPoint: .bottomTrailing)
13}
Starting bar chart with 4 bars of data close together in SwiftUI
Zoom in on top section of bar chart
It can be difficult to see the differences between the bars when all the values are similar. One way to improve this is with the use of annotation to add the values to the bars. Another option is to zoom the y-axis to just show the top section of the bars, which can be achieved using chartYScale. The bar chart is updated to set the Domain for the y-axis to a range from 40 to 48.
The results are surprising, The plot area is correctly set for the y-axis from 40 to 48, however the bars in the bar chart extend below the plot area and below the chart. These bars are displayed over any content that may be placed below the chart.
1struct BarView2: View {
2 var mean: Double {
3 return Double(chartData.reduce(0) { $0 + $1.value }) / Double(chartData.count)
4 }
5
6 var body: some View {
7 ZStack {
8 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
9 .edgesIgnoringSafeArea(.all)
10
11 VStack {
12 GroupBox("Results - top bars - extend below chart") {
13 Chart(chartData) { values in
14 BarMark(
15 x: .value("name", values.name),
16 y: .value("value", values.value)
17 )
18 .foregroundStyle(Colors.barColor)
19 .cornerRadius(10.0)
20 .annotation(position: .overlay, alignment: .top, spacing: 15.0) {
21 Text("\(values.value)")
22 .font(.caption2)
23 .foregroundColor(.white)
24 .fontWeight(.bold)
25 .padding(2)
26 }
27
28 RuleMark(y: .value("mean", mean))
29 .foregroundStyle(Colors.lineColor)
30 .annotation(position: .overlay,
31 alignment: .bottomTrailing,
32 spacing: 20) {
33 Text("mean = \(String(format: "%.1f", mean))")
34 .foregroundColor(Colors.lineColor)
35 }
36 }
37 .chartYScale(domain: 40...48)
38 .chartYAxis {
39 AxisMarks(position: .leading) { _ in
40 AxisValueLabel()
41 }
42 }
43 .chartXAxis {
44 AxisMarks(position: .bottom) { _ in
45 AxisGridLine()
46 AxisValueLabel()
47 }
48 }
49 .padding()
50 }
51 .frame(height: 400)
52 Spacer()
53 }
54 }
55 }
56}
Display top bars using y-scale results in bars extending below the chart
Use Group Box Style
A lot of iOS apps use card-type views to separate sections in an App. The use of
groupboxes is good for layout and groupBoxStyle
can be used to set consistent
styles for various sections of an app. It is surprising that applying a
groupBoxStyle
cuts off the extended bars in the bar chart at the edge of the
GroupBox. This helps to have the bars not block other content, but the bars are still
extended below the plot area.
1struct YellowGroupBoxStyle: GroupBoxStyle {
2 func makeBody(configuration: Configuration) -> some View {
3 configuration.content
4 .padding(.top, 20)
5 .padding(10)
6 .background(Colors.bgGradient)
7 .cornerRadius(15)
8 .shadow(radius: 6.0, x: 6.0, y: 8.0)
9 .overlay(
10 configuration.label.padding(12),
11 alignment: .topLeading
12 )
13 .padding()
14 }
15}
1struct BarView3: View {
2 var mean: Double {
3 return Double(chartData.reduce(0) { $0 + $1.value }) / Double(chartData.count)
4 }
5
6 var body: some View {
7 ZStack {
8 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
9 .edgesIgnoringSafeArea(.all)
10 VStack {
11 GroupBox("Results - top bars - groupBoxStyle") {
12 Chart(chartData) { values in
13 BarMark(
14 x: .value("name", values.name),
15 y: .value("value", values.value)
16 )
17 .foregroundStyle(Colors.barColor)
18 .cornerRadius(10.0)
19 .annotation(position: .overlay, alignment: .top, spacing: 15.0) {
20 Text("\(values.value)")
21 .font(.caption2)
22 .foregroundColor(.white)
23 .fontWeight(.bold)
24 .padding(2)
25 }
26
27 RuleMark(y: .value("mean", mean))
28 .foregroundStyle(Colors.lineColor)
29 .annotation(position: .overlay,
30 alignment: .bottomTrailing,
31 spacing: 20) {
32 Text("mean = \(String(format: "%.1f", mean))")
33 .foregroundColor(Colors.lineColor)
34 }
35 }
36 .chartYScale(domain: 40...48)
37 .chartYAxis {
38 AxisMarks(position: .leading) { _ in
39 AxisValueLabel()
40 }
41 }
42 .chartXAxis {
43 AxisMarks(position: .bottom) { _ in
44 AxisGridLine()
45 AxisValueLabel()
46 }
47 }
48 .padding()
49 }
50 .groupBoxStyle(YellowGroupBoxStyle())
51 .frame(height: 400)
52 Spacer()
53 }
54 }
55 }
56}
Use of GroupBoxStyle cuts the bars off at the edge of the GroupBox
Clip the bar chart
The Chart can be modified with the clipped method and this almost works. The bars seem to be clipped below the plot area, including the x-axis labels with the effect that the x-axis labels are covered over with the extended bars.
1struct BarView4: View {
2 var mean: Double {
3 return Double(chartData.reduce(0) { $0 + $1.value }) / Double(chartData.count)
4 }
5
6 var body: some View {
7 ZStack {
8 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
9 .edgesIgnoringSafeArea(.all)
10
11 VStack {
12 GroupBox("Results - top bars - clipped") {
13 Chart(chartData) { values in
14 BarMark(
15 x: .value("name", values.name),
16 y: .value("value", values.value)
17 )
18 .foregroundStyle(Colors.barColor)
19 .cornerRadius(10.0)
20 .annotation(position: .overlay, alignment: .top, spacing: 15.0) {
21 Text("\(values.value)")
22 .font(.caption2)
23 .foregroundColor(.white)
24 .fontWeight(.bold)
25 .padding(2)
26 }
27
28 RuleMark(y: .value("mean", mean))
29 .foregroundStyle(Colors.lineColor)
30 .annotation(position: .overlay,
31 alignment: .bottomTrailing,
32 spacing: 20) {
33 Text("mean = \(String(format: "%.1f", mean))")
34 .foregroundColor(Colors.lineColor)
35 }
36 }
37 .chartYScale(domain: 40...48)
38 .clipped()
39 .chartYAxis {
40 AxisMarks(position: .leading) { _ in
41 AxisValueLabel()
42 }
43 }
44 .chartXAxis {
45 AxisMarks(position: .bottom) { _ in
46 AxisGridLine()
47 AxisValueLabel()
48 }
49 }
50 .padding()
51 }
52 .groupBoxStyle(YellowGroupBoxStyle())
53 .frame(height: 400)
54 Spacer()
55 }
56 }
57 }
58}
Use of Clipped cuts the base of the bars off, but covers the x-axis labels
Use yStart and yEnd on bar chart
The solution is to take a closer look at BarMark and the initialiser
init(x:yStart:yEnd:width:). In this initialiser, a start value and an end
value can be specified for the bar mark rather than a single y-value for each element
in the array of data. Change the BarMark to specify x
, yStart
and yEnd
values,
using a constant for the start values - the same constant as is used for the starting
range in the chartYScale
domain.
1struct BarView5: View {
2 var mean: Double {
3 return Double(chartData.reduce(0) { $0 + $1.value }) / Double(chartData.count)
4 }
5
6 let minValue = 40
7 let maxValue = 48
8
9 var body: some View {
10 ZStack {
11 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
12 .edgesIgnoringSafeArea(.all)
13
14 VStack {
15 GroupBox("Results - top bars - yStart & yEnd") {
16 Chart(chartData) { values in
17 BarMark(
18 x: .value("name", values.name),
19 yStart: .value("value", minValue),
20 yEnd: .value("value", values.value)
21 )
22 .foregroundStyle(Colors.barColor)
23 .cornerRadius(5.0)
24 .annotation(position: .overlay,
25 alignment: .top) {
26 Text("\(values.value)")
27 .font(.footnote)
28 .foregroundColor(.white)
29 .fontWeight(.bold)
30 .offset(y:30)
31 }
32 RuleMark(y: .value("mean", mean))
33 .foregroundStyle(Colors.lineColor)
34 .annotation(position: .overlay,
35 alignment: .bottomTrailing,
36 spacing: 5) {
37 Text("mean = \(String(format: "%.2f", mean))")
38 .foregroundColor(Colors.lineColor)
39 }
40 }
41 .chartYAxis {
42 AxisMarks(position: .leading)
43 }
44 .padding()
45 .chartYScale(domain: minValue...maxValue)
46 }
47 .groupBoxStyle(YellowGroupBoxStyle())
48 .frame(height: 400)
49 Spacer()
50 }
51 }
52 }
53}
Use of yStart and yEnd correctly displays the top section of the bars in SwiftUI
Use RectangleMark
Finally we have what we wanted where the bottom sections of the bars are excluded and
the bar chart is zoomed in on the top section of the chart. Is this still a bar
chart? An alternative is to move away from BarMark
and use RectangleMark
instead. The same data is charted with BarMark
and RectangleMark
below with
identical results produced..
1struct RectangleMarkView: View {
2 var mean: Double {
3 return Double(chartData.reduce(0) { $0 + $1.value }) / Double(chartData.count)
4 }
5
6 let minValue = 40
7 let maxValue = 48
8
9 var body: some View {
10 ZStack {
11 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
12 .edgesIgnoringSafeArea(.all)
13
14 VStack {
15 HStack {
16 GroupBox("Results - BarMark") {
17 Chart(chartData) { values in
18 BarMark(
19 x: .value("name", values.name),
20 yStart: .value("value", minValue),
21 yEnd: .value("value", values.value)
22 )
23 .foregroundStyle(Colors.barColor)
24 .cornerRadius(5.0)
25 .annotation(position: .overlay,
26 alignment: .top) {
27 Text("\(values.value)")
28 .font(.footnote)
29 .foregroundColor(.white)
30 .fontWeight(.bold)
31 .offset(y:30)
32 }
33 RuleMark(y: .value("mean", mean))
34 .foregroundStyle(Colors.lineColor)
35 .annotation(position: .overlay,
36 alignment: .bottomTrailing,
37 spacing: 5) {
38 Text("\(String(format: "%.1f", mean))")
39 .foregroundColor(Colors.lineColor)
40 }
41 }
42 .chartYAxis {
43 AxisMarks(position: .leading)
44 }
45 .padding()
46 .chartYScale(domain: minValue...maxValue)
47 }
48 .groupBoxStyle(YellowGroupBoxStyle())
49
50 GroupBox("Results - RectangleMark") {
51 Chart(chartData) { values in
52 RectangleMark(
53 x: .value("name", values.name),
54 yStart: .value("value", minValue),
55 yEnd: .value("value", values.value)
56 )
57 .foregroundStyle(Colors.barColor)
58 .cornerRadius(5.0)
59 .annotation(position: .overlay,
60 alignment: .top) {
61 Text("\(values.value)")
62 .font(.footnote)
63 .foregroundColor(.white)
64 .fontWeight(.bold)
65 .offset(y:30)
66 }
67 RuleMark(y: .value("mean", mean))
68 .foregroundStyle(Colors.lineColor)
69 .annotation(position: .overlay,
70 alignment: .bottomTrailing,
71 spacing: 5) {
72 Text("\(String(format: "%.1f", mean))")
73 .foregroundColor(Colors.lineColor)
74 }
75 }
76 .chartYAxis {
77 AxisMarks(position: .leading)
78 }
79 .padding()
80 .chartYScale(domain: minValue...maxValue)
81 }
82 .groupBoxStyle(YellowGroupBoxStyle())
83 }
84 .frame(height: 350)
85 }
86 }
87 }
88}
RectangleMark may be more appropriate to use rather than BarMark when not showing the entire bar in the chart
Use line chart instead
Both BarMark
and RectangleMark
produce identical results with bar charts zoomed
in on the top section of the bars. This chart could be open to misinterpretation as
in the examples above the bar for 46 looks to be twice as much as the bar for 43
(because it is) - however, the underlying data has not doubled. There may be an
assumption, when glancing at a bar chart, that the bar represents the size of the
data.
A line chart, using LineMark, might be more appropriate when trying to communicate differences between one category an the next.
1struct LineChartView: View {
2 var mean: Double {
3 return Double(chartData.reduce(0) { $0 + $1.value }) / Double(chartData.count)
4 }
5
6 var body: some View {
7 ZStack {
8 Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
9 .edgesIgnoringSafeArea(.all)
10
11 VStack {
12 GroupBox("Results - LineMark") {
13 Chart(chartData) { values in
14 LineMark(
15 x: .value("name", values.name),
16 y: .value("value", values.value)
17 )
18 .foregroundStyle(Colors.barColor)
19 .symbol(by: .value("data", "value"))
20 .symbolSize(150)
21 .annotation(position: .overlay,
22 alignment: .top) {
23 Text("\(values.value)")
24 .font(.footnote)
25 .foregroundColor(.white)
26 .fontWeight(.bold)
27 .offset(y:30)
28 }
29 RuleMark(y: .value("mean", mean))
30 .foregroundStyle(Colors.lineColor)
31 .annotation(position: .overlay,
32 alignment: .bottomTrailing,
33 spacing: 5) {
34 VStack {
35 Text("mean")
36 Text("\(String(format: "%.1f", mean))")
37 }
38 .foregroundColor(Colors.lineColor)
39 }
40 }
41 .chartYAxis {
42 AxisMarks(position: .leading)
43 }
44 .chartLegend(.hidden)
45 .padding()
46 .chartYScale(domain: 40...48)
47 }
48 .groupBoxStyle(YellowGroupBoxStyle())
49 .frame(height: 400)
50 Spacer()
51 }
52 }
53 }
54}
Line chart might be more appropriate than showing the top of bar chart
Conclusion
Bar charts are used to present categories of data as rectangular bars with the
heights or widths proportional to the values they represent. When a data set contains
data with very slight differences, it is possible to zoom in on the top section of
tha bar chart with Swift Charts. The mechanism to do this is to use yStart
and
yEnd
values for each BarMark as well as setting the y-axis scale with
chartYScale
.
However, just because it is possible, does not mean it should be done, as the resulting bar chart might be misleading. The size of the bar is no longer proportional to the value it represents. It may be better to use a line chart to zoom in on differences between different categories.
The code for BarChartSection View is available on GitHub.