Categories
iOS Development

Draw line graphs and charts in SwiftUI

Graphs and charts are an essential tool for visualizing data, and with SwiftUI, it’s easy to create custom and interactive graphs (Spoiler: we won’t add any interactivity during this arcticle). You can use them in iOS, iPadOS, and macOS apps. In this article, we’ll explore how to draw a line graph in SwiftUI. In a follow up article I will show you how to draw bar graphs in SwiftUI.

See my earlier article if you want to draw graphs in Swift: https://mic.st/blog/draw-graphs-easily-with-carekits-ockcartesianchartview/

Let’s start with a really quick recap as you might still be pretty new to SwiftUI.

Update: Native API for iOS 16.0+

The following article is about building a view for showing graphs on your own.

There is also Apple’s own native framework for drawing graphs in SwiftUI that you can use starting iOS 16.0+.

If possible, you should use this native API, see: https://developer.apple.com/documentation/charts

Thanks @Jem for leaving a comment about that!

SwiftUI project Setup

Let’s take a quick moment to recap on SwiftUI. As you may know, SwiftUI is a modern UI framework that uses a declarative syntax to create user interfaces. For starters, it has way less boilerplate compared to defining UIs or views in general using Swift alone. However, the syntax can look a little strange if you are coming from Swift.

To create a new SwiftUI project, launch Xcode and select “File” > “New” > “Project.” In the “Create a new Xcode project” window, select “App” and click “Next.” Then, choose “SwiftUI” as the user interface and give your project a name. Finally, select the directory where you want to save your project and click “Create.”

Alright, now we are ready to go! You should see a new Xcode project where you land in a file called “ContentView.swift”. On the right side there should be your preview of this rendered ContentView.

The classic “Hello, World!” content looks like this:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}Code language: Swift (swift)

If you never did any SwiftUI before, I can highly recommend to just play a little bit around with this piece of code. Just add any view modifiers and see what happens.

Draw a Line Graph in SwiftUI

Now that we have our little project set up, let’s start by draw the line graph itself. As you probably know, a line graph is a simple type of graph that displays data as a series of points connected by straight lines. We could later add some rounding / interpolating to make it look “nicer” but let’s start easy first.

Our goal is to pass some data that represents X and Y coordinates to some custom View and this View should render our data. To make things easier, the view should scale itself accordingly to the minimum and maximum values. The origin of the graph should be at the bottom-left corner.

E.g. the data might look like this:

let data:[(Double, Double)] = [(0, 0), (1, 5), (2, 3), (3, 8), (4, 4), (5, 6), (6, 1)]Code language: Swift (swift)

This data set represents seven points with the x-values ranging from 0 to 6 and the y-values ranging from 0 to 8.

In the following sections I will start with the code followed by a small explanation what happens. The great thing about SwiftUI is that you can quickly play around with it. That means whenever you need to re-draw your line graph in SwiftUI you do not need to re-build. Xcode does instantly render your view in the preview.

Eventually we want to have a custom View that we can call like this:

import SwiftUI

struct ContentView: View {
  let data:[(Double, Double)] = [(0, 0), (1, 5), (2, 3), (3, 8), (4, 4), (5, 6), (6, 1)]

