Approaches to creating line graphs for iOS apps in the SwiftUI framework

Oleh Didushok
11 min readJan 11, 2024

--

Photo by Isaac Smith on Unsplash

An important component of mobile app development is the creation of informative graphs and charts. Visual representation of data is crucial for conveying complex information to users simply and concisely.

Although SwiftUI provides a powerful set of tools for creating screens and user interfaces, until iOS 16, there was no native framework from Apple for working with graphs. Of course, this didn’t mean that there was no way to create apps with graphs and charts. There were two ways to create graphs natively (using struct Shapes) or use third-party frameworks.

Here are some ways to implement charts before iOS 16:

  1. Developing graphics natively using struct Shapes, or rather struct Path, allows you to create your shapes and objects with any geometric properties, customize the animation of your graphic objects, and divide your UI into many small components. But this option has the following disadvantages: сreating complex forms can be cumbersome and require a significant amount of code, potentially making development complex; Path and Shapes may not have all the functionality of standard graphic frameworks, so you’ll need to implement some features separately.
  2. Using third-party frameworks saves development time and provides a relatively secure and proven code base (since the code has been used many times in other projects). However, even here there are drawbacks: dependence on a third-party framework and its subsequent update after critical bugs are found or with the release of new versions of iOS; dependence of some frameworks on others and their mutual compatibility; a significant increase in the size of the program.

Let’s look at different options for creating linear graphs. For example, let’s take the format of a regular line chart. The image below shows a broken-line graph with round points of current values, where the days of the week are marked horizontally, and the mood options (Excellent, Good, Usual, Terrible) are marked vertically by day.

We need to develop a line graph using the SwiftUI framework with support starting from iOS 15. Also, we need to minimize the use of third-party frameworks. Given that the specialized Swift Charts framework is only available since iOS 16, we start with the native way via struct Path.

Method №1: Shapes

SwiftUI provides many powerful tools out of the box, with Shapes being one of them, and Apple’s tools include Capsule, Circle, Ellipse, Rectangle, and RoundedRectangle. The Shape protocol conforms to the Animatable and View protocols, which means we can customize their appearance and behavior. But we can also create our shape using the Path structure (the outline of a two-dimensional shape we draw ourselves). The Shape protocol contains an important method func path(in: CGRect) -> Path: after implementing it, we must return a Path describing the structure of the newly created Shape.

Let’s start by creating a struct LineView that accepts an array of optional values of type Double? and uses Path to draw a graph from each previous array value to the next.

struct LineView: View {
let dataPoints: [Double?]
var body: some View {
GeometryReader { geometry in
let height = geometry.size.height
let width = geometry.size.width
Path { path in
path.move(to: CGPoint(x: 0, y: height * self.ratio(for: 0)))

for index in 0..<dataPoints.count {
path.addLine(to: CGPoint(
x: CGFloat(index) * width / CGFloat(dataPoints.count - 1),
y: height * self.ratio(for: index)))
}
}
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2, lineJoin: .round))
}
.padding()
}
private func ratio(for index: Int) -> Double {
return 1 - ((dataPoints[index] ?? 0) / 3)
}
}

To determine the boundary dimensions of the graph and calculate ratios, we use the GeometryReader, which will allow us to get the height and width values for superview. These values, along with the func ratio(for index: Int) -> Double method, calculate the location of each point on the line by multiplying the height by the ratio of the individual data point to the highest point (func ratio(for index: Int)).

To emulate the input data, we will create an enum MoodCondition that will describe different possible states:

enum MoodCondition: Double, CaseIterable {
case terrible = 0
case usual
case good
case excellent
var name: String {
switch self {
case .terrible: "Terrible"
case .usual: "Usual"
case .good: "Good"
case .excellent: "Excellent"
}
}
static var statusList: [String] {
return MoodCondition.allCases.map { $0.name }
}
}

Using the enum MoodCondition, let’s define the variable let selectedWeek, which will store the MoodCondition states for all days of the week:

 dataPoints = [
MoodCondition.excellent.rawValue,
MoodCondition.terrible.rawValue,
MoodCondition.good.rawValue,
MoodCondition.terrible.rawValue,
MoodCondition.good.rawValue,
MoodCondition.usual.rawValue,
MoodCondition.excellent.rawValue
]

Similar to the struct LineView, we will create a separate struct LineChartCircleView. The specified structure also accepts an array of optional values (let dataPoints), and an additional value let radius. The structure draws separate round points with a radius of radius also through Path.

