iOS, Objective-C, Swift, Design and Whatever Comes in Mind

Tear-Down: Trade Republic Charts

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 🧐

Video of trade republic interface with smooth animation between graphs.

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.

Video that focuses on the transition between one month and a year.

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:

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:

  1. 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.
  2. 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:

Video of the basic charts.

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:

  1. A smaller segment must transition to a larger segment when the displayed interval is increased.
  2. 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.

Video of my version of animated charts.

Making it interactive 👉👆

Video of the trade republic app whilst the user interacts with it.

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:

  1. Showing a selection indicator when user begins the gesture and remove it when the it ends.
  2. Moving the indicator corresponding to the thumb's movement, snapping to the nearest data point.
  3. 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! 🥳

My re-created version of the chart containing user interaction and animations.

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 🙃

"Tear-Down: Trade Republic Charts".

On the Limitations of CharacterSet

Recently, I wanted to build a sanitizer for a text input. The text input had rules that excluded come characters. When an invalid character is typed the user should be notified why it does not appear on screen. However, pasting a text from clipboard is also an option so the sanitizer needs to handle multiple invalid characters at once, too.

“Great, there is the CaracterSet API, let’s do this!”

By stating the problem we start by defining a function that has a string as input and needs to produce a sanitized string and the invalid characters that were removed:

func sanitize(input: String) -> (sanitized: String, removed: String?)

Additionally, the set of invalid characters needs to be defined:

let forbiddenCharacters = CharacterSet(charactersIn: "<>.;*")

The input will not be a gigantic string but we want to keep an eye on performance as the function needs to be called on every character the user types. The best we can hope for is a time complexity of O(n), n being the length of the input string.

Let’s start with the most simple approach: filter through the characters of the input string and figure out which characters not belong in there. Those parts need to be memorized in order to tell the user later why the input differs from their expectation. The individual characters need to be concatenated into a single string, which is done in line 9.

func sanitize1(input: String) -> (removed: String?, sanitized: String) {
    var removed: String?
    let sanitized = input.filter { (character) -> Bool in //O(n)
        if forbiddenCharacterSet.contains(character) { //O(1) (Set containment is constant)

            // Store the removed character
            let forbiddenCharacterAsString = String(character)
            removed = (removed ?? "") // Inizializing it only when needed
            removed?.append(forbiddenCharacterAsString)
            return false
        }
        return true
    }
    return (removed, sanitized)
    // O(n)
}

The time complexity is O(n) since it loops through all the characters and checks if thy are contained in the set. The set containment operation is constant O(1) which gives us the desired complexity of O(n). Yay! 🎉

We even can do better in some scenarios by using set operations! Create a set form the input string and calculate the intersection with the set of forbidden characters. It would be neat if it was possible to get characters out of a character set.

Unfortunately, it is a one way street so there is no way to figure out what characters are contained in the intersection. Another filtering is needed. That might break our performance win as we see.

func sanitize2(input: String) -> (removed: String?, sanitized: String) {
    var removed: String?
    var sanitized = input

    let inputSet = CharacterSet(charactersIn: input) // O(n)
    let intersection = inputSet.intersection(forbiddenCharacterSet) // O(min(n,k))
    if !intersection.isEmpty {
        // Input contains invalid characters, only perform the filter now
        sanitized = input.filter { (character) -> Bool in //O(n)
            if intersection.contains(character) { //O(1) (Set containment is constant)
                ...
            }
            return true
        }
    }
    return (removed, sanitized)
    // Filter only performed when needed, but checking for containment is not possible.
    // ALSO: Getting characters OUT of a CharacterSet is not possible
    // O(n) + O(min(n,k)), when it contains invalid characters, O(min(n,k)) if not
}

Time complexity wise, creating a CharacterSet from a string takes approximately O(n) and may be even cheaper. Calculating the intersection of a set is O(min(k, n)) (k being the amount of forbidden characters). In the most cases k is smaller than n.

A CharacterSet is not a normal set so it is unfortunately not possible to create an array from it.
The only way to figure out what elements causes the intersection to be not empty is to filter again. Compared to function #1 it is only done when we have a reason to do so.

Except:

Cannot convert value of type 'String.Element' (aka 'Character') to expected argument type ’Unicode.Scalar'. As it turns out, a CharacterSet is not simply a set of characters Set<Character>! 😖

It is not possible to check if a given character belongs to the CharacterSet! It is also not possible to retrieve any character out of a CharacterSet or even to convert it back to a string!

Backup strategy:

If CharacterSet does not provide the right APIs for us, we could drop it entirely. We store the forbidden characters in a string let forbiddenCharacters = "<>.;*". Following approach #1 we use the filter function to check if any characters need to be removed form the input. Instead of having the nice constant O(1) lookup operation of a set we need to drop down to a string search operation. That takes O(k) time.