  var body: some View {
    LineGraphView(data: data)
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}Code language: Swift (swift)

Drawing the data

Let’s start with the code for drawing the line itself.

struct LineGraphView: View {
  let data: [(Double, Double)]
  var body: some View {
    GeometryReader { geometry in
      Path { path in
        let xScale = geometry.size.width / CGFloat(data.count - 1)
        let yScale = geometry.size.height / CGFloat(data.max { $0.1 < $1.1 }?.1 ?? 1)
        path.move(to: .init(x: 0, y: geometry.size.height - CGFloat(data[0].1) * yScale))
        for i in 1..<data.count {
          path.addLine(to: .init(x: CGFloat(i) * xScale, y: geometry.size.height - CGFloat(data[i].1) * yScale))
        }
      }
      .stroke(Color.blue, lineWidth: 2)
    }
  }
}Code language: Swift (swift)

Alright, let’s break down this code snippet! We start with a GeometryReader, which gives us access to the size of the view it’s contained in.

If you want to learn a little bit more about `GeometryReader`https://www.hackingwithswift.com/books/ios-swiftui/understanding-frames-and-coordinates-inside-geometryreader by Paul Hudson.

Next, we create a Path and start drawing the line using move(to:) to move the drawing cursor to the first point of our data. Then, we loop through the data and draw a line to each subsequent point using addLine(to:).

We calculate the scaling factors for the x and y axes by dividing the view size by the number of data points and the maximum data value, respectively. Finally, we stroke the path with a blue color and a line width of 2 points. And voila, we have ourselves a beautiful line graph!

Line graph for seven data points drawn by SwiftUI.
Line graph for seven data points drawn by SwiftUI.

Axis and ticks

However, what is not so beautiful are the missing axis and ticks on the axis to indicate the data point values. To draw the line graph in SwiftUI was rather easy. Adding ticks involves some more thinking for making it perfect. For simplicity we just want to have a tick for every full value. In the conclusion section there is an example showing that this does not always works really great.

Add the following code into you GeometryReader block for adding axis and ticks.

// x-axis
Path { path in
  let xScale = geometry.size.width / CGFloat(data.count - 1)
  let y = geometry.size.height
  path.move(to: .init(x: 0, y: y))
  path.addLine(to: .init(x: geometry.size.width, y: y))

  let tickSpacing = 1
  for i in 0..<(data.count - 1) {
    let tickX = CGFloat(i * tickSpacing) * xScale
    guard tickX < geometry.size.width else { break }
    path.move(to: .init(x: tickX, y: y - 5))
    path.addLine(to: .init(x: tickX, y: y + 5))
  }
}
.stroke(Color.red, lineWidth: 5)

// y-axis
Path { path in
  let x: CGFloat = 0
  let yScale: CGFloat = geometry.size.height / CGFloat(data.max { $0.1 < $1.1 }?.1 ?? 1)
  path.move(to: .init(x: x, y: 0))
  path.addLine(to: .init(x: x, y: geometry.size.height))

  let tickSpacing: CGFloat = 1
  for i in 0..<Int(data.max { $0.1 < $1.1 }!.1) {
    let tickY = CGFloat(i) * tickSpacing * yScale
    path.move(to: .init(x: x - 5, y: geometry.size.height - tickY))
    path.addLine(to: .init(x: x + 5, y: geometry.size.height - tickY))
  }
}
.stroke(Color.red, lineWidth: 5)Code language: Swift (swift)

OK, what happens here?

The first block of code creates the x-axis. It starts by defining a Path and then setting the starting point at the bottom-left corner of the chart. The x-axis is drawn using the addLine method, which creates a line from the starting point to the right-most point of the chart. The tickSpacing variable is used to determine the space between each tick on the x-axis. The loop iterates over the data set. Inside of it we draws tick marks at the specified intervals along the X-axis.

The second block of code creates the y-axis. It looks quite similar to the first one. However, for calculating the yScale we need to find the maximum Y value of the data set.

Finally, both the x and y axis are drawn using the stroke method. This uses the specified color and line width for drawing the path.

For our simple example, we chose a fixed tickSpacing value of 1 so that you can easily read our small number of data points. You might need to change this logic if you want to render larger data sets.

Line graph drawn in SwiftUI showing seven data points and X and Y axis with ticks for every integer.
Our line graph with added axis

Add some labels

Still our graph looks a little bit empty.You do not really get an idea of the dimensions. So let’s add some labels on our ticks. For simplicity, we just add on every tick a label of the tick’s value. Depending on your expected data you might change the logic here a little bit as well.

You can add the following code also inside of your GeometryReader block.

// add x-axis tick labels
ForEach(1..<(data.count - 1), id: \.self) { i in
  let tickLabel = Text("\(i)")
    .frame(width: 16, height: 16, alignment: .center)
  let tickX = CGFloat(i) * geometry.size.width / CGFloat(data.count - 1)
  tickLabel
    .position(x: tickX, y: geometry.size.height - 16)
}

// add y-axis tick labels
ForEach(1..<Int(data.max { $0.1 < $1.1 }!.1), id: \.self) { i in
  let tickLabel = Text("\(i)")
    .frame(width: 16, height: 16, alignment: .center)
  let tickY = CGFloat(i) * geometry.size.height / CGFloat(data.max { $0.1 < $1.1 }!.1)
  tickLabel
    .position(x: 16 - 5, y: geometry.size.height - tickY)
}Code language: Swift (swift)

We basically loop over the same ranges we had before. I did set the label’s frame to a fixed size so that their position is easier to calculate.

Line graph showing six data points, X and Y axis with ticks on each integer and labels on each tick indicating the integer value.
Our final simple line graph with ticks and labels.

Conclusion

We did just draw a simple line graph in SwiftUI by using a relatively simple custom view. This handles scaling and adding of axis, ticks and labels completely on it’s own. If you add more data than the example of just seven values it will automatically scale itself.

From this solution you can easily iterate now. E.g. add support for larger datasets, add animations, more labels, legends, lines, animations, etc.

First thing you will quickly see, is that the given first draft will not look that great for large data sets.

For example given this data:

struct ContentView: View {
  let data:[(Double, Double)] = [(0, 0), (1, 5), (2, 3), (3, 30), (4, 4), (5, 6), (6, 1), (7, 4), (8, 6), (9, 1)]

