Post

LineChart in SwiftUI

Result

Design

Features

  • x/y axis
    • X axis means month in this case. It always shows 8 monthes, which can control its width better.
    • Y axis displays data based on month. It has a wide range from 1 to 100k. Using abbreviation to control its width better.
  • next/previous year/month button
  • dotted line
  • dot on the data

Data

  • chartData: A dictionary. Key is year, value is data of this year.
  • displayData: A list of Double value. data[0] represent data in Jan, data[1] represent data in Feb…
  • endMonth: Int value. The display month will from 0 to endMonth or endMonth-8 to endMonth

Implement

Chart Line

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// yValues is the list of the data
// factor is the scale factor based on the chart height and max data
struct LineChartShape: Shape {
    var yValues: [Double]
    var factor: Double
    
    func path(in rect: CGRect) -> Path {
        let xIncrement = (rect.width / (CGFloat(yValues.count - 1)))
        var xValue = 0.0
        var path = Path()
        path.move(to: CGPoint(x: xValue, y: (rect.height - (yValues[0] * factor))))
        for i in 1..<yValues.count {
            xValue += xIncrement
            let pt = CGPoint(x: xValue,
                             y: (rect.height - (yValues[i] * factor)))
            path.addLine(to: pt)
        }
        return path
    }
}

X Axis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct XaxisView: View {
    var data: [Double]
    let month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
    var endMonth: Int
    
    var body: some View {
        GeometryReader { gr in
            let labelWidth = (gr.size.width * 0.9) / CGFloat(data.count - 1)
            let padWidth = (gr.size.width * 0.05) / CGFloat(data.count - 1)
            let labelHeight = gr.size.height
            let tickHeight = gr.size.height * 0.2
            let offset = endMonth - 8
            ZStack {
                Rectangle()
                    .frame(width:gr.size.width, height: 1.5)
                    .offset(x: 0, y: -(gr.size.height/2.0))
                    .foregroundColor(.white)
                
                HStack(spacing:0) {
                    ForEach(0..<data.count, id: \.self) { i in
                        ZStack {
                            VStack {
                                Rectangle()
                                    .frame(width: 1, height: tickHeight)
                                    .foregroundColor(.white)
                                Spacer()
                            }
                            Text(month[i + offset])
                                .font(.footnote)
                                .scaledToFill()
                                .frame(width:labelWidth, height: labelHeight)
                                .offset(CGSize(width: 0.0, height: 5.0))
                                .foregroundColor(.white)
                        }
                    }
                    .padding(.horizontal, padWidth)
                }
            }
            .offset(CGSize(width: -(gr.size.width / CGFloat(data.count))/2.0 - 1, height: 0.0))
        }
    }
}

Y Axis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// calculate axis parameters with given steps
struct AxisParameters {
    static func getTicks(top:Double) -> [Int] {
        let steps = [5, 10, 15, 20, 25, 30, 50, 100, 150, 200, 250, 300, 500, 1000, 1500, 2000, 2500, 3000, 5000, 10000, 15000, 20000, 25000, 30000, 50000, 100000]
        var step = 1
        var dis = 100000
        for v in steps {
            if abs(v - Int(top / 3.0)) < dis {
                step = v
            }
            dis = abs(v - Int(top / 3.0))
        }
        var high = Int(top)
        
        high = ((Int(top)/step) * step) + step + step
        var ticks:[Int] = []
        for i in stride(from: 0, to: high, by: step) {
            ticks.append(i)
        }
        return ticks
    }
    
    static func getStringTick(tick: Int) -> String {
        var stringTick: String
        if tick < 10000 {
            stringTick = "\(tick)"
        } else {
            stringTick = "\(Int(tick/1000))k"
        }
        return stringTick
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ticks: axis parameters
// scaleFactor: control the position based on the chart height and max data
struct YaxisView: View {
    var ticks: [Int]
    var scaleFactor: Double
    
    var body: some View {
        GeometryReader { gr in
            let fullChartHeight = gr.size.height
            ZStack {
                // y-axis line
                Rectangle()
                    .foregroundColor(.white)
                    .frame(width:1.5)
                    .offset(x: gr.size.width/2.0 + 1, y: 1)
                
                // Tick marks
                ForEach(ticks, id:\.self) { t in
                    HStack {
                        Spacer()
                        Text("\(AxisParameters.getStringTick(tick: t))")
                            .font(.footnote)
                            .scaledToFill()
                            .minimumScaleFactor(0.01)
                            .lineLimit(1)
                            .foregroundColor(.white)
                    }
                    .offset(x: -15, y: (fullChartHeight/2.0) - (CGFloat(t) * CGFloat(scaleFactor)))
                }
            }
        }
    }
}

Dotted Line & Data Point

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// dotted line should be used with .stroke
// shown in the next block
struct Line: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: 0, y: 0))
        path.addLine(to: CGPoint(x: rect.width, y: 0))
        return path
    }
}
struct MarkerShapeWhite: Shape {
    var yValues: [Double]
    var factor: Double
    var radius: Double
    
    func path(in rect: CGRect) -> Path {
        let xIncrement = (rect.width / (CGFloat(yValues.count - 1)))
        var xValue = 0.0
        var path = Path()
        path.addEllipse(in: CGRect(x: xValue - radius, y: (rect.height - radius - (yValues[0] * factor)), width: radius * 2.0, height: radius * 2.0))
        for i in 1..<yValues.count-1 {
            xValue += xIncrement
            path.addEllipse(in: CGRect(x: xValue - radius, y: (rect.height - radius - (yValues[i] * factor)), width: radius * 2.0, height: radius * 2.0))
        }
        return path
    }
}