struct LineChartCircleView: View {
let dataPoints: [Double?]
let radius: CGFloat
var body: some View {
GeometryReader { geometry in
let height = geometry.size.height
let width = geometry.size.width
Path { path in
path.move(to: CGPoint(x: 0, y: (height * self.ratio(for: 0)) - radius))
path.addArc(center: CGPoint(x: 0, y: height * self.ratio(for: 0)),
radius: radius,
startAngle: .zero,
endAngle: .degrees(360.0),
clockwise: false)
for index in 1..<dataPoints.count {
let point = CGPoint(
x: CGFloat(index) * width / CGFloat(dataPoints.count - 1),
y: height * (dataPoints[index] ?? 0) / 3
)
path.move(to: point)
let center = CGPoint(
x: CGFloat(index) * width / CGFloat(dataPoints.count - 1),
y: height * self.ratio(for: index)
)
path.addArc(center: center,
radius: radius,
startAngle: .zero,
endAngle: .degrees(360.0),
clockwise: false)
}
}
.foregroundColor(.green)
}
.padding()
}
private func ratio(for index: Int) -> Double {
return 1 - ((dataPoints[index] ?? 0) / 3)
}
}

We overlay struct LineChartCircleView on struct LineView and get a broken-line graph with points for each value:

Combining LineChartCircleView and LineView

It is important to display the X and Y coordinate axes along with the curves, so let’s start with the implementation of the Y axis, namely, by creating a struct YAxisView:

struct YAxisView: View {
var scaleFactor: CGFloat
var body: some View {
GeometryReader { geometry in
let height = geometry.size.height
ForEach(MoodCondition.allCases, id: \.rawValue) { condition in
let index = MoodCondition.allCases.firstIndex(of: condition) ?? 0

HStack {
Spacer()
Text(condition.name.capitalized)
.font(Font.headline)
.lineLimit(1)
}
.offset(y: (height * 0.9) - (CGFloat(index) * scaleFactor))
}
}
}
}

The value for the variable scaleFactor will be passed from the parent struct LineChartView, and the offset modifier will list all possible MoodCondition depending on the value of each value and the height of the chart.

To construct the coordinate X, create a struct XAxisView:

struct XAxisView: View {
var body: some View {
GeometryReader { geometry in
let labelWidth = (geometry.size.width * 0.8) / CGFloat(WeekDay.allCases.count + 1)
HStack {
Rectangle()
.frame(width: geometry.size.width * 0.15)
ForEach(WeekDay.allCases, id: \.rawValue) { item in
Text(item.rawValue.capitalized)
.font(Font.headline)
.frame(width: labelWidth)
}
}
}
}
}

Create an enum WeekDay to display all days of the week on the XaxisView axis:

enum WeekDay: String, CaseIterable {
case monday = "Mon"
case tuesday = "Tue"
case wednesday = "Wed"
case thursday = "Thu"
case friday = "Fri"
case saturnday = "Sat"
case sunday = "Sun"
}

To make the graph easier to use, let’s add horizontal dashed grid lines for the Y-axis, which will correspond to each MoodCondition. To do this, create a separate struct LinesForYLabel:

struct LinesForYLabel: View {
var body: some View {
GeometryReader { geometry in
let height = geometry.size.height
let width = geometry.size.width
Path { path in
let yStepWidth = height / 3
for index in 0...3 {
let y = CGFloat(index) * yStepWidth
path.move(to: .init(x: 0, y: y))
path.addLine(to: .init(x: width, y: y))
}
}
.stroke(style: StrokeStyle(lineWidth: 1, dash: [4]))
.foregroundColor(Color.gray)
}
.padding(.vertical)
}
}

It is important to combine all the Views into one single struct LineChartView, where they will be contained simultaneously:

  • X and Y axes;
  • broken-line graph;
  • intersection points;
  • horizontal dashed lines for the Y-axis.
struct LineChartView: View {
let dataPoints: [Double?]
init() {
dataPoints = [
MoodCondition.excellent.rawValue,
MoodCondition.terrible.rawValue,
MoodCondition.good.rawValue,
MoodCondition.terrible.rawValue,
MoodCondition.good.rawValue,
MoodCondition.usual.rawValue,
MoodCondition.excellent.rawValue
]
}
var body: some View {
GeometryReader { geometry in
let axisWidth = geometry.size.width * 0.23
let fullChartHeight = geometry.size.height
let scaleFactor = (fullChartHeight * 1.15) / CGFloat(MoodCondition.allCases.count)
VStack {
HStack {
YAxisView(scaleFactor: Double(scaleFactor))
.frame(width: axisWidth, height: fullChartHeight)
ZStack {
LinesForYLabel()
LineView(dataPoints: dataPoints)
LineChartCircleView(dataPoints: dataPoints, radius: 4.0)
}
.frame(height: fullChartHeight)
}
XAxisView()
}
}
.frame(height: 200)
.padding(.horizontal)
}
}

