Recently I stumbled upon a very nice representation of data over time: the charts from Trade Republic. They really stand out by their minimalism and smooth animation. Because I liked it so much I wanted to find out how they did it and chose to replicate the interface.
I came real close and in this post I want to share how I archieved it.
Analysis 🧐
The chart smoothly transitions between data from one day (1T) to five days (5T) to one month (1M) to half a year (6M) to a year (1J) to five years (5Y). Whilst doing so, a dashed baseline is updated to always mark the first entry of the selected range. When the rightmost point is below that baseline the chart is colored in red. It becomes green otherwise.
When you follow a point on the graph you see that some points just move up or down whilst other also move horizontally.
Also please notice the timing of the animation. It moves not linearly but has a quite extensive easing on both ends.
Here, I zoomed to the transition from a month to a year. At first I assumed that this transition would move the current month to the right end of the year while scaling it down to become a twelth of the width. To my surprise I recognized that the transform moves to the opposite direction and that still confuses me. Let's see if I can fix that in my replicated version 🙃
My intuition says that a CABAsicAnimation
between two CGPath
s combined with a custom timing function might be the key to this transition.
Getting mock data 💾
A nice demo can be done by using random data and that is where I actually started. Soon I found out that it becomes much more appealing when real data is involved so I downloaded historically data about the DAX from Yahoo. The CSV can then be parsed like:
struct DataStore {
private let rawData: [StockData]
init(contentsOfCSV input: String) {
let lines = input.components(separatedBy: .newlines)
var resut = [StockData]()
for line in lines {
let components = line.components(separatedBy: ",")
// StockData simply contains the fields defined by the CSV. We actually only need `Close`.
if let data = StockData(from: components) {
resut.append(data)
}
}
// Reverse the input, more on that later.
rawData = resut.reversed()
}
}
After reading the historical data we need to split it in order to come up with defined ranges of time (last five days, last month, last six months, last year and last five years):
lazy var month: [StockData] = {
var result = [StockData]()
guard let firstDate = rawData.first?.date else { return result }
guard let deadline = Calendar.current.date(byAdding: .month, value: -1, to: firstDate)?.endOfDay else { return result }
// Get every element whose date is before or on the same date as the deadline.
result = rawData.filter( { $0.date > deadline } )
return result
}()
// Make use of a handy extension:
extension Date {
var startOfDay: Date {
return Calendar.current.startOfDay(for: self)
}
var endOfDay: Date {
var components = DateComponents()
components.day = 1
components.second = -1
return Calendar.current.date(byAdding: components, to: startOfDay)!
}
}
Drawing a chart 📈
Drawing a chart is mainly calculating points in respect to the frame size of a view. Once we have scaled the points so that the minimum and maximum all fit, we can combine the points with a path and a chart will evolve. So let's start with a CAShapeLayer
where we are going to draw the chart on:
class ChartView: UIView {
private let graphLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.name = "GraphLayer"
layer.strokeColor = UIColor.red.cgColor
layer.fillColor = UIColor.clear.cgColor
layer.lineWidth = 1
layer.lineJoin = .bevel
layer.isGeometryFlipped = true
return layer
}()
override func layoutSubviews() {
super.layoutSubviews()
graphLayer.frame = bounds
}
}
The shape layer is added as sublayer during initialization. isGeometryFlipped
sets the origin of the layers coordinate system in the bottom left corner – exactly where the Cartesian Coordinate System has its origin located at.
Now the ViewController must chose what data to display. The view that will contain the chart, calledChartView
, only knows how to handle points but the chart should show dates on the x-axsis. Since the axis is not labled in any way it is sufficient to convert the dates into a number, as long as their relative distance to one another is preserved. Using the timeIntervalSince1970
makes a great candidate for that:
let newData: [StockData] = model.month
// Store the currently selected data so it can be used later.
model.currentDataSet = newData
let newPoints: [CGPoint] = newData.map { (data) -> CGPoint in
let xComponent = data.date.timeIntervalSince1970
return CGPoint(x: xComponent, y: data.close)
}
chartView.transform(to: newPoints)
Now we have our ChartView
in place, added it to the ViewController's view and derived points to draw a line between. The actual drawing consists of two steps:
- Scale the points so that the minimum x-value is on the left origin, the maximum x value is on the right edge of the view and the corresponding y values are also scaled accordingly to fit the view's bounds.
- Draw a line between the scaled points.
Step one is done like:
private func scale(_ points: [CGPoint], for size: CGSize) -> [CGPoint] {
let xValues = points.map( { $0.x } )
let yValues = points.map( { $0.y } )
let max = (x: xValues.max() ?? 0, y: yValues.max() ?? 0)
let min = (x: xValues.min() ?? 0, y: yValues.min() ?? 0)
let scaleFactorX: CGFloat
if max.x - min.x == 0 {
scaleFactorX = 0
} else {
scaleFactorX = size.width / (max.x - min.x)
}
let scaleFactorY: CGFloat
if max.y - min.y == 0 {
scaleFactorY = 0
} else {
scaleFactorY = size.height / (max.y - min.y)
}
let scaledPoints = points.map { point -> CGPoint in
let scaledX = scaleFactorX * (point.x - min.x)
let scaledY = scaleFactorY * (point.y - min.y)
return CGPoint(x: scaledX, y: scaledY)
}
return scaledPoints
}
And thus the entire drawing function emerges to:
func transform(to newPoints: [CGPoint]) {
guard newPoints.count > 0 else { return }
// Step 1: Scale according to current frame:
let newPath = CGMutablePath()
let scaledPoints = scale(newPoints, for: frame.size)
// Step 2: connect scaled points
// Notice the the first point correlates to the most recent point which is drawn on the right!
newPath.move(to: scaledPoints.first!)
for point in scaledPoints.dropFirst() {
newPath.addLine(to: point)
}
graphLayer.path = newPath
// Store points for later usage:
points = newPoints
self.scaledPoints = scaledPoints
}
With that in place we get a nice chart that scales in respect to the minimum and maximum data points:
Baseline
We need another layer for the baseline. In order to achieve a dashed line, we use a CAShapeLayer
and set a path on it:
private let baselineLayer: CAShapeLayer = {
let layer = CAShapeLayer()
layer.backgroundColor = UIColor.clear.cgColor
layer.name = "BaselineOfGraph"
layer.opacity = 1
layer.strokeColor = UIColor.lightGray.cgColor
layer.lineWidth = 1.0
layer.lineJoin = .round
layer.lineDashPattern = [2, 3]
return layer
}()
Now the baseline is configured but a frame is not set, yet. We create a new method that is called on layoutSubviews()
to do exactly that. Additionally, it creates the path that the baselineLayer
is about to draw based on the current frame size and the leftmost datapoint (which defines the position of the baseline).
private func updateBaselineLayer() {
baselineLayer.removeFromSuperlayer()
if let leftmostPoint = scaledPoints.last {
baselineLayer.frame = CGRect(x: 0, y: leftmostPoint.y, width: bounds.width, height: 2.0)
} else {
baselineLayer.frame = CGRect(x: 0, y: 0, width: bounds.width, height: 2.0)
}
let baselinePath = CGMutablePath()
baselinePath.move(to: CGPoint(x: 0, y: baselineLayer.bounds.midY))
baselinePath.addLine(to: CGPoint(x: baselineLayer.bounds.maxX, y: baselineLayer.bounds.midY))
baselineLayer.path = baselinePath
graphLayer.addSublayer(baselineLayer)
}
Animate transitions 📉🔜📈
Timing function
As we can see in the videos, the animation does not use a linear timing curve. It rather makes use of heavy easing on both start and end. To come close to that we play around with timing curves until we find something that fits.
private let timingFunction = CAMediaTimingFunction(controlPoints: 0.64, 0, 0, 1)
Two animations need to take place now: the path animation that transforms the graph and an animation that moves the baseline. Both share the same timing function so that they perform in sync.
func transform(to newPoints: [CGPoint]) {
// The same as above:
guard newPoints.count > 0 else { return }
let newPath = CGMutablePath()
let scaledPoints = scale(newPoints, for: frame.size)
newPath.move(to: scaledPoints.first!)
for point in scaledPoints.dropFirst() {
newPath.addLine(to: point)
}
let newBaselinePosition = CGPoint(x: baselineLayer.position.x, y: scaledPoints.last!.y)
// New part:
let pathAnimation = CABasicAnimation(keyPath: #keyPath(CAShapeLayer.path))
pathAnimation.fromValue = graphLayer.presentation()?.path
pathAnimation.toValue = newPath
pathAnimation.duration = 1.2
pathAnimation.timingFunction = timingFunction
let baselineAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.position))
baselineAnimation.fromValue = baselineLayer.presentation()?.position
baselineAnimation.toValue = newBaselinePosition
baselineAnimation.timingFunction = timingFunction
baselineAnimation.duration = 1.2
graphLayer.add(pathAnimation, forKey: "PathAnimation")
baselineLayer.add(baselineAnimation, forKey: "BaselineAnimation")
// Set the new properties without implicit animations:
CATransaction.begin()
CATransaction.setDisableActions(true)
baselineLayer.position = newBaselinePosition
graphLayer.path = newPath
CATransaction.commit()
// Bookkeeping:
points = newPoints
self.scaledPoints = scaledPoints
}
On both layers we use the presentation layer in order to accomodate for a user who changes mind while an animation is ongoing. After both animations are added to their layers, the final values need to be set. We do so by disabling CoreAnimation's implicit animations since they would interfere with the desired ones.
The right feeling of this animation depends on two things:
- A smaller segment must transition to a larger segment when the displayed interval is increased.
- The larger segment must zoom in when the time interval is reduced.
This is where the reversed elements from above come into play! We draw the points from right to left. When the number of points increases the new points are added on the left (e.g. the user selected a larger interval like from one month to a year). The points that are alredy contained will morph to their new positions (on the right side) whilst new points come in from the left. At any time the user wants to see the most recent data which happened to be positioned on the right (e.g. the last five days, the last month, the last year).
When the graph is about to contain less elements than it did before, elements on the left are removed which results in a zoom-effect.
Making it interactive 👉👆
In this video you can see how the user is able to interact with the chart view. When the thumb contacts the screen a little highlight indicator appears. It can be dragged around and snapps to the next available data point. That snapping is supported by the TapticEngine which lets the user feel haptic feedback.
This interaction feature basically consists of three parts:
- Showing a selection indicator when user begins the gesture and remove it when the it ends.
- Moving the indicator corresponding to the thumb's movement, snapping to the nearest data point.
- Giving haptic feedback at every snap and update the UI with the price at the given point.
In order to implement this we introduce a new sublayer:
private var touchindicatorLayer: CALayer = {
let layer = CALayer()
layer.backgroundColor = UIColor.lightGray.cgColor
layer.opacity = 0
return layer
}()
To play along with the users gesture we can override the following methods to get a good starting point:
func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {...}
func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {...}
func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {...}
In touchesBegan
we first get the location of the touch and then use a helper function that calculates the point next to that location.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
guard !points.isEmpty, !scaledPoints.isEmpty else { return }
guard let location = touches.first?.location(in: self) else { return }
// Store where the finger hit the screen to calculate relative movement
lastTouchLocation = location
guard let selected = pointNextTo(touch: location) else { return }
self.selectedElement = selected
showSelectedPointHandle()
moveSelectedPointHandle()
// The delegate updates the UI.
delegate?.highlightDidChanged(to: selected.indexInData)
}
Other helper methods are responsible for showing and moving the selection indicator. Showing and hiding make also use of implicit animations. Moving should not be animated so they are turned off:
private func showSelectedPointHandle() {
touchindicatorLayer.opacity = 1
}
private func moveSelectedPointHandle() {
guard let selectedPoint = selectedElement else { return }
// Disable implicit animations because moving should exactly follow the users thumb.
CATransaction.begin()
CATransaction.setDisableActions(true)
touchindicatorLayer.position.x = selectedPoint.point.x
CATransaction.commit()
}
private func hideSelectedPointHandle() {
touchindicatorLayer.opacity = 0
}
The biggest helper function is the one that calculates the nearest point to a given location. It returns not only the nearest point but also its index. A simple iteration over the contained points is enough for that:
private func pointNextTo(touch: CGPoint) -> (point: CGPoint, indexInData: Int)? {
guard !points.isEmpty, !scaledPoints.isEmpty else { return nil }
var pointNextToTouchLocation: CGPoint = .zero
var bestDistancePointToLocationOfTouch: CGFloat = .greatestFiniteMagnitude
var indexOfFoundPoint: Int = 0
for (index, displayedPoint) in scaledPoints.enumerated() {
let distance = abs(displayedPoint.x - touch.x)
if distance < bestDistancePointToLocationOfTouch {
bestDistancePointToLocationOfTouch = distance
pointNextToTouchLocation = displayedPoint
indexOfFoundPoint = index
}
}
return (point: pointNextToTouchLocation, indexInData: indexOfFoundPoint)
}
Even though this function is simple, it leaves out the fact that our array of scaled points is already sorted. Finding a minimum is way faster in this cases. Therefore we leverage the fact that once a minimum is found (a.k.a. local minimum) and the next point is further apart than the local minimum, we have found the global minimum. The improved version looks like this:
private func pointNextTo(touch: CGPoint) -> (point: CGPoint, indexInData: Int)? {
guard !points.isEmpty, !scaledPoints.isEmpty else { return nil }
var pointNextToTouchLocation: CGPoint = .zero
var bestDistancePointToLocationOfTouch: CGFloat?
var indexOfFoundPoint: Int = 0
for (index, displayedPoint) in scaledPoints.enumerated() {
let distance = abs(displayedPoint.x - touch.x)
if bestDistancePointToLocationOfTouch != nil && distance > bestDistancePointToLocationOfTouch! {
// scaledPoints is sorted regarding their x value. So a local minimum is also a global minimum.
// We can break the loop when a minimum was found (bestDistancePointToLocationOfTouch != nil) and the next distance is greater than this.
break
}
if distance < bestDistancePointToLocationOfTouch ?? .greatestFiniteMagnitude {
bestDistancePointToLocationOfTouch = distance
pointNextToTouchLocation = displayedPoint
indexOfFoundPoint = index
}
}
return (point: pointNextToTouchLocation, indexInData: indexOfFoundPoint)
}
At this point we show the highlight indicator and have helper methods in place to move and hide it. Let's put them into action:
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesMoved(touches, with: event)
guard let location = touches.first?.location(in: self) else { return }
guard let selected = pointNextTo(touch: location) else { return }
// Move the handle, store new data and call delegate functions if changed.
if selectedElement?.indexInData != selected.indexInData {
selectedElement = selected
moveSelectedPointHandle()
delegate?.highlightDidChanged(to: selected.indexInData)
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
selectedElement = nil
hideSelectedPointHandle()
delegate?.hightlightDidEnd()
}
Another possible optimization is to only calculate the nearest point once the touch gesture has moved a significant amount of points. You'll find an implementation for that in the corresponding GitHub project!
We just saw that the ChartView
calls its delegate to inform it about highlighted points. The ViewController
sits on the receiving side of the calls. It updates the label above the chart view to match the selected element. In this prototype, the ChartView
and the ViewController
communicate by passing the index of the highlighted element. That is not the most elegant solution and might also be improved.
Here you can see the implementation of said delegate methods:
extension ViewController: ChartViewDelegate {
func highlightDidChanged(to elementAtIndex: Int) {
guard elementAtIndex < model.currentDataSet.count else { return }
let selectedElement = model.currentDataSet[elementAtIndex]
let leftElement = model.currentDataSet.last!
// Color is chosen relative to the first element which sets the baseline.
let colorOfPriceLabel: UIColor = leftElement.close > selectedElement.close ? colorMinus : colorPlus
updatePriceLabel(to: String.init(format: "%.2f", selectedElement.close), in: colorOfPriceLabel)
// Only give feedback when not too many points are displayed. That is the case in the 1Y and 5Y state.
if segmentedControl.selectedSegmentIndex != 4 && segmentedControl.selectedSegmentIndex != 5 {
feedbackGenerator.selectionChanged()
}
}
private func updatePriceLabel(to newText: String, in color: UIColor) {
UIView.transition(with: priceLabel, duration: 0.2, options: [.beginFromCurrentState, .transitionCrossDissolve], animations: {
self.priceLabel.text = newText
self.priceLabel.textColor = color
}, completion: nil)
}
}
Aaaaaand with that we have finished our re-creation of that nice chart view! 🥳
The main takeaways are:
- A chart is nothing more than scaled points connected by a line.
- Using a
CABasicAnimation
to morph aCGPath
is very smooth. By doing so almost the entire animation comes for free. - The animation benefits a lot from start drawing the chart from the right combined with proportional numbers of points.
- Interaction can be broken down into three steps. The most complicated part is to get the point next to the users finger and even that is straight foreward.
- Accessibility is nothing that takes care of itself! This entire demo is missing accessibility adjustments in order to focus on the main visuals.
UIKit
andCoreAnimation
are just awsome frameworks 💯
The entire project is available on my site at GitHub!
As always I would be happy to hear what you think 🙃