SwiftUI Line Chart Tutorial
Create a Line Chart in SwiftUI Using Paths
Introduced at WWDC 2019, the SwiftUI framework gave the iOS community a lot to cheer about. An easy-to-use, declarative API written in Swift lets developers quickly build UI prototypes.
While we can leverage the Shapes protocol to build Bar Charts from the ground up, the same cannot be said about Line Charts. Thankfully, we’ve got the Paths
structure to help us with that.
Using SwiftUI paths, which are similar to CGPaths
from the Core Graphics framework, we can combine lines and curves to build beautiful logos and shapes.
Being true to the declarative way of writing UI, SwiftUI paths are built with a set of instructions. In the next few sections, we’ll discuss what that means.
Our Goal
Exploring SwiftUI’s Path API and creating simple shapes out of it.
Using Combine and URLSession to fetch the historical stock data. We’ll be using Alpha Vantage’s API to retrieve the stock information.
Creating a Line Chart in SwiftUI that displays the stock’s prices over time.
By the end of this article, you should be able to create an iOS application similar to the one below:
Create a Simple SwiftUI Path
Here’s an example of creating a right-angled triangle using paths in SwiftUI:
var body: some View { | |
Path { path in | |
path.move(to: CGPoint(x: 100, y: 100)) | |
path.addLine(to: CGPoint(x: 100, y: 300)) | |
path.addLine(to: CGPoint(x: 300, y: 300)) | |
}.fill(Color.green) | |
} |
The Path API consists of a bunch of functions. move
is responsible for setting the starting point of the path. addLine
is responsible for drawing a straight line to the destination point specified.
addArc
, addCurve
, addQuadCurve
, addRect
, and addEllipse
are just a few other methods that let us create circular arcs or Bezier curves among many other things with paths.
Appending two or more paths is possible using addPath
.
The following illustration shows a triangle followed by a circular pie:
Now that we’ve got an idea of how to create paths in SwiftUI, let’s jump to Line Charts in SwiftUI.
SwiftUI Line Chart
The model to decode the JSON response from the API is given below:
struct StockPrice : Codable{ | |
let open: String | |
let close: String | |
let high: String | |
let low: String | |
private enum CodingKeys: String, CodingKey { | |
case open = "1. open" | |
case high = "2. high" | |
case low = "3. low" | |
case close = "4. close" | |
} | |
} | |
struct StocksDaily : Codable { | |
let timeSeriesDaily: [String: StockPrice]? | |
private enum CodingKeys: String, CodingKey { | |
case timeSeriesDaily = "Time Series (Daily)" | |
} | |
init(from decoder: Decoder) throws { | |
let values = try decoder.container(keyedBy: CodingKeys.self) | |
timeSeriesDaily = try (values.decodeIfPresent([String : StockPrice].self, forKey: .timeSeriesDaily)) | |
} | |
} |
Let’s create an ObservableObject
class. We’ll perform the API requests using the URLSession’s Combine Publisher and transform the results using the Combine operators.
class Stocks : ObservableObject{ | |
@Published var prices = [Double]() | |
@Published var currentPrice = "...." | |
var urlBase = "https://www.alphavantage.co/query?function=TIME_SERIES_DAILY&symbol=NSE:YESBANK&apikey=demo&datatype=json" | |
var cancellable : Set<AnyCancellable> = Set() | |
init() { | |
fetchStockPrice() | |
} | |
func fetchStockPrice(){ | |
URLSession.shared.dataTaskPublisher(for: URL(string: "\(urlBase)")!) | |
.map{output in | |
return output.data | |
} | |
.decode(type: StocksDaily.self, decoder: JSONDecoder()) | |
.sink(receiveCompletion: {_ in | |
print("completed") | |
}, receiveValue: { value in | |
var stockPrices = [Double]() | |
let orderedDates = value.timeSeriesDaily?.sorted{ | |
guard let d1 = $0.key.stringDate, let d2 = $1.key.stringDate else { return false } | |
return d1 < d2 | |
} | |
guard let stockData = orderedDates else {return} | |
for (_, stock) in stockData{ | |
if let stock = Double(stock.close){ | |
if stock > 0.0{ | |
stockPrices.append(stock) | |
} | |
} | |
} | |
DispatchQueue.main.async{ | |
self.prices = stockPrices | |
self.currentPrice = stockData.last?.value.close ?? "..." | |
} | |
}) | |
.store(in: &cancellable) | |
} | |
} | |
extension String { | |
static let shortDate: DateFormatter = { | |
let formatter = DateFormatter() | |
formatter.dateFormat = "yyyy-MM-dd" | |
return formatter | |
}() | |
var stringDate: Date? { | |
return String.shortDate.date(from: self) | |
} | |
} |
The API results consist of nested JSON with the key being the dates. These are unordered in the dictionary and we need to sort them. For that, we’ve declared an extension that converts the string to date and does a comparison in the sort
function.
Now that we’ve got the prices and stock data in the Published
properties, we need to pass them to the LineView
— a custom SwiftUI view that we will see next:
struct LineView: View { | |
var data: [(Double)] | |
var title: String? | |
var price: String? | |
public init(data: [Double], | |
title: String? = nil, | |
price: String? = nil) { | |
self.data = data | |
self.title = title | |
self.price = price | |
} | |
public var body: some View { | |
GeometryReader{ geometry in | |
VStack(alignment: .leading, spacing: 8) { | |
Group{ | |
if (self.title != nil){ | |
Text(self.title!) | |
.font(.title) | |
} | |
if (self.price != nil){ | |
Text(self.price!) | |
.font(.body) | |
.offset(x: 5, y: 0) | |
} | |
}.offset(x: 0, y: 0) | |
ZStack{ | |
GeometryReader{ reader in | |
Line(data: self.data, | |
frame: .constant(CGRect(x: 0, y: 0, width: reader.frame(in: .local).width , height: reader.frame(in: .local).height)), | |
minDataValue: .constant(nil), | |
maxDataValue: .constant(nil) | |
) | |
.offset(x: 0, y: 0) | |
} | |
.frame(width: geometry.frame(in: .local).size.width, height: 200) | |
.offset(x: 0, y: -100) | |
} | |
.frame(width: geometry.frame(in: .local).size.width, height: 200) | |
} | |
} | |
} | |
} |
The view above gets invoked from our SwiftUI ContentView, where the name, price, and an array of price history are passed. Using the GeometryReader, we’ll pass the width and height of the frame to the Line
struct, where we’ll eventually join the points using SwiftUI paths:
struct Line: View { | |
var data: [(Double)] | |
@Binding var frame: CGRect | |
let padding:CGFloat = 30 | |
var stepWidth: CGFloat { | |
if data.count < 2 { | |
return 0 | |
} | |
return frame.size.width / CGFloat(data.count-1) | |
} | |
var stepHeight: CGFloat { | |
var min: Double? | |
var max: Double? | |
let points = self.data | |
if let minPoint = points.min(), let maxPoint = points.max(), minPoint != maxPoint { | |
min = minPoint | |
max = maxPoint | |
}else { | |
return 0 | |
} | |
if let min = min, let max = max, min != max { | |
if (min <= 0){ | |
return (frame.size.height-padding) / CGFloat(max - min) | |
}else{ | |
return (frame.size.height-padding) / CGFloat(max + min) | |
} | |
} | |
return 0 | |
} | |
var path: Path { | |
let points = self.data | |
return Path.lineChart(points: points, step: CGPoint(x: stepWidth, y: stepHeight)) | |
} | |
public var body: some View { | |
ZStack { | |
self.path | |
.stroke(Color.green ,style: StrokeStyle(lineWidth: 3, lineJoin: .round)) | |
.rotationEffect(.degrees(180), anchor: .center) | |
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) | |
.drawingGroup() | |
} | |
} | |
} |
stepWidth
and stepHeight
are computed for constraining the chart within a given width and height of the frame. They’re then passed to the extension function of the Path
struct to create the line chart:
extension Path { | |
static func lineChart(points:[Double], step:CGPoint) -> Path { | |
var path = Path() | |
if (points.count < 2){ | |
return path | |
} | |
guard let offset = points.min() else { return path } | |
let p1 = CGPoint(x: 0, y: CGFloat(points[0]-offset)*step.y) | |
path.move(to: p1) | |
for pointIndex in 1..<points.count { | |
let p2 = CGPoint(x: step.x * CGFloat(pointIndex), y: step.y*CGFloat(points[pointIndex]-offset)) | |
path.addLine(to: p2) | |
} | |
return path | |
} | |
} |
Finally, the SwiftUI application displaying the stocks chart in action is ready. The following illustration showcases that:
Conclusion
In this article, we managed to bring together SwiftUI and Combine once again — this time for fetching stock prices and displaying them in a Line Chart. Knowing the intricacies of SwiftUI paths is a good starting point for working with SwiftUI shapes, which require you to implement the path
function.
You can take the SwiftUI Line Charts above a step further by using gestures to highlight the points and their respective values. To know how to do that and more, refer to this repository.
The full source code of the application above is available in the GitHub repository.
That’s it for this one. Thanks for reading.