Ghost Typing Your Way to Hollywood

18 Nov 2016 8 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 wondering 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.

Looking closely at Strings

My first attempt centred on adding one character at a time to a label's text property with a Timer instance controlling when the character is added.

I will show the entire class below for fullness but only focus on describing the animation specific pieces. The UI consists of a label and a button to trigger the animation:

Animation UI

Code time:

class SingleLineViewController: UIViewController {

    // MARK: - Properties
    
    @IBOutlet weak var typingLabel: UILabel!
    
    var animationTimer: Timer?
    var fullTextToBeWritten: String?
    
    // MARK: - ViewLifecycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        fullTextToBeWritten = typingLabel.text
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        animationTimer?.invalidate()
    }
    
    // MARK: - ButtonActions
    
    @IBAction func animatorButtonPressed(_ sender: Any) {
        animateText()
    }
    
    // MARK: - Animation
    
    func animateText() {
        animationTimer?.invalidate()
        typingLabel.text = nil
        
        var nextCharacterIndexToBeShown = 0
        
        animationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { [weak self] (timer: Timer) in
            if let fullTextToBeWritten = self?.fullTextToBeWritten, let label = self?.typingLabel {
                let characters = Array(fullTextToBeWritten.characters)
                
                if nextCharacterIndexToBeShown < characters.count {
                    let nextCharacterToAdd = String(characters[nextCharacterIndexToBeShown])
                    
                    if let currentText = label.text {
                        label.text = currentText + nextCharacterToAdd
                    } else {
                        label.text = nextCharacterToAdd
                    }
                    
                    nextCharacterIndexToBeShown += 1
                } else {
                    timer.invalidate()
                }
            } else {
                timer.invalidate()
            }
        })
    }
}

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

Looking good, eh? πŸ•Ά.

We are really interested in animateText() as this is the method that, as the name suggests, animates the text on screen. The idea in the code above is that we have the full string, fullTextToBeWritten, and we keep a counter, nextCharacterIndexToBeShown, that is used to determine which character will be animated on-screen next. Before each character is added, we check that there are still characters to add, and if not, we know that the string animation is complete, and we can cancel the timer, animationTimer. Each character is set to be added every 0.1 seconds - this time interval can be adjusted to match the animation speed that you want. The animationTimer instance is using:

scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Swift.Void) -> Timer

Which is available for iOS 10 or above. If you need to support iOS 9 or lower, you can use:

open class func scheduledTimer(timeInterval ti: TimeInterval, target aTarget: Any, selector aSelector: Selector, userInfo: Any?, repeats yesOrNo: Bool) -> Timer

And create another method for it to call, populating the relevant information into the userInfo parameter.

For added protection, the above method stores the timer as a property, which allows the animation to be cancelled.

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 examples you will see make use of either; instead, the animation is achieved using a timer.

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 😲.

Moving our attention onto NSAttributedStrings

So the actual animation part of the String approach worked well, but that jump when you move between multiple lines is really annoying - we need a way to determine the final location of each character and then reveal that character already in its final location.

Let's explore the UILabel API. 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 MutlipleLineViewController: UIViewController {
    
    // MARK: - Properties
    
    @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) {
        animateText()
    }
    
    // MARK: - Animation
    
    func animateText() {
        animationTimer?.invalidate()
        configureLabel(alpha: 0, until: typingLabel.text?.characters.count)
        
        var showCharactersUntilIndex = 1
        
        animationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { [weak self] (timer: Timer) in
            if let label = self?.typingLabel, let attributedText = label.attributedText {
                let characters = Array(attributedText.string.characters)
                
                if showCharactersUntilIndex <= characters.count {
                    self?.configureLabel(alpha: 1, until: showCharactersUntilIndex)
                    
                    showCharactersUntilIndex += 1
                } else {
                    timer.invalidate()
                }
            } else {
                timer.invalidate()
            }
        })
    }
    
    func configureLabel(alpha: CGFloat, until: Int?) {
        if let attributedText = typingLabel.attributedText  {
            let attributedString = NSMutableAttributedString(attributedString: attributedText)
            attributedString.addAttribute(NSForegroundColorAttributeName, value: typingLabel.textColor.withAlphaComponent(CGFloat(alpha)), range: NSMakeRange(0, until ?? 0))
            typingLabel.attributedText = attributedString
        }
    }
}

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

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

