Shortly after iOS 13 was released I discovered a new accessibility feature of the native stocks app:
The app offers to use to rotor to select "data comprehension". After doing so the user can select between various options to get a verbal description of the chart, for example the trend or minimum and maximum values. One option that stands out is "play audiograph".
If you haven't tried it yourself, here is a short video demonstrating it:
Using sound to describe a chart for accessibility reasons seems to be a great idea and because Apple has put so much effort into it, it seems like people really benefit from that!
Charts most often describe a time-value relationship that is otherwise really hard to put into words. Why should disabled people be excluded from that?
Unfortunately there is no public API from Apple that enables developers to implement it in their apps (yet). What's more, there seems to be no open source library that fills the gap (yet).
Audiograph can play the content that is visually displayed by the chart. The developer just needs to invoke the play method, passing an array of CGPoint that is already used to draw the UI.
All logic that's left is to decide when to play the Audiograph.
In order to help for that, Audiograph provides a pre-configured UIAccessibilityCustomAction for the developer to add to the chart view.
In my early attempt in learning SwiftUI I stumbled upon a fairly common problem: loading data from disk and saving it after updates were made.
It seems simple at first but when thinking about I can come up with a number of hurdles one has to take:
Loading data should be performed in the background. Thus the data is not immediately available to us. It can even come from a database request or via network.
The UI should reload as soon as data is available.
Changes the user has made must be saved so that the model layer is always persisted.
However, the file system should not be asked to write a file every time the user interacts. Give them a little time until writes are made. That's called debouncing.
Let's Go!
In order to create a sample project we start by defining our very basic model:
struct Model: Codable {
var content: [Element]
}
struct Element: Codable, Identifiable {
let id = UUID()
let name: String
}
Now it is a great time to care about a class that saves an object to disk and is able to read it from there. Let's call it PersistanceController. When loading data, it calls a completion handler to deliver that data:
class PersistanceController<T: Codable> {
...
func loadData(for filename: String, fileExtension: String, defaultValue: T, completion: @escaping ((T) -> Void)) {
let fileManager = FileManager()
let dataURL = pathToSavedDataInSharedContainer(forName: filename, fileExtension: fileExtension)
if fileManager.fileExists(atPath: dataURL.path) {
let decoder = PropertyListDecoder()
queue.async(flags: .barrier) {
guard let data = try? Data(contentsOf: dataURL), let decoded = try? decoder.decode(T.self, from: data) else {
DispatchQueue.main.async {
completion(defaultValue)
}
return
}
DispatchQueue.main.async {
completion(decoded)
}
}
}
else {
completion(defaultValue)
}
}
}
For writing data we define our function as follows:
private func save(_ object: T, fileName: String, fileExtension: String = "plist", completionHandler: (() -> Void)? ) {
let fileManager = FileManager()
let fileURL = pathToSavedDataInSharedContainer(forName: fileName, fileExtension: fileExtension)
if !fileManager.fileExists(atPath: fileURL.path) {
// Directory needs to be created first.
try? fileManager.createDirectory(atPath: applicationDataDirectory.path, withIntermediateDirectories: true, attributes: nil)
}
let encoder = PropertyListEncoder()
queue.async(flags: .barrier) {
defer {
DispatchQueue.main.async(execute: completionHandler ?? {})
}
guard let data = try? encoder.encode(object) else {
return
}
try? data.write(to: fileURL, options: [.atomic])
}
}
Those functions can potentially do whetever they want them to do as long as they save and restore the data.
To make our lives easier when it comes to storing updated data we introduce a Subscriber. New values are passed in through whatever publisher this subscriber is connected to (we'll cover that later). Errors are not handled in this simple example.
// Should be used to trigger a write request.
var writeSubscriber: Subscribers.Sink<T, Never>!
private func setupSubscriber() {
writeSubscriber = Subscribers.Sink(receiveCompletion: nil, receiveValue: { (value) in
self.save(value, fileName: self.filename) {
// No error handling here.
print("Saved")
}
})
}
Now we have
a way to load data from disk asynchronously. The data is received in a completion block.
a way to save data by passing it to a subscriber.
We continue making our lives easier by introducing a PropertyWrapper that encapsulates all of the logic above:
@propertyWrapper
class Storing<Value: Codable>: ObservableObject {
private var value: Value?
private let persistanceController: PersistanceController<Value>
private let filename: String
private let defaultValue: Value
let objectWillChange = PassthroughSubject<Value, Never>()
private let writePublisher = PassthroughSubject<Value, Never>()
init(filename: String, defaultValue: Value) {
self.filename = filename
self.defaultValue = defaultValue
persistanceController = PersistanceController(filename: filename)
setupPublishers()
persistanceController.loadData(for: filename, fileExtension: "plist", defaultValue: defaultValue) { loaded in
print("Loaded")
// Foreward the new data into the publisher
self.objectWillChange.send(loaded)
self.value = loaded
}
}
private func setupPublishers() {
// Connect write publisher to persistance controller's receiving subscriber that will write the data for us
writePublisher.debounce(for: .seconds(1.5), scheduler: RunLoop.main).receive(subscriber: persistanceController.writeSubscriber)
}
var wrappedValue: Value {
get {
value ?? defaultValue
}
set {
objectWillChange.send(newValue)
value = newValue
writePublisher.send(newValue)
}
}
}
The property wrapper is initialized with a filename and a default value. As soon as loading the file is completed the publisher will pass the new value along to its subscribers. When the wrapped value is mutated, the writePublisher is asked to send the new value.
The function setupPublishers connects the publisher with the writeSubscriber of the persistance controller. We also include a debounce so that not every mutation is written to disk immediately. Upon mutation it waits 1.5 seconds until the file system should process the new data.
Now we can
use the property wrapper to store and load any Codable.
use the wrapped value either directly or use the publisher to add a subscriber to it. Then we'll be notified about events such as mutations or the initial loading.
Now to the view
In this simple example we just want display the content in a list. Before we get to the declaration of the view we need to instantiate our model.
SwiftUI is still in beta so unfortunately we can not use the property wrapper directly as source for our list like this
// Does not compile for unkown reason:
@Storing(filename: "TrackList", defaultValue: Model(content: [])) var model: Model
also wrapping it inside another wrapper does not work:
@ObjectBinding @Storing(filename: "TrackList", defaultValue: Model(content: [])) var model: Model
Therefore we fall back to creating a model wrapper class (it needs to be a class since @ObjectBinding demands so). That wrapper receives changes from @Storing's publisher and forewards it to its own publisher:
class ModelWrapper: ObservableObject {
@Storing(filename: "Animals", defaultValue: Model(content: [])) var model: Model
let objectWillChange = PassthroughSubject<Model, Never>()
init() {
let intermediateSubsriber = Subscribers.Sink<Model, Never>(receiveCompletion: { (completion) in
}) { (value) in
self.objectWillChange.send(value)
}
_model.objectWillChange.receive(subscriber: intermediateSubsriber)
}
}
In doing so we can finally wire up the model layer with our view:
struct ContentView : View {
@ObservedObject var modelWrapper: ModelWrapper = .init()
var body: some View {
NavigationView {
List(modelWrapper.model.content) { element in
Text("\(element.name)")
}
.navigationBarTitle(Text("Animals"))
.navigationBarItems(leading: Button(action: {
// We can access the property directly, everything else is done for us automatically.
self.modelWrapper.model.content.append( Element.generateMock())
}, label: {
Text("Add more")
})
, trailing: Button(action: {
self.modelWrapper.model.content.removeAll()
}, label: {
Text("Remove All")
}))
}
}
}
Now our list is populated by data that is loaded from disk asynchronously. When we press a button the model gets updated and the new data is written do disk automatically. When the user presses one buttons frequently it debounces the writes until there is a short brake.
The project still has a lot room for improvements but I wanted to make it simple.
If you want to take a look at the very basic project we build, you can download it here.
If you have any feedback or know how to avoid that extra ModelWrapperplease let me know! 😊
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 CGPaths 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:
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:
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).
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:
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 a CGPath 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 and CoreAnimation 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 🙃