Ghost typing your way to Hollywood

November 18th, 2016
#ui #animation

Growing up I watched a lot of Hollywood movies that involved some sort of computing and while some of those portrays left a lot to be desired in terms of realism, one common theme in those movies was what I call ghost typing ๐Ÿ‘ป.

Ghost Typing

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.

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 animateText() as this is the method that is, as the name suggests, animating 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 canceled.

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 it's grace. By default a UILabel will centre it's content in it's frame. As our ghost typing approach gradually builds that label's content, the label is only aware that it's 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 it's final location.

Let's explore UILabel's API. With label instance's that has their content set via text we apply the properties of that label uniformly to each character, this means that all characters are either bold or none are. However UILabel also allows us to set it's content via the attributedText property - this property accepts an instance of NSAttributedString. NSAttributedString allows us to define the appearance of each individual character and we can take advantage of this 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, where as 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 so giving the appearance of the character being typed onto the screen. This 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 the String animation.

Photo of a typewriter

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 the next 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. In order 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 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 a few labels to animate however with more labels you can end up in callback nested hell ๐Ÿ˜ˆ.

Recursively

Ideally we want to store our labels into 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 setup the labels themselves and then again inside an inner method called doAnimation. The doAnimation method is needed as it allows us to recursively call animateTyping and chain these animation's 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 whole-heartedly accept their cheque knowing that whether the text is over 1 line or multiple lines you have it covered.

You can find the completed project by heading over to https://github.com/wibosco/GhostTyping-Example


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, its 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 Twitter - @wibosco