Episode #209

Cool Text Effects

21 minutes
Published on February 19, 2016

This video is only available to subscribers. Get access to this video and 584 others.

In this episode we dive deeper into the text system, leveraging CoreText to compute exact metrics about each glyph in a string. We can use this information to do interesting things with text. There's a lot of code in this one, but you'll learn the basic foundations of text, how CoreText works, and how to extract metrics and paths from your rendered text. We'll use this in a future episode to implement some interesting effects!

Episode Links

  • Source Code
  • iOS Fonts - a great resource of fonts with preview, including when they became available on iOS platforms as well as their official family name for creating from code
  • Swift: Interacting with C APIs - This is a useful reference when dealing with C APIs from Swift. There’s a lot to get tripped up on here, so keep this handy if you get stuck.
  • Typographical Concepts - This is a must-read to understand the core concepts of all of the data you can extract about text.
  • Core Text Overview

We're creating a TextEffectView subclass that will contain all of our CoreText code. This will look like a UILabel, but we won't subclass, because that is entering a world of pain.

Setting up properties

    public var font: UIFont! {
        didSet {
            createGlyphLayers()
            setNeedsDisplay()
        }
    }

    public var text: String?{
        didSet {
            createGlyphLayers()
            setNeedsDisplay()
        }
    }

    public var textColor: UIColor! {
        didSet {
            createGlyphLayers()
            setNeedsDisplay()
        }
    }

Processing the text

    func computeLetterPaths(attributedString: NSAttributedString) {
        letterPaths = []
        letterPositions = []
        lineRects = []

        let frameSetter = CTFramesetterCreateWithAttributedString(attributedString)
        let textPath = CGPathCreateWithRect(bounds, nil)
        let textFrame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), textPath, nil)

        let lines = CTFrameGetLines(textFrame)
        var origins = [CGPoint](count: CFArrayGetCount(lines), repeatedValue: CGPointZero)
        CTFrameGetLineOrigins(textFrame, CFRangeMake(0, 0), &origins)

        for (var lineIndex = 0; lineIndex < CFArrayGetCount(lines); lineIndex++) {
            let unmanagedLine: UnsafePointer<Void> = CFArrayGetValueAtIndex(lines, lineIndex)
            let line: CTLineRef = unsafeBitCast(unmanagedLine, CTLineRef.self)
            var lineOrigin = origins[lineIndex]
            let lineBounds = CTLineGetBoundsWithOptions(line, CTLineBoundsOptions.UseGlyphPathBounds)
            lineRects.append(lineBounds)

            // adjust origin for flipped coordinate system
            lineOrigin.y = -CGRectGetHeight(lineBounds)

            let runs = CTLineGetGlyphRuns(line)
            for (var runIndex = 0; runIndex < CFArrayGetCount(runs); runIndex++) {
                let runPointer = CFArrayGetValueAtIndex(runs, runIndex)
                let run = unsafeBitCast(runPointer, CTRunRef.self)
                let attribs = CTRunGetAttributes(run)
                let fontPointer = CFDictionaryGetValue(attribs, unsafeAddressOf(kCTFontAttributeName))
                let font = unsafeBitCast(fontPointer, CTFontRef.self)

                let glyphCount = CTRunGetGlyphCount(run)
                var ascents = [CGFloat](count: glyphCount, repeatedValue: 0)
                var descents = [CGFloat](count: glyphCount, repeatedValue: 0)
                var leading = [CGFloat](count: glyphCount, repeatedValue: 0)
                CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascents, &descents, &leading)

                for (var glyphIndex = 0; glyphIndex < glyphCount; glyphIndex++) {
                    let glyphRange = CFRangeMake(glyphIndex, 1)
                    var glyph = CGGlyph()
                    var position = CGPointZero
                    CTRunGetGlyphs(run, glyphRange, &glyph)
                    CTRunGetPositions(run, glyphRange, &position)
                    position.y = lineOrigin.y

                    if let path = CTFontCreatePathForGlyph(font, glyph, nil) {
                        letterPaths.append(UIBezierPath(CGPath: path))
                        letterPositions.append(position)
                    }
                }
            }
        }
    }

Creating the Layers

    func createGlyphLayers() {
        assert(NSThread.isMainThread())
        guard let text = self.text else { return }

        if let sublayers = self.layer.sublayers {
            for sublayer in sublayers {
                sublayer.removeAllAnimations()
                sublayer.removeFromSuperlayer()
            }
        }

        let ctFont = CTFontCreateWithName(font.fontName, font.pointSize, nil)
        let attributedString = NSAttributedString(string: text,
            attributes: [
                (kCTFontAttributeName as String): ctFont
            ])
        computeLetterPaths(attributedString)

        let containerLayer = CALayer()
        containerLayer.geometryFlipped = true
        layer.addSublayer(containerLayer)

        for (index, path) in letterPaths.enumerate() {
            let pos = letterPositions[index]

            // create shape layer for this glyph
            let glyphLayer = CAShapeLayer()
            glyphLayer.path = path.CGPath
            glyphLayer.fillColor = textColor.CGColor
            glyphLayer.strokeColor = UIColor.redColor().CGColor

            let jitter = (CGFloat(arc4random_uniform(10)) - 5.0) / 10.0 + 1.0
            glyphLayer.transform = CATransform3DMakeScale(jitter, jitter, 1.0)

            var glyphFrame = glyphLayer.bounds
            glyphFrame.origin = pos
            glyphLayer.frame = glyphFrame
            containerLayer.addSublayer(glyphLayer)
        }
    }