Ghost Typing Your Way to Hollywood

18 Nov 2016 9 min read

Growing up, I watched a lot of Hollywood movies that involved some sort of computing, and while some of those portrayals left a lot to be desired in terms of realism, one common theme in those movies was what I call Ghost Typing πŸ‘».

Animation showing Ghost Typing on a computer screen

Ghost Typing is the term I give to the character-by-character animation shown above. Even as computer literacy has increased, it is still an animation that finds favour with movies, especially if the protagonist is interacting with any form of AI. As we see it so often, I was curious how difficult it would be to reproduce it on iOS. This post is about that process and some of the dead ends I went down.

I've also seen this animation referred to as the Typewriter Effect, but if I call it that, I won't get to use the πŸ‘» emoji and make Ghostbusters quips.

As everyone knows, ghosts come in all different shapes, sizes, and colours, and our ghost typing animation is no different. In this post, we will start small and simple, and gradually build up to a more complex and robust approach. If things get too much, pause at whatever approach works for you.

If you don't want to wait for the grand reveal, clone the accompanying project and look at QueuingAnimationsViewController to see where we end up.

A Single Line of Ghost Typing

The first approach we will look at centres on updating the text property of a UILabel using a Timer to animate each character onto the screen.

class SingleLineViewController: UIViewController {
    // 1
    @IBOutlet weak var typingLabel: UILabel!

    // 2
    var animationTimer: Timer?

    // 3
    var fullTextToBeWritten: String?

    // MARK: - ViewLifecycle

    override func viewDidLoad() {
        super.viewDidLoad()

        // 4
        fullTextToBeWritten = typingLabel.text
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        animationTimer?.invalidate()
    }

    // MARK: - ButtonActions

    // 5
    @IBAction func animatorButtonPressed(_ sender: Any) {
        animateTyping()
    }

    // MARK: - Animation

    // 6
    private func animateTyping() {
        guard let fullTextToBeWritten = self.fullTextToBeWritten else {
            return
        }

        // 7
        animationTimer?.invalidate()

        // 8
        typingLabel.text = nil

        // 9
        let characters = Array(fullTextToBeWritten)

        // 10
        var characterIndex = 0

        // 11
        animationTimer = Timer.scheduledTimer(withTimeInterval: 0.1,
                                              repeats: true) { [weak self] (timer: Timer) in
            // 12                              
            guard let label = self?.typingLabel, characterIndex < characters.count else {
                timer.invalidate()

                return
            }

            // 13
            let nextCharacterToAdd = String(characters[characterIndex])

            label.text = (label.text ?? "") + nextCharacterToAdd

            characterIndex += 1
        }
    }
}

Go on, give it a try. I'll wait...

Looking good, eh? πŸ•Ά.

So what's actually happening above:

  1. typingLabel is the label that will display the ghost typing animation. It's connected to a label in the storyboard via an IBOutlet.
  2. animationTimer holds a reference to the timer driving the animation, so we can invalidate it when needed.
  3. fullTextToBeWritten stores the complete text that will be revealed character by character.
  4. We grab the label's initial text and stash it in fullTextToBeWritten before we start clearing the label.
  5. The animatorButtonPressed(_:) method is connected to a button in the storyboard via an IBAction and kicks off the animation when tapped.
  6. animateTyping() is the private method that drives the whole ghost typing effect.
  7. Any in-progress animation is stopped by invalidating the existing timer.
  8. The label's text is cleared so we can rebuild it one character at a time.
  9. The full text is converted into an Array of characters so we can index into it.
  10. characterIndex tracks our position in the string as we reveal each character.
  11. A repeating timer is scheduled that fires every 0.1 seconds. self is captured weakly to avoid a retain cycle.
  12. We check that self is still alive and that we still have characters to animate. If either is false, the timer is invalidated to stop the animation.
  13. We grab the next character and append it to the label's existing text, using an empty string as the fallback if the label's text is nil.

I keep using the word animating, but the more eagle-eyed among you will have noticed that I don't make use of UIView Animation or Core Animation - in fact, none of the approaches you will see make use of either; instead, the animation is achieved using a timer. But the word animating still feels right to describe what we are doing here, so I'll keep using it.

This approach works well if the string you are animating contains characters that fit into one line; however, once the string moves to two lines, the animation loses some of its grace. By default, a UILabel will centre its content in its frame. As our ghost typing approach gradually builds that label's content, the label is only aware that its content will occupy two, three, etc lines of text when it actually does. This results in the text jumping as the vertical centre of the content changes 😲 - creating an odd experience. We want our animation to reveal each character in its final location.

Photo of Stay Puft from Ghostbusters

Multiple Lines of Ghost Typing

So the actual animation worked well, but that jump when we move between multiple lines isn't great. We need a way to know where each character will end up before we start animating. The trick is to set all the text up front - letting the label lay everything out in its final position - and then hide it by setting the foreground colour alpha to 0. From there, we simply reveal each character in place. We're not animating layout, we're animating visibility.

