Enhancing UIViews Using Extensions
There are two common ways of adding new features to UIView
s: 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.
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 bareUILabel
, 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
andUICollectionView
, 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
inUIButton
).
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:
- Create three snapshots (one for each character – see the image below).
- Create three image views and add them as subviews.
- Hide real characters by changing their
NSForegroundColorAttributeName
value toUIColor.clearColor()
. - Animate frames of image views.
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:
- We introduce some helpful local variables; then we loop through each of the three dots.
- 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.
- In the last iteration we hide the real dots in the text.
- 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 aUITextField
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.