Enhancing UIViews Using Extensions

This article uses Swift 2.1.

There are two common ways of adding new features to UIViews: composition and subclassing. Today I'd like to focus on a third, less known, but often a more fitting approach: extensions (or categories in Objective-C).

I don't want to bore you, so I thought that we could discuss this topic while making something useful at the same time. We'll build an extension that animates dots in an ellipsis displayed by a UILabel. This animation lately became one of the standards for indicating that some operation is being performed in the background.

Animation achieved without subclassing UILabel

As always, the full code is available on GitHub.

Why an Extension?

As mentioned in the introduction, the implementation will be based on an extension. But, why do we even want to use an extension in the first place? Well, there are some cases where extensions are just more powerful than their alternatives:

  • If we simply used a subclass, let's say JumpingDotsLabel, we wouldn't be able to mix it with some other feature unavailable in bare UILabel, e.g. a marquee effect. (Assuming that two extensions don't break each other.)
  • If we wanted to add some new feature to, e.g. both UITableView and UICollectionView, we would have to create subclasses for both of them, probably duplicating some functionality along the way. With an extension we can work with their parent class (UIScrollView) directly.
  • We can use them in places where we don't control what view class is used (e.g. an animation of titleLabel in UIButton).

Having said that, let's add an extension to UILabel:

extension UILabel {
    func startJumpingDots() throws {}
}

Approaching the Animation

In the beginning, we have to introduce an important constraint. To be even able to start thinking about the animation we have to assume that three dots are represented by three separate characters (...), not an ellipsis ().

My initial approach was to modify values of NSBaselineOffsetAttributeName for the last three characters using CADisplayLink (or some higher-level abstraction like pop). It turns out, though, that the offset value influences the intrinsic content size of the label too, causing the label's height to change on each layout during the animation.

I tinkered with other ideas, but in the end decided to use the good ol' snapshot trick. Instead of operating on real views we can take snapshots of them and animate the snapshots instead.

We can split the whole process into four steps:

  1. Create three snapshots (one for each character – see the image below).
  2. Create three image views and add them as subviews.
  3. Hide real characters by changing their NSForegroundColorAttributeName value to UIColor.clearColor().
  4. Animate frames of image views.

Frames of snapshots

With the plan ready we can move on to the implementation.

Simple Implementation

We need to know at what frames the characters are displayed. Introduced in TextKit boundingRectForGlyphRange(_:inTextContainer:) seems like a good fit for our problem. We quickly find out, though, that UILabel doesn't expose its internal NSTextStorage instance 😕. What a bummer.

Thankfully, there's a way out of it. We can set up a new text stack that will mimic the settings used internally by UILabel. There's a possible danger in doing that ⚠️ – these settings can change in future releases of iOS and stop matching our configuration.

We'll encapsulate that text stack in its own class LabelTextStackReplica. We'll also introduce two new methods: boundingRectForCharacterAtPosition(_:inAttributedString:) and snapshotRect(_:). With all these parts ready we can start implementing the startJumpingDots method. First, we check if the label's content is correct:

func startJumpingDots() throws {
    let requiredEnding = "..."

    guard let attributedText = attributedText else {
        throw JumpingDotsError.MissingAttributedString
    }

    let text = attributedText.string

    guard text.hasSuffix(requiredEnding) else {
        throw JumpingDotsError.DoesNotEndWithThreeDots
    }

Then, we proceed to implement the animation itself:

    // section 1
    let endingCharacterCount = requiredEnding.characters.count
    let endingRange = NSRange(location: text.characters.count - endingCharacterCount, length: endingCharacterCount)

    for i in 0..<endingCharacterCount {

        // section 2
        let characterPosition = text.characters.count - endingCharacterCount + i
        let boundingRect = boundingRectForCharacterAtPosition(characterPosition,
            inAttributedString: attributedText).integral

        let imageView = UIImageView(frame: boundingRect)
        imageView.image = snapshotRect(boundingRect)
        addSubview(imageView)

        // section 3
        if i == endingCharacterCount - 1 {
            changeTextColorAtRange(endingRange, to: UIColor.clearColor())
        }

        // section 4
        let delay = Double(i) * 0.15
        UIView.animateKeyframesWithDuration(1.15, delay: delay, options: [.Repeat], animations: {
            let relativeDuration = 0.282

            UIView.addKeyframeWithRelativeStartTime(0, relativeDuration: relativeDuration, animations: {
                imageView.frame.origin.y = -self.bounds.height / 4
            })

            UIView.addKeyframeWithRelativeStartTime(relativeDuration, relativeDuration: relativeDuration, animations: {
                imageView.frame.origin.y = 0
            })
            }, completion: nil
        )
    }
}

Let's analyze what we do in each part:

  1. We introduce some helpful local variables; then we loop through each of the three dots.
  2. We take a snapshot of the part of the view containing the character, then we create an image view and add it as a subview.
  3. In the last iteration we hide the real dots in the text.
  4. We schedule the animations in a way that makes them look nice. We use keyframe animations, because they are just powerful enough for our use case. If you want to use a different animation curve, I encourage you to read my article about Bézier Curve Fitting.

Just like that we've got ourselves the animation shown in the beginning of this post ✨. But, let's not stop there just yet.

Improvement: Pausing

In the current implementation the animation will keep repeating indefinitely. It's not what we really want. Let's fix that by adding a method for stopping the animation to our public API:

func stopJumpingDots() {
    jumpingDotsPausing = true
}

We can't stop the animation immediately, because a transition to the initial state won't be smooth – we have to let the dots get back to their initial positions first. That's why we have to keep an information (jumpingDotsPausing) that we want the animation to stop repeating.

It's currently not possible to add properties to class extensions in Swift, though. However, since we operate on views that inherit from NSObject we can use Objective-C runtime capability, associated objects, for our storage instead. Let's define two boolean properties backed by associated objects:

private(set) var jumpingDotsRunning: Bool
private var jumpingDotsPausing: Bool

Getter of the first property is accessible from the outside, so that a client of our API can check the current state of the animation.

Now, we have to replace options: [.Repeat] with options: [] in our animation code, as we'll be triggering the next iteration manually. We'll also add a completion block to our keyframe animation:

}, completion: { [weak self] finished in
    if i == endingCharacterCount - 1 {
        self?.triggerNextIterationIfNeededUsingEndingRange(endingRange, addedSubviews: addedSubviews, originalTextColor: originalTextColor)
    }
}

