TextEffects with NSLayoutManager

Welcome back for another round of Swift Yeti!
This week we are going to dive into some text effect with NSLayoutManager.

I was shown app called Secret a couple weeks back, which had an effect that caught my eye.

This effect is so simple yet captures the very essence of a secret being revealed to you. My only problem is that the effect only shows at the bottom of the screen, which is not where my eyes are focused. So lets fix this issue by reimplementing this text function so everyone can see it!

NSLayoutManager, NSTextStorage, NSTextContainer
are three very special classes were added in iOS7. With these classes you have a lot of power at your finger tips, including the power to help us recreate this effect: NSLayoutManager.

We are going to start by creating a new class, CharacterLabel, as a subclass of UILabel. We are also going to add two arrays to hold old character CATextLayers and new character CATextLayers for switching.

class CharacterLabel: UILabel, NSLayoutManagerDelegate {

    var oldCharacterTextLayers = Array<CATextLayer>()
    var characterTextLayers = Array<CATextLayer>()
}

NSLayoutManager, NSTextStorage, NSTextContainer need to work together to get everything laid out nicely. It all starts with NSTextStorage:

NSTextStorage is responsible for holding a string value and notifying the set of NSLayoutManagers when any change occurs to said string.

NSTextContainer is responsible for defining a region where text can be laid out. This region is used by NSLayoutManager to determine where to break lines. In our case this will be our CharacterLabel’s frame.

NSLayoutManager is our main focus. Its responsibility is to take the string from the NSTextStorage and break it out into glyphs, determine where the glyphs should be displayed based on the NSTextContainers region, and finally display the glyphs for the visible region.

Now UILabel does not have an NSLayoutManager, NSTextStorage,and NSTextContainer like UITextView. So we need to create our own and use those instead.

class CharacterLabel: UILabel, NSLayoutManagerDelegate {

    let textStorage = NSTextStorage(string: " ");
    let textContainer = NSTextContainer();
    let layoutManager = NSLayoutManager();
    var oldCharacterTextLayers = Array<CATextLayer>()
    var characterTextLayers = Array<CATextLayer>()
}

Because we are using our own we are going to override the getter and setter of text and attributedText. This will allow us to pass out new text to the text storage to be laid out as well as prevent the label from rendering it on screen.

override var text: String! {
get {
    return super.text
}

set {
    let wordRange = NSMakeRange(0, newValue.utf16count)
    var attributedText = NSMutableAttributedString(string: newValue)
    attributedText.addAttribute(NSForegroundColorAttributeName , value:self.textColor, range:wordRange)
    attributedText.addAttribute(NSFontAttributeName , value:self.font, range:wordRange)

    var paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.alignment = self.textAlignment
    attributedText.addAttribute(NSParagraphStyleAttributeName, value:paragraphStyle, range: wordRange)

    self.attributedText = attributedText
}

}

override var attributedText: NSAttributedString! {
get {
    return super.attributedText
}

set {

    if textStorage.string == newValue.string {
        return
    }

    cleanOutOldCharacterTextLayers()
    oldCharacterTextLayers = Array<CATextLayer>(characterTextLayers)
    textStorage.setAttributedString(newValue)
}

}

Whenever we change the text in the text storage a notification triggers the layout manager to invalidate its current layout and recalculate. This is where we can step in as the NSLayoutManagerDelegate. We are interested in one call here, layoutManager didFinishLayout.

When a layout has finish we can inject ourselves to add our CATextLayers

func layoutManager(layoutManager: NSLayoutManager!, didCompleteLayoutForTextContainer textContainer: NSTextContainer!, atEnd layoutFinishedFlag: Bool) {
    calculateTextLayers()
}

func calculateTextLayers() {
}

The main idea behind this is to loop over every character and grab the layout managers glyph rect. With the glyph rect in hand we can create a CATextLayer with that rect and assign it the proper string. After that we will have an array of text layers to have fun with.

