Drawing Charts in iOS Before SwiftUI | by Gennady Stepanov | Nov, 2022

Implementation of a line chart using UIKit

In this article, we will look at how interactive linear charts could have been implemented in iOS before SwiftUI Charts was introduced in 2022.

Let’s imagine a real-world scenario that a Product Owner requires your team to create a minimal linear chart that:

  • Show off a smooth curve with a gradient;
  • interactively display data on a graph, responding to user gestures;
  • The current value must be marked with a vertical line and displayed in a bubble with additional information;
  • It would be nice to support multiple charts in one area;
  • There should be minimal difference between iOS and Android Final Appearance.

It’s a fairly 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 stock, banking or fitness tracking apps.

Every product development starts with research and it’s easy to find that there’s no need to reinvent the wheel – there’s already a library that fits our needs. It was originally written for Android (mpandroid charts By Philip Jahoda) and its iOS version is called Chart (written by Daniel Cohen Guindy,

Among its advantages I would like to note the following:

  • Supports multiple chart types;
  • There are lots of customization options available;
  • It is compatible with the Android version;
  • 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 convenient in a separate project. Let’s create an empty project and install Chart with your favorite dependency manager. I like SPM, all available options are listed on chart github page,

I will not describe the initial process of creating a project. If you want to grab the final version of the project and work with the tutorial, it’s available my github page,

First things first, let’s make a very basic linear chart. This will help us to understand the overall logic of the Charts library and most importantly what needs to be changed to get the desired result.

add the following code to viewDidLoad() of our view controller (don’t forget this import Charts in the 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)

To understand the logic of the chart I suggest going in the reverse direction starting from the bottom of this code snippet.

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

Also, the chart data type must correspond to the view type LineChartData, This data type has a constructor that accepts some dataset (at this stage we can see that there is also a constructor that accepts an array of datasets, this is the key to implementing multiple chars in a field, As we remember the product owner asked to try and support this feature).

Instead the dataset type must correspond to the linear chart type LineChartDataSet, which is an abstraction over an array of data entries (eventually being points in the chart area). Each entry has X and Y coordinates, which are fairly simple.

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

Oh no, it doesn’t look exactly like what the business wants us to implement. Let’s plan the change again:

  • change graph line color
  • Extract points from graphs and their annotations
  • Add Smoothing to Our Curve
  • add gradient under curve
  • remove axis annotation
  • remove legend
  • Remove grid.

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

Chart area setting:

// 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 a step further and create a dataset factory to support multiple chart 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 above is an abstraction UIColor Since we are planning to get chart data from view model and don’t want UIKit To leak into the 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 see what we got after these changes:

Great, we handled everything except the touch. Now the chart draws an orange crosshair on the nearest value. Now let’s see what can be changed without any effort and what we have to implement.

let’s go back 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 up to us to implement:

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

To help us here are two characteristics of the chart area. Firstly, it has a representative, and secondly – it can display markers. So our next step would be to create a custom marker that is inherited 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)

For info bubbles, let’s just create a custom view, its implementation is not really important for chart logic, you can find an implementation example in the final project (ChartInfoBubbleView) From the design mockup we see that it should contain the date, the color legend and the Y value.

nb, For multiple row cases the legend and value must correspond to each row, this requires the dataset to be normalized to have the same dimension on X. In other words, we don’t have a function to give X and get Y. For a random location, we have a predefined set of discrete values ​​and so those X must match.

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

/// 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 will add the analogy ChartViewDelegate Make a draft We are of particular interest in two methods:

  • func chartValueSelected(_ chartView: ChartViewBase, entry: ChartDataEntry, highlight: Highlight) – Here we get the dataset entry, its data will be used in the information bubble, its highlight property will provide the point coordinates on the graph. An important detail is the use of highlighted properties. .xPx And .yPx but no .x And .y Which may seem a bit confusing at first but this is how it works;
  • func chartValueNothingSelected(_ chartView: ChartViewBase) – We will hide our markers here.

Returning to the chart area setting, we will add marker support.

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

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

To avoid bubbles sliding outside the chart area we can add a very straightforward logic that checks whether the bubble view fits within the chart area and whether horizontal or vertical adjustments should be made. An example of this logic can be found in the final project.

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

An eagle-eyed reader may have noticed that we started with points with XY coordinates, where X is just the element number in the dataset and Y is the value, so where did the data come from? It’s quite simple, ChartDataEntry There are 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 in there and passed it back in the delegate’s touch-handling callback.

The Charts library has extensive customization options that are often required by product owners to maintain consistency between the iOS and Android platforms. We’ve proven this on a simple example that goes from default visualizations to more or less real-world optimized implementations.

Where to go from here? Try to think about applying two, three and n lines to a chart area and what challenges might arise in this regard.

Leave a Reply