With label instances that have their content set via text, we apply the properties of that label uniformly to each character. This uniformity means that all characters are either bold or none are. However, UILabel also allows us to set its content via the attributedText property - this property accepts an instance of NSAttributedString. NSAttributedString allows us to define the appearance of each character, and we can take advantage of this to smooth out our animation:

class MultipleLineViewController: UIViewController {
    @IBOutlet weak var typingLabel: UILabel!

    var animationTimer: Timer?

    // MARK: - ViewLifecycle

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        animationTimer?.invalidate()
    }

    // MARK: - ButtonActions

    @IBAction func animatorButtonPressed(_ sender: Any) {
        animateTyping()
    }

    // MARK: - Animation

    private func animateTyping() {
        // 1
        guard let attributedText = typingLabel.attributedText else {
            return
        }

        animationTimer?.invalidate()

        // 2
        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText)

        mutableAttributedString.addAttribute(NSAttributedStringKey.foregroundColor,
                                             value: typingLabel.textColor.withAlphaComponent(0),
                                             range: NSMakeRange(0, attributedText.length))
        typingLabel.attributedText = mutableAttributedString

        let characters = Array(attributedText.string)
        var characterIndex = 0

        animationTimer = Timer.scheduledTimer(withTimeInterval: 0.1,
                                              repeats: true) { [weak self] (timer: Timer) in
            guard let label = self?.typingLabel, characterIndex < characters.count else {
                timer.invalidate()

                return
            }

            // 3
            mutableAttributedString.addAttribute(NSAttributedStringKey.foregroundColor,
                                                 value: label.textColor.withAlphaComponent(1),
                                                 range: NSMakeRange(characterIndex, 1))
            label.attributedText = mutableAttributedString

            characterIndex += 1
        }
    }
}

Go on, give this one a try over multiple lines of text. Again, I'll wait.

I don't know about you, but it's looking pretty good to me 😁.

These are the changes:

  1. We guard that the label has attributed text to work with.
  2. All characters in the label are hidden by setting their foreground colour alpha to 0. This means the text is still laid out in the label - it's just invisible.
  3. With each tick of the timer, the character at characterIndex has its foreground colour alpha set to 1, making it visible. Because the text was already laid out in the label, revealing it this way avoids any layout shifts.

The above is only a partial solution because often we don't want our labels to exist in isolation, but rather have one animation hand off to another animation.

Tying animations together

When handing off from one animation to another, we have two ways of doing it:

  • Chaining
  • Queuing

Chaining

class ChainingAnimationsViewController: UIViewController {
    // 1
    @IBOutlet weak var firstTypingLabel: UILabel!
    @IBOutlet weak var secondTypingLabel: UILabel!
    @IBOutlet weak var thirdTypingLabel: UILabel!

    var animationTimer: Timer?

    // MARK: - ViewLifecycle

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        animationTimer?.invalidate()
    }

    // MARK: - ButtonActions

    @IBAction func animatorButtonPressed(_ sender: Any) {
        startTypingAnimation()
    }

    // MARK: - Animation

    // 2
    private func startTypingAnimation() {
        animationTimer?.invalidate()

        // 3
        makeInvisible(label: firstTypingLabel)
        makeInvisible(label: secondTypingLabel)
        makeInvisible(label: thirdTypingLabel)

        // 4
        animateTyping(label: firstTypingLabel,
                      completion: { [weak self] in
            self?.animateTyping(label: self?.secondTypingLabel,
                                completion: {
                self?.animateTyping(label: self?.thirdTypingLabel,
                                    completion: nil)
            })
        })
    }

    // 5
    private func animateTyping(label: UILabel?,
                               completion: (() -> Void)?) {
        guard let label = label, let attributedText = label.attributedText else {
            completion?()

            return
        }
        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText)

        let characters = Array(attributedText.string)
        var characterIndex = 0

        animationTimer = Timer.scheduledTimer(withTimeInterval: 0.1,
                                              repeats: true) { timer in
            guard characterIndex < characters.count else {
                timer.invalidate()
                completion?()

                return
            }

            mutableAttributedString.addAttribute(NSAttributedStringKey.foregroundColor,
                                                 value: label.textColor.withAlphaComponent(1),
                                                 range: NSMakeRange(characterIndex, 1))
            label.attributedText = mutableAttributedString

            characterIndex += 1
        }
    }

    private func makeInvisible(label: UILabel) {
        guard let attributedText = label.attributedText else {
            return
        }

        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText)
        mutableAttributedString.addAttribute(NSAttributedStringKey.foregroundColor,
                                             value: label.textColor.withAlphaComponent(0),
                                             range: NSMakeRange(0, attributedText.length))
        label.attributedText = mutableAttributedString
    }
}
  1. Three labels - each one will be animated in sequence to create a chained ghost typing effect.
  2. startTypingAnimation() kicks off the chained animation sequence by invalidating any in-progress animation timer.
  3. All three labels are made invisible by setting their foreground colour alpha to 0. Like before, the text is still laid out in each label - it's just hidden.
  4. The animations are chained together iteratively by nesting completion closures. The first label animates, and when it finishes, it triggers the second, which in turn triggers the third.
  5. animateTyping(label:completion:) handles the ghost typing effect for a single label. The label and attributedText are unwrapped up front via a guard, and the mutableAttributedString and characters array are created once before the timer starts - creating them once before the timer starts avoids unnecessary work on every tick. A completion closure is passed in and called once all characters have been revealed.
  6. Once all characters in the label have been revealed, the timer is invalidated and the completion closure is called - which triggers the next label's animation in the chain.