  var body: some View {
    LineGraphView(data: data)
  }
}Code language: Swift (swift)

You get the following output:

As you can see, what I mentioned earlier comes into effect here: Because we did just add ticks and labels for every value, the Y axis looks a little bit crowded. You can fix this by adding an additional parameter (e.g. for removing every n-th tick) or you change the logic so that it automatically hides some based on your needs (e.g. based on the view’s available size).

However, I will leave that up to you now 🙂 Happy experimenting!

Full Code example

ContentView.swift

//
//  ContentView.swift
//  TestGraph
//
//  Created by Michael Steudter on 01.05.23.
//

import SwiftUI

struct ContentView: View {
  let data:[(Double, Double)] = [(0, 0), (1, 5), (2, 3), (3, 30), (4, 4), (5, 6), (6, 1), (7, 4), (8, 6), (9, 1)]
 
  var body: some View {
    LineGraphView(data: data)
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}
Code language: Swift (swift)

LineGraphView.swift

//
//  LineGraphView.swift
//  TestGraph
//
//  Created by Michael Steudter on 01.05.23.
//

import SwiftUI

struct LineGraphView: View {
  let data: [(Double, Double)]
  
  var body: some View {
    ZStack {
      GeometryReader { geometry in
        Path { path in
          let xScale = geometry.size.width / CGFloat(data.count - 1)
          let yScale = geometry.size.height / CGFloat(data.max { $0.1 < $1.1 }?.1 ?? 1)
          path.move(to: .init(x: 0, y: geometry.size.height - CGFloat(data[0].1) * yScale))
          for i in 1..<data.count {
            path.addLine(to: .init(x: CGFloat(i) * xScale, y: geometry.size.height - CGFloat(data[i].1) * yScale))
          }
        }
        .stroke(Color.blue, lineWidth: 2)
        
        //    // x-axis
        Path { path in
          let xScale = geometry.size.width / CGFloat(data.count - 1)
          let y = geometry.size.height
          path.move(to: .init(x: 0, y: y))
          path.addLine(to: .init(x: geometry.size.width, y: y))
          
          let tickSpacing = 1
          for i in 0..<(data.count - 1) {
            let tickX = CGFloat(i * tickSpacing) * xScale
            guard tickX < geometry.size.width else { break }
            path.move(to: .init(x: tickX, y: y - 5))
            path.addLine(to: .init(x: tickX, y: y + 5))
          }
        }
        .stroke(Color.red, lineWidth: 5)
        //
        // y-axis
        Path { path in
          let x: CGFloat = 0
          let yScale: CGFloat = geometry.size.height / CGFloat(data.max { $0.1 < $1.1 }?.1 ?? 1)
          path.move(to: .init(x: x, y: 0))
          path.addLine(to: .init(x: x, y: geometry.size.height))
          
          let tickSpacing: CGFloat = 1
          for i in 0..<Int(data.max { $0.1 < $1.1 }!.1) {
            let tickY = CGFloat(i) * tickSpacing * yScale
            path.move(to: .init(x: x - 5, y: geometry.size.height - tickY))
            path.addLine(to: .init(x: x + 5, y: geometry.size.height - tickY))
          }
        }
        .stroke(Color.red, lineWidth: 5)
        
        // add x-axis tick labels
        ForEach(1..<(data.count - 1), id: \.self) { i in
          let tickLabel = Text("\(i)")
            .frame(width: 16, height: 16, alignment: .center)
          let tickX = CGFloat(i) * geometry.size.width / CGFloat(data.count - 1)
          tickLabel
            .position(x: tickX, y: geometry.size.height - 16)
        }
        
        // add y-axis tick labels
        ForEach(1..<Int(data.max { $0.1 < $1.1 }!.1), id: \.self) { i in
          let tickLabel = Text("\(i)")
            .frame(width: 16, height: 16, alignment: .center)
          let tickY = CGFloat(i) * geometry.size.height / CGFloat(data.max { $0.1 < $1.1 }!.1)
          tickLabel
            .position(x: 16 - 5, y: geometry.size.height - tickY)
        }
      }
    }
  }
}
Code language: Swift (swift)

2 replies on “Draw line graphs and charts in SwiftUI”

Why did you roll your own chart when there is a perfectly good native Charts framework created by Apple months prior to this post? Wouldn’t you want to teach the reader the most efficient way to do it?

Hey Jem,
thank you for the hint and sorry for my late reply!

Of course you are right, I just updated the article with a link to the official docs.

Back then my project on that I based the article was not min 16.0 so I was not able to use it and did not really look into it.
But yes, whenever possible you should always prefer native APIs. Thanks again for the hint!

Have a nice day and best regards,
Michael

Leave a Reply

Your email address will not be published. Required fields are marked *