Using init(), we initialize the struct LineChartView with the input data for the dataPoints property through MoodCondition for all days of the week. The calculation of axisWidth and scaleFactor values is based on the ratio of values along the Y-axis and the size of the chart and may vary depending on the design. The structures LinesForYLabel(), LineView(dataPoints: dataPoints), LineChartCircleView(dataPoints: dataPoints, radius: 4.0) are superimposed on each other and placed in the ZStack. Then they are combined with YAxisView(scaleFactor: Double(scaleFactor)) and XAxisView() in HStack/VStack, respectively.

This way, you can develop any variants and combinations of charts. However, there is an interdependence of each component of the View, for example, the amount of code and the complexity of maintaining and expanding the existing functionality.

Method №2: SwiftUICharts

Another option for building a similar chart is to use a third-party framework, such as SwiftUICharts. It’s what they do:

  • Pie charts, line charts, bar charts, and histograms.
  • Various coordinate grids.
  • Interactive labels to show the current value of the chart, etc.

The library is available with iOS 13 and Xcode 11 and can be installed via Swift Package Manager or CocoaPods. After adding SwiftUICharts to your project, you need to import the framework using import SwiftUICharts:

import SwiftUI
import SwiftUICharts

struct SwiftUIChartsLibraryView: View {
let chartData: LineChartData

init() {
let dataSet = LineDataSet(
dataPoints: [
LineChartDataPoint(
value: MoodCondition.excellent.rawValue,
xAxisLabel: WeekDay.monday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.terrible.rawValue,
xAxisLabel: WeekDay.tuesday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.good.rawValue,
xAxisLabel: WeekDay.wednesday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.terrible.rawValue,
xAxisLabel: WeekDay.thursday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.good.rawValue,
xAxisLabel: WeekDay.friday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.usual.rawValue,
xAxisLabel: WeekDay.saturnday.rawValue.capitalized
),
LineChartDataPoint(
value: MoodCondition.excellent.rawValue,
xAxisLabel: WeekDay.sunday.rawValue.capitalized
)
],
pointStyle: PointStyle(
fillColour: .green,
pointType: .filled,
pointShape: .circle
),
style: LineStyle(
lineColour: ColourStyle(colour: .blue),
lineType: .line
)
)

let gridStyle = GridStyle(
numberOfLines: 4,
lineColour: Color(.lightGray).opacity(0.5),
lineWidth: 1,
dash: [4],
dashPhase: 0
)

let chartStyle = LineChartStyle(
infoBoxPlacement: .infoBox(isStatic: true),
xAxisLabelPosition: .bottom,
xAxisLabelFont: .headline,
xAxisLabelColour: Color.black,
yAxisGridStyle: gridStyle,
yAxisLabelPosition: .leading,
yAxisLabelFont: .headline,
yAxisLabelColour: Color.black,
yAxisNumberOfLabels: 4,
yAxisLabelType: .custom
)

self.chartData = LineChartData(
dataSets: dataSet,
metadata: ChartMetadata(title: "", subtitle: ""),
yAxisLabels: MoodCondition.statusList,
chartStyle: chartStyle
)
}

var body: some View {
LineChart(chartData: chartData)
.pointMarkers(chartData: chartData)
.yAxisGrid(chartData: chartData)
.xAxisLabels(chartData: chartData)
.yAxisLabels(chartData: chartData)
.frame(minWidth: 150,
maxWidth: 350,
minHeight: 100,
idealHeight: 150,
maxHeight: 200,
alignment: .center)
.padding(.horizontal, 24)
}
}

First, we initialize the let dataSet model with input data based on values from enum MoodCondition and enum WeekDay. Immediately, we configure the point markers with pointStyle and the model to control the style of the lines with style. We use GridStyle to configure the grid view for the chart and LineChartStyle to add the main chart settings.

The configuration returns a LineChartData object with all the necessary settings. This allows you to specify the required array of input values and customize the chart display according to the specified design. The disadvantages of this approach are:

  • Limited possibilities of the framework for graphical editing of the chart.
  • Time spent on learning the principles of working with the functionality.
  • Difficulty in combining different charts at the same time.