Chart Area

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Chart Area creates the view inside the graph, including the chart, dotted line, data point, but not including x/y axis
struct ChartAreaView: View {
    var data: [Double]
    var ticks: [Int]
    var scaleFactor: Double
    
    var body: some View {
        GeometryReader { gr in
            ZStack {
                ForEach(ticks, id:\.self) { t in
                    if t > 0 {
                        Line()
                           .stroke(style: StrokeStyle(lineWidth: 1, dash: [5]))
                           .frame(width: gr.size.width ,height: 1)
                           .foregroundColor(.white)
                           .offset(y: (gr.size.height/2.0) - (CGFloat(t) * CGFloat(scaleFactor)))
                    }
                }
                
                
                LineChartShape(yValues: data, factor: scaleFactor)
                    .stroke(lineWidth: 2.0)
                    .foregroundColor(.white)
                
                MarkerShapeWhite(yValues: data, factor: scaleFactor, radius: 5)
                    .foregroundColor(.white)
                
                // the last point
                // create it outside the MarkerShapeWhite to make it in different color
                // for convenient, using the image rather than view
                Image("point")
                    .position(CGPoint(x: gr.size.width/2 + 8, y: gr.size.height/2 + 8))
                    .offset(x: 0, y: -data.last! * scaleFactor)
                    .frame(width: 16, height: 16)
            }
        }
    }
}

Line Chart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// this struct create the final line chart with given data and endMonth
struct LineChartView: View {
    var data: [Double]
    var endMonth: Int
    
    @State private var offset = CGSize.zero
    
    var body: some View {

        GeometryReader { gr in
            let maxValue = data.max() ?? 0
            let axisWidth = gr.size.width * 0.12
            let axisHeight = gr.size.height * 0.1
            let fullChartHeight = gr.size.height - axisHeight

            let tickMarks: [Int] = AxisParameters.getTicks(top: maxValue)
            let scaleFactor = (fullChartHeight * 0.95) / CGFloat(tickMarks[tickMarks.count-1])
            
            VStack(spacing:0) {
                
                HStack(spacing:0) {
                    YaxisView(ticks: tickMarks, scaleFactor: Double(scaleFactor))
                        .frame(width:axisWidth, height: fullChartHeight)
                    ChartAreaView(data: data, ticks: tickMarks, scaleFactor: Double(scaleFactor))
                        .frame(height: fullChartHeight)
                    Spacer()
                }
                .zIndex(1)
                
                HStack(spacing:0) {
                    Rectangle()
                        .fill(Color.clear)
                        .frame(width:axisWidth, height:axisHeight)
                    XaxisView(data: data, endMonth: endMonth)
                        .frame(height:axisHeight)
                    Spacer()
                }
            }
        }
    }
}

Apply LineChart in the View

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct MyChartView: View {
    @Binding var MyData: [Double]
    @Binding var endMonth: Int
    
    var body: some View {
        
        GeometryReader {
            gr in
            LineChartView(data: self.MyData, endMonth: self.endMonth)
            .frame(width: gr.size.width, height: gr.size.height)
        }
            
    }
}

Apply MyChartView in the View

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
// part of code inside the body
// contains the year and its control button
// yearMax is a state value of the current year
// yearMin is a state value decided by the data
// generateDisplayData is a function generate a list based on the year, endMonth and ChartData (Dictionary of year and list of data)
VStack {
    HStack (spacing: 10) {
        Text("\(year)")
            .font(.system(size: 14, weight: .bold, design: .default))
            .padding(.leading, 18)
            .foregroundColor(.white)
            .onChange(of: year) {
                newValue in
                generateDisplayData(year: year, endMonth: endMonth)
            }
        
        VStack (spacing: 5) {
            Button(action: {
                if self.year < self.yearMax {
                    self.year += 1
                    self.endMonth = self.chartData[year]!.count
                }
            }, label: {
                Image("arrow_up")
                    .resizable()
                    .frame(width: 24, height: 24)
            })
            
            Button(action: {
                if self.year > self.yearMin {
                    self.year -= 1
                    self.endMonth = self.chartData[year]!.count
                }
            }, label: {
                Image("arrow_down")
                    .resizable()
                    .frame(width: 24, height: 24)
            })
        }
        .onChange(of: endMonth) {
            newValue in
            generateDisplayData(year: year, endMonth: endMonth)
        }
        
        Spacer()
        
        Button(action: {
            if self.endMonth > 8 {
                self.endMonth -= 1
            } else if endMonth == 8 && year > self.yearMin {
                self.endMonth = 12
                self.year -= 1
            }
        }, label: {
            Image(systemName: "chevron.left")
                .resizable()
                .scaledToFit()
                .frame(width: 10, height: 10)
                .foregroundColor(.white)
        })
        
        Button(action: {
            if self.endMonth < 12 && self.chartData[self.year]!.count > endMonth {
                self.endMonth += 1
            } else if endMonth == 12 && year < self.yearMax {
                self.endMonth = 8
                self.year += 1
            }
        }, label: {
            Image(systemName: "chevron.right")
                .resizable()
                .scaledToFit()
                .frame(width: 10, height: 10)
                .foregroundColor(.white)
        })
        
        
        Spacer()
            .frame(width: 16)
    }

    HStack {
        Spacer()
            .frame(width: 18)
        MyChartView(MyData: $displayData, endMonth: $endMonth)
            .frame(width: UIScreen.main.bounds.size.width * 356 / 390, height: UIScreen.main.bounds.size.height * 188 / 844, alignment: .center)
        Spacer()
    }
}
This post is licensed under CC BY 4.0 by the author.