Drawing Charts in iOS Before SwiftUI

An implementation of Line Chart using UIKit

Gennady Stepanov
Better Programming

--

Image generated with craiyon.com

In this article, we’ll see how interactive linear charts could have been implemented in iOS before SwiftUI Charts was introduced in 2022.

Let’s imagine a fairly real-world scenario that a product owner requires your team to create a minimalistic linear chart that would:

  • show a smooth curve with a gradient;
  • interactively display data on graph responding to user’s gesture;
  • current value should be marked with a vertical line and displayed in a bubble alongside additional information;
  • it would be nice to support multiple charts in one area;
  • there must be the minimum difference between iOS and Android final appearance.

That’s quite a verbose explanation but trying to visualize it, you’ll notice that you’ve seen it many times in different variations in apps like Apple’s Stocks, banking, or fitness tracking app.

Let’s Go

Every product development starts with research and it’s easy to find that there’s no need to reinvent the wheel — there’s a library already that suits our needs. It was originally written for Android (MPAndroidChart by Philipp Jahoda) and its iOS version is called Charts (written by Daniel Cohen Gindi)

Among its benefits I would like to note the following:

  • supports plenty of chart types;
  • lots of customization options available;
  • corresponds to its Android version looks;
  • installation with CocoaPods, Carthage, SPM.

To visualize the final product requirements, we need to end up with the following widget:

Experimenting with UI components and third-party libraries is most comfortable in a separate project. Let’s create an empty project and install Charts with your favorite dependency manager. I prefer SPM, all available options are listed on the Charts Github page.

I won’t describe the initial process of creating a project. In case you want to grab the final version of the project and work alongside the tutorial, it is available on my GitHub page.

First things first, let’s create a very basic linear chart. It will help us understand the overall logic of Charts library and most importantly what needs to be changed in order to achieve the desired outcome.

Add the following code to viewDidLoad() of our view controller (don’t forget to import Charts in file header)

override func viewDidLoad() {
super.viewDidLoad()
let lineChartEntries = [
ChartDataEntry(x: 1, y: 2),
ChartDataEntry(x: 2, y: 4),
ChartDataEntry(x: 3, y: 3),
]
let dataSet = LineChartDataSet(entries: lineChartEntries)
let data = LineChartData(dataSet: dataSet)
let chart = LineChartView()
chart.data = data

view.addSubview(chart)
chart.snp.makeConstraints {
$0.centerY.width.equalToSuperview()
$0.height.equalTo(300)
}
}

In order to grasp the logic of Charts I suggest moving in the reverse direction starting from the bottom of this code snippet.

We can see that the chart area itself is a UIView descendant and we must set data into it. The type of chart is exactly a linear chart, for bar charts and other variants there are other dedicated types of views.

Also, the chart data type must correspond to the view type being LineChartData. This data type has a constructor that accepts some datasets (at this stage we may notice that there’s also a constructor that accepts an array of datasets, this is the key to implementing multiple charts in one area, as we remember the product owner asked to try and support this feature).

The dataset type in its turn must correspond to linear chart type being LineChartDataSet, which is an abstraction above an array of data entries (finally being points in the chart area). Every entry has X and Y coordinates, quite simple.

Let’s build and run our project to see what’s drawn on the screen:

Oh no, it absolutely doesn’t look like what the business wants us to implement. Let’s make a plan of changes then:

  • Change graph line color
  • Remove points from the graph and their annotations
  • Add smoothing to our curve
  • Add gradient beneath the curve
  • Remove axis annotations
  • Remove legend
  • Remove grid.

Customize it

Some of these settings refer to the chart area and some to the dataset (this is because one area can display multiple charts each with its own settings)

Chart area settings:

// disable grid
chart.xAxis.drawGridLinesEnabled = false
chart.leftAxis.drawGridLinesEnabled = false
chart.rightAxis.drawGridLinesEnabled = false
chart.drawGridBackgroundEnabled = false
// disable axis annotations
chart.xAxis.drawLabelsEnabled = false
chart.leftAxis.drawLabelsEnabled = false
chart.rightAxis.drawLabelsEnabled = false
// disable legend
chart.legend.enabled = false
// disable zoom
chart.pinchZoomEnabled = false
chart.doubleTapToZoomEnabled = false
// remove artifacts around chart area
chart.xAxis.enabled = false
chart.leftAxis.enabled = false
chart.rightAxis.enabled = false
chart.drawBordersEnabled = false
chart.minOffset = 0
// setting up delegate needed for touches handling
chart.delegate = self

For dataset handling let’s look one step further and create a dataset factory to support multiple charts cases.

/// Factory preparing dataset for a single chart
struct ChartDatasetFactory {
func makeChartDataset(
colorAsset: DataColor,
entries: [ChartDataEntry]
) -> LineChartDataSet {
var dataSet = LineChartDataSet(entries: entries, label: "")

// chart main settings
dataSet.setColor(colorAsset.color)
dataSet.lineWidth = 3
dataSet.mode = .cubicBezier // curve smoothing
dataSet.drawValuesEnabled = false // disble values
dataSet.drawCirclesEnabled = false // disable circles
dataSet.drawFilledEnabled = true // gradient setting

// settings for picking values on graph
dataSet.drawHorizontalHighlightIndicatorEnabled = false // leave only vertical line
dataSet.highlightLineWidth = 2 // vertical line width
dataSet.highlightColor = colorAsset.color // vertical line color

addGradient(to: &dataSet, colorAsset: colorAsset)

return dataSet
}
}