Method №3: Swift Charts

The last option for building a chart is the Swift Charts framework.

It creates various types of charts, including line, dot, and bar charts. Scales and axes that correspond to the input data are automatically generated for them.

We import the framework using import Charts, then create a struct Day function that will correspond to a specific day WeekDay and MoodCondition:

  struct Day: Identifiable {
let id = UUID()
let mood: MoodCondition
let day: WeekDay
}

Based on the struct Day, we will create a let currentWeeks variable that will correspond to the given week with the corresponding Day:

let currentWeeks: [Day] = [
Day(mood: .excellent, day: .monday),
Day(mood: .terrible, day: .tuesday),
Day(mood: .good, day: .wednesday),
Day(mood: .terrible, day: .thursday),
Day(mood: .good, day: .friday),
Day(mood: .usual, day: .saturnday),
Day(mood: .excellent, day: .sunday)
]

To build the required graph, we use structures:

  • LineMark, which visualizes data using a sequence of connected segments.
  • PointMark, which displays data using dots.
struct ChartsView: View {
struct Day: Identifiable {
let id = UUID()
let mood: MoodCondition
let day: WeekDay
}
let currentWeeks: [Day] = [
Day(mood: .excellent, day: .monday),
Day(mood: .terrible, day: .tuesday),
Day(mood: .good, day: .wednesday),
Day(mood: .terrible, day: .thursday),
Day(mood: .good, day: .friday),
Day(mood: .usual, day: .saturnday),
Day(mood: .excellent, day: .sunday)
]
private let weekDayTitle = "Week Day"
private let moodTitle = "Mood"
var body: some View {
VStack {
Chart {
ForEach(currentWeeks) {
LineMark(
x: .value(weekDayTitle, $0.day.rawValue.capitalized),
y: .value(moodTitle, $0.mood.rawValue)
)
PointMark(
x: .value(weekDayTitle, $0.day.rawValue.capitalized),
y: .value(moodTitle, $0.mood.rawValue)
)
.foregroundStyle(.green)
}
}
.chartXAxis {
AxisMarks(preset: .aligned,
position: .bottom
) { value in
AxisValueLabel()
.font(.headline)
.foregroundStyle(.black)
}
}
.chartYAxis {
AxisMarks(preset: .aligned,
position: .leading) { value in
AxisValueLabel {
let day = MoodCondition.statusList[value.index]
Text(day.capitalized)
.font(.headline)
.foregroundColor(.black)
}
AxisGridLine(
stroke: StrokeStyle(
lineWidth: 1,
dash: [4]))
}
}
}
.frame(height: 200)
.padding(.horizontal)
}
}

Using ForEach, we run through all the input data currentWeeks and set x, y values to LineMark and PointMark, respectively.

In the .chartXAxis modifier, set up the axis:

  • Positioning;
  • Color;
  • Scale for the X-axis.

The same applies to chartYAxis, but we also configure the Y-axis grid.

The peculiarity of using Swift Charts is that, with the help of various modifiers, we can quickly create many different types of charts without much effort. The framework is easy to use, supports animation, has a wide range of functions for creating and editing charts/diagrams, and also contains a lot of material on how to work with it.

Let’s compare the options for building charts using Shapes, SwiftUIChartsLIbrary, and Swift Charts for a comparative analysis:

struct ContentView: View {
var body: some View {
VStack {
VStack {
titleView(title: "A line graph through the Path")
LineChartView()
Spacer()
titleView(title: "A line graph through the SwiftUIChartsLibrary")
SwiftUIChartsLibraryView()
Spacer()
titleView(title: "A line graph through the Charts")
ChartsView()
}
}
}
private func titleView(title: String) -> some View {
Text(title)
.font(Font.headline)
.foregroundColor(.blue)
}
}

The result was as follows:

Create diagrams using Shapes, SwiftUIChartsLibrary, Swift Charts

So, we have tested 3 different options for building diagrams in the SwiftUI environment and such a simple task as building a graph in SwiftUI requires a thorough analysis:

  • The minimum version of iOS;
  • Design complexity;
  • The number of graphs.;
  • Time allocated for development;
  • The possibility of frequent design changes in the future, etc.

We have created a primitive chart, but even such a simple design allows you to understand all the difficulties that may arise in the future for iOS developers when building charts using the SwiftUI framework.

Thanks to Maksym Yevtukhivskyi for his help in writing this article.

Here you can find this project:

--

--