let forbiddenCharacters = "<>.;*"
func sanitize3(input: String) -> (removed: String?, sanitized: String) {
    var removed: String?
    // Using string containment now.
    let sanitized = input.filter { (character) -> Bool in //O(n)

        if forbiddenString.contains(character) { // O(k), not a set operation
            // Store the removed character
            let forbiddenCharacterAsString = String(character)
            removed = (removed ?? "") // Inizializing it only when needed
            removed?.append(forbiddenCharacterAsString)
            return false
        }
        return true
    }

    return (removed, sanitized)
    // O(k*n) in total
}

After filtering we still need to combine the removed characters to a string. Thus, the third approach is O(kn) since for every character in the filter operation the forbidden characters need to be scanned.

But wait, there’s more. We can improve our small algorithm! The String-API gives us one anchor to use our not really beloved CharacterSet: string.rangeOfCharacter(from: CharacterSet). If any character of the CharacterSet is found in the string, its range is returned. However, it only finds the first match. So we need to build a loop to find every occurrence:

func sanitize4(input: String) -> (removed: String?, sanitized: String) {
    var removed: String?
    var sanitized = input

    // Documentation: "This method does not perform any Unicode normalization."
    while let range = sanitized.rangeOfCharacter(from: forbiddenCharacterSet) { // O(n)*k
        let wrongPart = String(sanitized[range])
        removed = (removed ?? "") // Inizializing it only when needed
        removed?.append(wrongPart)
        sanitized.removeSubrange(range)
    }

    return (removed, sanitized)
    // O(k*n) in total but saves a little because .rangeOfCharacter() will find elements more quickly.
}

Apple sadly does not provide any information about the time complexity of this method so we assume that it is O(n). Doing it for every invalid character brings us to O(nk) in total.

That is quite close to our desired O(n). However, it does not produce correct results when unicode is involved. If we waive unicode, we can also modify function #1:

func sanitize1_1(input: String) -> (removed: String?, sanitized: String) {
    var removed: String?
    let sanitized = input.filter { (character) -> Bool in //O(n)

        for scalar in character.unicodeScalars { //O(|unicode scalars in character|)
            if forbiddenCharacterSet.contains(scalar) { //O(1) (Set containment is constant)
                // Store the removed character
                let forbiddenCharacterAsString = String(character)
                removed = (removed ?? "") // Inizializing it only when needed
                removed?.append(forbiddenCharacterAsString)
                return false
            }
        }
        return true
    }
    return (removed, sanitized)
    // O(n) * O(|unicode scalars in character|)
}

In the filter function every character’s unicodeScalar is examined and checked if the CharacterSet contains it. The rest is the same logic as before. Time complexity wise an additional loop with O(|unicode scalars of character|) is introduced. Thus giving us the combined complexity of O(n * |unicode scalars of character|)​.

Wait a minute.

We can not check if a CharacterSet contains a Character but it’s possible to test if it contains a UnicodeScalar! Why isn’t it called “UnicodeScalarSet” then?

🧐

Conclusion

A CharacterSet is really not a set of Characters. We can not check if a string’s character is part of it. It is more a “UnicodeScalarSet”. Since a character can consist of multiple unicode scalars, it is not possible to build a unicode aware API around CharacterSet. It is also impossible to get elements out of a CharacterSet.

If we need unicode support, we'll go with function #3. O(k*n) is quite ok if the invalid characters aren’t too many. Otherwise, function #4 or #1_1 are the best options. With that we come pretty close to O(n).

It wonder why the CharacterSet API is so limited. It even misses the point of its name. Working around its limitations is possible but may be confusing in the first time.

If you want to, you can download the playground here. I would love to hear from you, as well!

Additional information

Using Attabench for performance investigations shows that all of the introduced ways are nearly constant and there is no clear winner. However if you need unicode support, function #3 may be the best fit.

Performance investigation results showing that every algorithm has On time complexity.

"On the Limitations of CharacterSet".

Overlooked API of the Day: NSCache

I am almost sure that you have stumbled upon the method didReceiveMemoryWarning. It gets added to every UIViewController that you create usind a template from Xcode.

Image of the default code in didReceiveMemoryWarning

Depending on the memory consumption of your ViewController, it's the systems way of telling you "Hey, please use less memory or I am forced to kill your process". This might be a good time to drop references to cached images that can be re-loaded from disk or via network.

Your app also gets a notification called UIApplicationDidReceiveMemoryWarningNotification when the device will run our of memory. Every object can listen for this notification and act accordingly.

If you do not want do deal with this kind of situation by yourself NSCache assist you. NSCache works like a regular dictionary but will drop elements once the memory pressure gets too high.

You can, for example, put downloaded images into a NSCache. They may get purged during usage of your app. In this case you need to re-download them, which is not great but certainly better than crashing.

You can find the documentation of NSCache over here. And if you need an image cache that writes the cache to disk and manages all of the downloading and storing things for you, please take a look at Kingfisher

"Overlooked API of the Day: NSCache".