The fist thing we want to do is start with an empty array for the new CATextLayers. And setup the text storage string with the proper label attributes.

 func calculateTextLayers() {
    characterTextLayers.removeAll(keepCapacity: false)
    if let attributedText = textStorage.string? {

}

Next we ask the layout manager for the used rect for text container. This will allow us to vertically center our text like a normal UILabel.

Now we begin iterating over the length of the string. Create a glyphRange and request the characters used for the glyph at that range.

func calculateTextLayers() {
    characterTextLayers.removeAll(keepCapacity: false)
    if let attributedText = textStorage.string? {

        let wordRange = NSMakeRange(0, attributedText.utf16count);
        let attributedString = self.internalAttributedText();
        let layoutRect = layoutManager.usedRectForTextContainer(textContainer);

        for var index = wordRange.location; index < wordRange.length+wordRange.location; index += 0 {
        }
    }
}

We then ask the layout manager for the the textContainer used by the glyph index. This ensures the layout rect is for the correct region.

Now we need to get the positions for the layout manager

First is the boundingRect for our glyph. This will be the smallest region the glyph can be displayed, without kerning applied.

let glyphRange = NSMakeRange(index, 1);
let characterRange = layoutManager.characterRangeForGlyphRange(glyphRange, actualGlyphRange:nil);
let textContainer = layoutManager.textContainerForGlyphAtIndex(index, effectiveRange: nil);
var glyphRect = layoutManager.boundingRectForGlyphRange(glyphRange, inTextContainer: textContainer);

Second is the location: This is where the rect should be placed inside the textContainer.

  var location = layoutManager.locationForGlyphAtIndex(index);

Finally, we ask the layoutManager if there is special spacing applied between the glyphIndex and the previous glyph.

var kerningRange = layoutManager.rangeOfNominallySpacedGlyphsContainingIndex(index)

if kerningRange.location == index {
    if countElements(characterTextLayers) > 0 {
                    var previousLayer = characterTextLayers[characterTextLayers.endIndex-1]
                    var frame = previousLayer.frame
                    frame.size.width += CGRectGetMaxX(glyphRect)-CGRectGetMaxX(frame)
                    previousLayer.frame = frame
       }
   }

If the kerning range location is equal to our current index, then the spacing between this range and the previous one is not normal based on the font. TO fix this we can adjust our previous glyph to include the same rect as our new one, which should ensure no characters get cut off like this:

Next we are going to adjust our glyphRect to take in to account the position, and because it is a UILabel make it vertically centered.

glyphRect.origin.y += location.y-(glyphRect.height/2)+(self.bounds.size.height/2)-(layoutRect.size.height/2);

Almost finished. I created a nice little convenience initializer for CATextLayer to take a frame and a string, allowing us to create an awesome new glyph in one step.

var textLayer = CATextLayer(frame: glyphRect, string: attributedString.attributedSubstringFromRange(characterRange));
            initialTextLayerAttributes(textLayer)

Add it to the backing layer and our array.

layer.addSublayer(textLayer);
characterTextLayers.append(textLayer);

And finally, step the index by the number of characters we displayed in the glyph! This prevents duplicate layers from appearing when you have strings like 'ff', which is rendered in certain fonts as one glyph, but two characters.

index += characterRange.length;

All together it looks like the following:

func calculateTextLayers() {
    characterTextLayers.removeAll(keepCapacity: false)
    if let attributedText = textStorage.string? {

        let wordRange = NSMakeRange(0, attributedText.utf16count);
        let attributedString = self.internalAttributedText();
        let layoutRect = layoutManager.usedRectForTextContainer(textContainer);

        for var index = wordRange.location; index < wordRange.length+wordRange.location; index += 0 {
            let glyphRange = NSMakeRange(index, 1);
            let characterRange = layoutManager.characterRangeForGlyphRange(glyphRange, actualGlyphRange:nil);
            let textContainer = layoutManager.textContainerForGlyphAtIndex(index, effectiveRange: nil);
            var glyphRect = layoutManager.boundingRectForGlyphRange(glyphRange, inTextContainer: textContainer);
            var location = layoutManager.locationForGlyphAtIndex(index);
            var kerningRange = layoutManager.rangeOfNominallySpacedGlyphsContainingIndex(index);

            if kerningRange.length > 1 && kerningRange.location == index {
                if countElements(characterTextLayers) > 0 {
                    var previousLayer = characterTextLayers[characterTextLayers.endIndex-1]
                    var frame = previousLayer.frame
                    frame.size.width += CGRectGetMaxX(glyphRect)-CGRectGetMaxX(frame)
                    previousLayer.frame = frame
                }
            }


            glyphRect.origin.y += location.y-(glyphRect.height/2)+(self.bounds.size.height/2)-(layoutRect.size.height/2);


            var textLayer = CATextLayer(frame: glyphRect, string: attributedString.attributedSubstringFromRange(characterRange));
            initialTextLayerAttributes(textLayer)

            layer.addSublayer(textLayer);
            characterTextLayers.append(textLayer);

            index += characterRange.length;
        }
    }
}

Congrats you made it! Now that all of the heavy lifting is in place. You should have a fully functional label that displays text just like a UILabel, except you now how the power to manipulate every glyph!

Reimagine Secret:

For the re-imagination of Secret I really wanted to showcase the the fading feature so that everyone can see it. So I decided to stick with a UICollectionView, but instead make it full screen and change swiping from Vertical to Horizontal. So now we get something like this:

But how do we create that effect?! Ok, ok you have waited long enough lets implement the fading.

We are going to create a subclass of CharacterLabel.

class FadingLabel: CharacterLabel {
}

We are going to override set attributed text. Inside the setter we are going call super and then two animation methods.

 override var attributedText: NSAttributedString! {
get {
    return super.attributedText
}

set {
    super.attributedText = newValue
    self.animateOut() { finished in
        self.animateIn(nil);
    }
}

}

I have created two functions animateIn and animateOut, both taking completion closures so that we can order them.

func animateIn(completion: ((finished: Bool) -> Void)?) {
}

func animateOut(completion: ((finished: Bool) -> Void)?) {
}

AnimateOut:
To animate out we are going to iterate over every character in our character array, generating a duration and delay.

We also want to store the longest running animation so that we can call the completion block.

 func animateOut(completion: ((finished: Bool) -> Void)?) {
    var longestAnimation = 0.0
    var longestAnimationIndex = -1
    var index = 0

    for textLayer in oldCharacterTextLayers {
        ++index
    }
}

Next I wrote a little helper class to give me block based CABasicAnimation’s. Feel free to use it if you like, but it is not complete so you will have to dive into the code and tweak it to make it work for all your properties. Once I make it more robust I hope to open source it.

In the animation block we set the opacity to 0.

In the completion block we will remove the textLayer from the backingLayer and then check to see if we should call the completion block.

let duration = (NSTimeInterval(arc4random()%100)/200.0)+0.25
let delay = NSTimeInterval(arc4random()%100)/500.0

if duration+delay > longestAnimation {
    longestAnimation = duration+delay
    longestAnimationIndex = index
   }
CLMLayerAnimation.animation(textLayer, duration:duration, delay:delay, animations: {
    textLayer.opacity = 0;
}, completion:{ finished in
    textLayer.removeFromSuperlayer()
    if textLayer == self.oldCharacterTextLayers[longestAnimationIndex] {
      if let completionFunction = completion? {
        completionFunction(finished: finished)
        }
    }
})

Thats animate out, animate in is even simpler

Animate In:
The animate in we simply need to generate a duration and delay. Use our animation helper to set the opacity to 1.

func animateIn(completion: ((finished: Bool) -> Void)?) {

    for textLayer in characterTextLayers {

        var duration = (NSTimeInterval(arc4random()%100)/200.0)+0.25
        var delay = NSTimeInterval(arc4random()%100)/500.0

        CLMLayerAnimation.animation(textLayer, duration:duration, delay:delay, animations: {
            textLayer.opacity = 1;
        }, completion:nil)

    }
}

… and you are done.

The very last thing we need to add is an override function to set the initial attributes when our text layers are created.

In this we are simply going to set the opacity to 0 so that we can fade in our animateIn function.

override func initialTextLayerAttributes(textLayer: CATextLayer) {
    textLayer.opacity = 0
}

OK, now you are really done. If you fire it and run you should have some nice fading text!

This is great we finished the Secret Text, but you still might be wondering why all this work with CATextLayers, you can do this with NSAttributedString like TOMSMotionString (which is great btw), but I want to go beyond that and expand the effect. Something like this:

We can even replicate the iOS8 autocomplete effect:

Hopefully you can see the possibilities with the ability to control each glyph. I have included both on github, so feel free to check out the demo code.

Swift: https://github.com/android1989/CharacterText

Objective-C: https://github.com/android1989/YetiCharacterLabelExample

If you see a cool iOS effect or want me to talk about a specific problem, feel free to tweet me or email me.

Disclaimer: At the time of this post, Swift has not reached a 1.0. Things are subject to change!