Like in the String example, we have an animateText method; however, there are some subtle differences between them. In the String example, we gradually built up the content of the label, whereas here we set the label's content and then hide it via the configureLabel method:

func configureLabel(alpha: CGFloat, until: Int?) {
    if let attributedText = typingLabel.attributedText  {
        let attributedString = NSMutableAttributedString(attributedString: attributedText)
        attributedString.addAttribute(NSForegroundColorAttributeName, value: typingLabel.textColor.withAlphaComponent(CGFloat(alpha)), range: NSMakeRange(0, until ?? 0))
        typingLabel.attributedText = attributedString
    }
}

In the above method, we use the NSForegroundColorAttributeName property on attributedText to change the alpha component of each character. Before the animation begins, we call configureLabel to set the alpha to 0 - effectively making the label invisible, and then during the animation, we set each character's alpha to 1, giving the appearance of the character being typed onto the screen. This alpha transformation allows us to determine the final location of each character at the start of the animation and so avoid the jumping of words between lines, which is present in the String animation.

Photo of Stay Puft from Ghostbusters

Tying animations together

But 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. Let's explore two possible ways of doing this below:

  • Iteratively
  • Recursively

Iteratively

class ChainingAnimationsViewController: UIViewController {

    // MARK: - Properties
    
    @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) {
        startTextAnimation()
    }
    
    // MARK: - Animation
    
    func startTextAnimation() {
        animationTimer?.invalidate()
        configureLabel(label: firstTypingLabel, alpha: 0, until: firstTypingLabel.text?.characters.count)
        configureLabel(label: secondTypingLabel, alpha: 0, until: secondTypingLabel.text?.characters.count)
        configureLabel(label: thirdTypingLabel, alpha: 0, until: thirdTypingLabel.text?.characters.count)
        
        animateText(label: firstTypingLabel, completion: { [weak self] in
            self?.animateText(label: self?.secondTypingLabel, completion: {
                self?.animateText(label: self?.thirdTypingLabel, completion: nil)
            })
        })
    }
    
    func animateText(label: UILabel?, completion: (()->Void)?) {
        var showCharactersUntilIndex = 1
        
        animationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { [weak self] (timer: Timer) in
            if let label = label, let attributedText = label.attributedText {
                let characters = Array(attributedText.string.characters)
                
                if showCharactersUntilIndex <= characters.count {
                    self?.configureLabel(label: label, alpha: 1, until: showCharactersUntilIndex)
                    
                    showCharactersUntilIndex += 1
                } else {
                    timer.invalidate()
                    
                    if let completion = completion {
                        completion()
                    }
                }
            } else {
                timer.invalidate()
                
                if let completion = completion {
                    completion()
                }
            }
        })
    }
    
    func configureLabel(label: UILabel, alpha: CGFloat, until: Int?) {
        if let attributedText = label.attributedText  {
            let attributedString = NSMutableAttributedString(attributedString: attributedText)
            attributedString.addAttribute(NSForegroundColorAttributeName, value: label.textColor.withAlphaComponent(CGFloat(alpha)), range: NSMakeRange(0, until ?? 0))
            label.attributedText = attributedString
        }
    }
}

So in the above class, we have three labels, with the intention being that when each label's content is fully shown, the next label's content can begin to be animated in. To do this, we also have a new method startTextAnimation - the role of this method is to set up the sequence in which the labels will be animated. It does by calling configureLabel and setting each label's content to invisible before chaining the completion block/closures of the individual label animation's together:

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