private extension ChartDatasetFactory {
func addGradient(
to dataSet: inout LineChartDataSet,
colorAsset: DataColor
) {
let mainColor = colorAsset.color.withAlphaComponent(0.5)
let secondaryColor = colorAsset.color.withAlphaComponent(0)
let colors = [
mainColor.cgColor,
secondaryColor.cgColor,
secondaryColor.cgColor
] as CFArray
let locations: [CGFloat] = [0, 0.79, 1]
if let gradient = CGGradient(
colorsSpace: CGColorSpaceCreateDeviceRGB(),
colors: colors,
locations: locations
) {
dataSet.fill = LinearGradientFill(gradient: gradient, angle: 270)
}
}
}

DataColor is an abstraction above UIColor since we are planning to get chart data from the view model and don’t want UIKit to leak into view model layer.

/// Abstraction above UIColor
enum DataColor {
case first
case second
case third

var color: UIColor {
switch self {
case .first: return UIColor(red: 56/255, green: 58/255, blue: 209/255, alpha: 1)
case .second: return UIColor(red: 235/255, green: 113/255, blue: 52/255, alpha: 1)
case .third: return UIColor(red: 52/255, green: 235/255, blue: 143/255, alpha: 1)
}
}
}

Let’s build and run to see what we’ve got after these tweaks:

Great, we handled everything except for touches. Now the chart draws an orange crosshair at the nearest value. Now let’s see what can be changed with no effort and what has to be implemented by us.

Touch Handling

Let’s go back to the dataset factory and add these settings:

// selected value display settings
dataSet.drawHorizontalHighlightIndicatorEnabled = false // leave only vertical line
dataSet.highlightLineWidth = 2 // vertical line width
dataSet.highlightColor = colorAsset.color // vertical line color

Now our chart should respond to touches like this:

The rest is on us to implement:

  • selected value circle
  • bubble with additional info attributes (date, value, color legend).

Here we have two features of the chart area to help us. First, it has a delegate, and second — it can display markers. So our next step will be creating a custom marker inheriting from MarkerView base class:

/// Marker for highlighting selected value on graph
final class CircleMarker: MarkerView {
override func draw(context: CGContext, point: CGPoint) {
super.draw(context: context, point: point)
context.setFillColor(UIColor.white.cgColor)
context.setStrokeColor(UIColor.blue.cgColor)
context.setLineWidth(2)

let radius: CGFloat = 8
let rectangle = CGRect(
x: point.x - radius,
y: point.y - radius,
width: radius * 2,
height: radius * 2
)
context.addEllipse(in: rectangle)
context.drawPath(using: .fillStroke)
}
}

As for the info bubble, let’s simply make a custom view, its implementation is not really significant for chart logic, you may find an implementation example in the final project (ChartInfoBubbleView). We notice from the design mockup that it must contain date, color legend, and the Y value.

NB: For multiple lines cases the legend and value must correspond to each line, in order for this to work the datasets need to be normalized to the same dimension on X. In other words, we don’t have a function to give X and get Y for a random place, we have predefined sets of discrete values and therefore those X’s must match.

Let’s then create a wrapper around the chart area that will store the area itself, marker, and info bubble.

/// Chart view
final class ChartView: UIView {
private let chart = LineChartView()
private let circleMarker = CircleMarker()
private let infoBubble = ChartInfoBubbleView()

var viewModel: ChartViewModelProtocol? {
didSet {
updateChartDatasets()
}
}

override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
}

Now in the delegate, we shall add conformance to ChartViewDelegate protocol. We have a particular interest in two methods:

  • func chartValueSelected(_ chartView: ChartViewBase, entry: ChartDataEntry, highlight: Highlight) — here we obtain the dataset entry, its data will be used in the info bubble, its highlight property will provide point coordinates on the graph. An important detail is to use the highlighted properties .xPx and .yPx but not .x and .y which might sound a bit confusing at first but this is the way it works;
  • func chartValueNothingSelected(_ chartView: ChartViewBase) — we shall hide our markers here.

Returning to the chart area settings we’ll add markers support.

// markers
chart.drawMarkers = true
circleMarker.chartView = chart
chart.marker = circleMarker

When we have this done the following logic works: the user’s touch is handled by the delegate method, and we show a circle marker and an info bubble. When tapping outside the line but within the chart area the marker and bubble are hidden.

In order to avoid bubbles slipping outside the chart area we can add a pretty straightforward logic that checks if the bubble view fits within the chart area or if there must be a horizontal or vertical adjustment made. An example of this logic can be found in the final project.

Well done, now we have the feature ready as per the product requirements:

The eagle-eyed reader might have noticed that we started with points with XY coordinates, where X is just the element number in dataset and Y is the value, so where did the data come from? It’s quite simple, ChartDataEntry has several initializers, one of which is @objc public convenience init(x: Double, y: Double, data: Any?) where data is any additional attribute we want to include, so we added our calendar date there and got it back in the delegate’s touch-handling callback.

Final Thoughts

The Charts library has wide customization options keeping consistency between iOS and Android platforms which is often required by product owners. We have proven it on a simple example going all the way from default visualization to more or less real-world customized implementation.

Where to go from here? Try thinking about implementing two, three, and N lines in one chart area and what challenges might arise in this regard.

--

--