At the end of the last animation we call triggerNextIterationIfNeededUsingEndingRange(_:addedSubviews:originalTextColor:), which isn't pretty internally, but that's how it is when you operate in an immensely stateful environment.

We'll stop here, but let's see what could be further improved in this implementation.

Other Possible Improvements

There's an unwritten contract in our API. We assume that nothing will be changed in the label itself during the animation. Can we even know that something changed? There are some tricks allowing us to do that:

  • If we want to know that the view changed its size we can add a hidden subview that will be calling us from its layoutSubviews() override. This is somewhat similar to how Soroush Khanlou leverages composition of view controllers.
  • We can use Key-Value Observing to be informed about changes. Even though we shouldn't use KVO for undocumented UIKit properties, such as contentOffset, many popular libraries do. That's why I don't expect to see breaking changes from Apple in this department anytime soon.
  • For views inheriting from UIControl we can often use target-action to be informed of changes in their state. For example when we want to know that a text changed in a UITextField instance.

Aside from preventing visual glitches, we could also easily extend our implementation to work with UITextView. We could introduce a new protocol, e.g. JumpingDotsDisplayable and move common functionality to the protocol extension.

I encourage you to check the complete implementation on GitHub in this gist and try to implement some of the things discussed here. It'll be fun!

Conclusion

We learned about approaches that we can use to make views more powerful with extensions. We also built a useful animation, as an example, along the way.

But, would I actually recommend using an extension in the way shown here?

¯\_(ツ)_/¯

Seriously, though, it depends. I'd definitely choose my approach in this order: composition (a new view that wraps some other views), a subclass, an extension.

There are times when an extension fits the problem best. So, I think it's definitely worth it to have this trick up your sleeve.

Thanks to Kamil Kołodziejczyk, Maciej Konieczny and Rafał Augustyniak for reading drafts of this.