This chaining is possible because we have made some changes to the animateText method from the previous examples - we now inject the label that the animation will be performed on and a callback block/closure.

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 😈.

Recursively

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

class RecursivelyChainingAnimationsViewController: UIViewController {

    // MARK: - Properties
    
    @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) {
        startTextAnimation()
    }
    
    // MARK: - Animation
    
    func startTextAnimation() {
        animationTimer?.invalidate()
        
        var typingAnimationLabelQueue = [firstTypingLabel, secondTypingLabel, thirdTypingLabel]
        
        for typingAnimationLabel in typingAnimationLabelQueue {
            configureLabel(label: typingAnimationLabel!, alpha: 0, until: typingAnimationLabel!.text?.characters.count)
        }
        
        func doAnimation() {
            guard typingAnimationLabelQueue.count > 0 else {
                return
            }
            
            let typingAnimationLabel = typingAnimationLabelQueue.removeFirst()
            
            animateTyping(label: typingAnimationLabel) {
                doAnimation()
            }
        }
        
        doAnimation()
    }
    
    func animateTyping(label: UILabel?, completion: (()->Void)?) {
        var showCharactersUntilIndex = 1
        
        animationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { [weak self] (timer: Timer) in
            if let label = label, let attributedText = label.attributedText {
                let characters = Array(attributedText.string.characters)
                
                if showCharactersUntilIndex <= characters.count {
                    self?.configureLabel(label: label, alpha: 1, until: showCharactersUntilIndex)
                    
                    showCharactersUntilIndex += 1
                } else {
                    timer.invalidate()
                    
                    if let completion = completion {
                        completion()
                    }
                }
            } else {
                timer.invalidate()
                
                if let completion = completion {
                    completion()
                }
            }
        })
    }
    
    func configureLabel(label: UILabel, alpha: CGFloat, until: Int?) {
        if let attributedText = label.attributedText  {
            let attributedString = NSMutableAttributedString(attributedString: attributedText)
            attributedString.addAttribute(NSForegroundColorAttributeName, value: label.textColor.withAlphaComponent(CGFloat(alpha)), range: NSMakeRange(0, until ?? 0))
            label.attributedText = attributedString
        }
    }
}

The above class is almost the same as before; the only difference is what's happening inside startTextAnimation.

func startTextAnimation() {
    animationTimer?.invalidate()
    
    var typingAnimationLabelQueue = [firstTypingLabel, secondTypingLabel, thirdTypingLabel]
    
    for typingAnimationLabel in typingAnimationLabelQueue {
        configureLabel(label: typingAnimationLabel!, alpha: 0, until: typingAnimationLabel!.text?.characters.count)
    }
    
    func doAnimation() {
        guard typingAnimationLabelQueue.count > 0 else {
            return
        }
        
        let typingAnimationLabel = typingAnimationLabelQueue.removeFirst()
        
        animateTyping(label: typingAnimationLabel) {
            doAnimation()
        }
    }
    
    doAnimation()
}

Here we push all of our labels into an array, typingAnimationLabelQueue. We then iterate over typingAnimationLabelQueue twice, once to set up the labels themselves and then again inside an inner method called doAnimation. The doAnimation method is needed as it allows us to call animateTyping recursively and chain those animations completion blocks/closures together.

Who they gonna call? πŸ‘»

So we have seen two different ways of creating the ghost typing effect and two different ways to chain those animations together. All this means that the next time Hollywood comes calling about creating an AI sequence on an iOS device for the latest Tom Cruise movie, you can wholeheartedly accept their cheque knowing that whether the text is over one line or multiple lines, you have it covered.

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 a cocoapod (GhostTypewriter) so that it could be more easily reused. If you have never used Cocoapods, it's a dependency management tool that allows you to inject third-party frameworks/libraries into your project - you should check it out.

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