You might have noticed that animateTyping(label:completion:) doesn't capture self at all - the timer closure only uses the label and completion parameters passed into it. That's a nice side effect of this refactoring; retain cycles in timer closures are a common source of bugs, and here we've avoided the problem entirely.

This approach works and is easy enough to understand when you only have a few labels to animate; however, with more labels, you can end up in a callback nested hell 😈.

Queuing

Ideally, we want to store our labels in a queue - that way, we can iterate through each label (like we do with the characters) and animate each.

class QueuingAnimationsViewController: UIViewController {
    @IBOutlet weak var firstTypingLabel: UILabel!
    @IBOutlet weak var secondTypingLabel: UILabel!
    @IBOutlet weak var thirdTypingLabel: UILabel!

    var animationTimer: Timer?

    // MARK: - ViewLifecycle

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        animationTimer?.invalidate()
    }

    // MARK: - ButtonActions

    @IBAction func animatorButtonPressed(_ sender: Any) {
        startTypingAnimation()
    }

    // MARK: - Animation

    private func startTypingAnimation() {
        animationTimer?.invalidate()

        // 1
        let typingAnimationLabelQueue = [firstTypingLabel, secondTypingLabel, thirdTypingLabel].compactMap { $0 }

        // 2
        for typingAnimationLabel in typingAnimationLabelQueue {
            makeInvisible(label: typingAnimationLabel)
        }

        var labelIndex = 0

        // 3
        func doAnimation() {
            guard labelIndex < typingAnimationLabelQueue.count else {
                return
            }

            let typingAnimationLabel = typingAnimationLabelQueue[labelIndex]
            labelIndex += 1

            animateTyping(label: typingAnimationLabel) {
                doAnimation()
            }
        }

        doAnimation()
    }

    private func animateTyping(label: UILabel?,
                               completion: (() -> Void)?) {
        guard let label = label, let attributedText = label.attributedText else {
            completion?()

            return
        }
        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText)

        let characters = Array(attributedText.string)
        var characterIndex = 0

        animationTimer = Timer.scheduledTimer(withTimeInterval: 0.1,
                                              repeats: true) { timer in
            guard characterIndex < characters.count else {
                timer.invalidate()
                completion?()

                return
            }

            mutableAttributedString.addAttribute(NSAttributedStringKey.foregroundColor,
                                                 value: label.textColor.withAlphaComponent(1),
                                                 range: NSMakeRange(characterIndex, 1))
            label.attributedText = mutableAttributedString

            characterIndex += 1
        }
    }

    private func makeInvisible(label: UILabel) {
        guard let attributedText = label.attributedText else {
            return
        }

        let mutableAttributedString = NSMutableAttributedString(attributedString: attributedText)
        mutableAttributedString.addAttribute(NSAttributedStringKey.foregroundColor,
                                             value: label.textColor.withAlphaComponent(0),
                                             range: NSMakeRange(0, attributedText.length))
        label.attributedText = mutableAttributedString
    }
}

The above class is almost the same as before; the only difference is what's happening inside startTypingAnimation():

  1. The three labels are gathered into a queue using compactMap to safely unwrap any optionals.
  2. Each label in the queue is made invisible by setting its foreground colour alpha to 0. The text is still laid out - it's just hidden.
  3. doAnimation() is a nested function defined inside startTypingAnimation(). This is important because it captures labelIndex from its enclosing scope - each time doAnimation() is called recursively via the completion handler, it picks up where it left off without needing a class-level property. It steps through typingAnimationLabelQueue, taking each label in turn, animates it, and passes itself as the completion handler - so when one label finishes animating, the next one starts automatically. Once all labels have been animated, the recursion stops.

makeInvisible(label:) would make a great helper method in a UILabel extension.

No more nesting πŸͺΉ.

Who they gonna call? πŸ‘»

We've explored a few different takes on the ghost typing effect and how to chain them together. That means the next time Hollywood needs an iOS app to display some dramatic AI dialogue on screen in the latest Tom Cruise movie, you'll be ready to wholeheartedly accept their cheque knowing that whether the text is over one line or multiple lines, you have it covered. When the call comes, just remember: ghosts might not be real, but ghost typing definitely is πŸ‘».

To see the above code snippets together in a working example, head over to the repository and clone the project.


I decided to expand this example and turn it into an open-source project called GhostTypewriter. It's available via CocoaPods and Swift Package Manager, so you can drop it straight into your project.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.