Core Text Tutorial for iOS: Making a Magazine App

Learn how to make your own magazine app with custom text layout in this Core Text tutorial for iOS. By Lyndsey Scott.

Leave a rating/review
Save for later
Share

Update note: This tutorial has been updated to Swift 4 and Xcode 9 by Lyndsey Scott. The original tutorial was written by Marin Todorov.

Core Text is a low-level text engine that when used alongside the Core Graphics/Quartz framework, gives you fine-grained control over layout and formatting.

With iOS 7, Apple released a high-level library called Text Kit, which stores, lays out and displays text with various typesetting characteristics. Although Text Kit is powerful and usually sufficient when laying out text, Core Text can provide more control. For example, if you need to work directly with Quartz, use Core Text. If you need to build your own layout engines, Core Text will help you generate “glyphs and position them relative to each other with all the features of fine typesetting.”

This tutorial takes you through the process of creating a very simple magazine application using Core Text… for Zombies!

Oh, and Zombie Monthly’s readership has kindly agreed not to eat your brains as long as you’re busy using them for this tutorial… So you may want to get started soon! *gulp*

Note: To get the most out of this tutorial, you need to know the basics of iOS development first. If you’re new to iOS development, you should check out some of the other tutorials on this site first.

Getting Started

Open Xcode, create a new Swift universal project with the Single View Application Template and name it CoreTextMagazine.

Next, add the Core Text framework to your project:

  1. Click the project file in the Project navigator (the strip on the left hand side)
  2. Under “General”, scroll down to “Linked Frameworks and Libraries” at the bottom
  3. Click the “+” and search for “CoreText”
  4. Select “CoreText.framework” and click the “Add” button. That’s it!

Now the project is setup, it’s time to start coding.

Adding a Core Text View

For starters, you’ll create a custom UIView, which will use Core Text in its draw(_:) method.

Create a new Cocoa Touch Class file named CTView subclassing UIView .

Open CTView.swift, and add the following under import UIKit:

import CoreText

Next, set this new custom view as the main view in the application. Open Main.storyboard, open the Utilities menu on the right-hand side, then select the Identity Inspector icon in its top toolbar. In the left-hand menu of the Interface Builder, select View. The Class field of the Utilities menu should now say UIView. To subclass the main view controller’s view, type CTView into the Class field and hit Enter.

Next, open CTView.swift and replace the commented out draw(_:) with the following:

	 	 
//1	 	 
override func draw(_ rect: CGRect) {	 	 
  // 2	 	 
  guard let context = UIGraphicsGetCurrentContext() else { return }	 	 
  // 3	 	 
  let path = CGMutablePath()	 	 
  path.addRect(bounds)	 	 
  // 4
  let attrString = NSAttributedString(string: "Hello World")
  // 5
  let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
  // 6
  let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attrString.length), path, nil) 
  // 7
  CTFrameDraw(frame, context)
}

Let’s go over this step-by-step.

  1. Upon view creation, draw(_:) will run automatically to render the view’s backing layer.
  2. Unwrap the current graphic context you’ll use for drawing.
  3. Create a path which bounds the drawing area, the entire view’s bounds in this case
  4. In Core Text, you use NSAttributedString, as opposed to String or NSString, to hold the text and its attributes. Initialize “Hello World” as an attributed string.
  5. CTFramesetterCreateWithAttributedString creates a CTFramesetter with the supplied attributed string. CTFramesetter will manage your font references and your drawing frames.
  6. Create a CTFrame, by having CTFramesetterCreateFrame render the entire string within path.
  7. CTFrameDraw draws the CTFrame in the given context.

That’s all you need to draw some simple text! Build, run and see the result.

Uh-oh… That doesn’t seem right, does it? Like many of the low level APIs, Core Text uses a Y-flipped coordinate system. To make matters worse, the content is also flipped vertically!

Add the following code directly below the guard let context statement to fix the content orientation:

// Flip the coordinate system
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)

This code flips the content by applying a transformation to the view’s context.

Build and run the app. Don’t worry about status bar overlap, you’ll learn how to fix this with margins later.

Congrats on your first Core Text app! The zombies are pleased with your progress.

The Core Text Object Model

If you’re a bit confused about the CTFramesetter and the CTFrame – that’s OK because it’s time for some clarification. :]

Here’s what the Core Text object model looks like:

Core Text Class Hierarchy

When you create a CTFramesetter reference and provide it with an NSAttributedString, an instance of CTTypesetter is automatically created for you to manage your fonts. Next you use the CTFramesetter to create one or more frames in which you’ll be rendering text.

When you create a frame, you provide it with the subrange of text to render inside its rectangle. Core Text automatically creates a CTLine for each line of text and a CTRun for each piece of text with the same formatting. For example, Core Text would create a CTRun if you had several words in a row colored red, then another CTRun for the following plain text, then another CTRun for a bold sentence, etc. Core Text creates CTRuns for you based on the attributes of the supplied NSAttributedString. Furthermore, each of these CTRun objects can adopt different attributes, so you have fine control over kerning, ligatures, width, height and more.

Onto the Magazine App!

Download and unarchive the zombie magazine materials.
Drag the folder into your Xcode project. When prompted make sure Copy items if needed and Create groups are selected.

To create the app, you’ll need to apply various attributes to the text. You’ll create a simple text markup parser which will use tags to set the magazine’s formatting.

Create a new Cocoa Touch Class file named MarkupParser subclassing NSObject.

First things first, take a quick look at zombies.txt. See how it contains bracketed formatting tags throughout the text? The “img src” tags reference magazine images and the “font color/face” tags determine text color and font.

Open MarkupParser.swift and replace its contents with the following:

import UIKit
import CoreText

class MarkupParser: NSObject {
  
  // MARK: - Properties
  var color: UIColor = .black
  var fontName: String = "Arial"
  var attrString: NSMutableAttributedString!
  var images: [[String: Any]] = []

  // MARK: - Initializers
  override init() {
    super.init()
  }
  
  // MARK: - Internal
  func parseMarkup(_ markup: String) {

  }
}

Here you’ve added properties to hold the font and text color; set their defaults; created a variable to hold the attributed string produced by parseMarkup(_:); and created an array which will eventually hold the dictionary information defining the size, location and filename of images found within the text.

Writing a parser is usually hard work, but this tutorial’s parser will be very simple and support only opening tags — meaning a tag will set the style of the text following it until a new tag is found. The text markup will look like this:

These are <font color="red">red<font color="black"> and
<font color="blue">blue <font color="black">words.

and produce output like this:

These are red and blue words.

Lets’ get parsin’!

Add the following to parseMarkup(_:):

//1
attrString = NSMutableAttributedString(string: "")
//2 
do {
  let regex = try NSRegularExpression(pattern: "(.*?)(<[^>]+>|\\Z)",
                                      options: [.caseInsensitive,
                                                .dotMatchesLineSeparators])
  //3
  let chunks = regex.matches(in: markup, 
                             options: NSRegularExpression.MatchingOptions(rawValue: 0), 
                             range: NSRange(location: 0,
                                            length: markup.characters.count))
} catch _ {
}
  1. attrString starts out empty, but will eventually contain the parsed markup.
  2. This regular expression, matches blocks of text with the tags immediately follow them. It says, “Look through the string until you find an opening bracket, then look through the string until you hit a closing bracket (or the end of the document).”
  3. Search the entire range of the markup for regex matches, then produce an array of the resulting NSTextCheckingResults.

Note: To learn more about regular expressions, check out NSRegularExpression Tutorial.

Now you’ve parsed all the text and formatting tags into chunks, you’ll loop through chunks to build the attributed string.

But before that, did you notice how matches(in:options:range:) accepts an NSRange as an argument? There’s going to be lots of NSRange to Range conversions as you apply NSRegularExpression functions to your markup String. Swift’s been a pretty good friend to us all, so it deserves a helping hand.

Still in MarkupParser.swift, add the following extension to the end of the file:

// MARK: - String
extension String {
  func range(from range: NSRange) -> Range<String.Index>? {
    guard let from16 = utf16.index(utf16.startIndex,
                                   offsetBy: range.location,
                                   limitedBy: utf16.endIndex),
      let to16 = utf16.index(from16, offsetBy: range.length, limitedBy: utf16.endIndex),
      let from = String.Index(from16, within: self),
      let to = String.Index(to16, within: self) else {
        return nil
   }

    return from ..< to
  }
}

This function converts the String's starting and ending indices as represented by an NSRange, to String.UTF16View.Index format, i.e. the positions in a string’s collection of UTF-16 code units; then converts each String.UTF16View.Index to String.Index format; which when combined, produces Swift's range format: Range. As long as the indices are valid, the method will return the Range representation of the original NSRange.

Your Swift is now chill. Time to head back to processing the text and tag chunks.

Inside parseMarkup(_:) add the following below let chunks (within the do block):

let defaultFont: UIFont = .systemFont(ofSize: UIScreen.main.bounds.size.height / 40)
//1
for chunk in chunks {  
  //2
  guard let markupRange = markup.range(from: chunk.range) else { continue }
  //3    
  let parts = markup[markupRange].components(separatedBy: "<")
  //4
  let font = UIFont(name: fontName, size: UIScreen.main.bounds.size.height / 40) ?? defaultFont       
  //5
  let attrs = [NSAttributedStringKey.foregroundColor: color, NSAttributedStringKey.font: font] as [NSAttributedStringKey : Any]
  let text = NSMutableAttributedString(string: parts[0], attributes: attrs)
  attrString.append(text)
}
  1. Loop through chunks.
  2. Get the current NSTextCheckingResult's range, unwrap the Range<String.Index> and proceed with the block as long as it exists.
  3. Break chunk into parts separated by "<". The first part contains the magazine text and the second part contains the tag (if it exists).
  4. Create a font using fontName, currently "Arial" by default, and a size relative to the device screen. If fontName doesn't produce a valid UIFont, set font to the default font.
  5. Create a dictionary of the font format, apply it to parts[0] to create the attributed string, then append that string to the result string.

To process the "font" tag, insert the following after attrString.append(text):

// 1
if parts.count <= 1 {
  continue
}
let tag = parts[1]
//2
if tag.hasPrefix("font") {
  let colorRegex = try NSRegularExpression(pattern: "(?<=color=\")\\w+", 
                                           options: NSRegularExpression.Options(rawValue: 0))
  colorRegex.enumerateMatches(in: tag, 
    options: NSRegularExpression.MatchingOptions(rawValue: 0), 
    range: NSMakeRange(0, tag.characters.count)) { (match, _, _) in
      //3
      if let match = match,
        let range = tag.range(from: match.range) {
          let colorSel = NSSelectorFromString(tag[range]+"Color")
          color = UIColor.perform(colorSel).takeRetainedValue() as? UIColor ?? .black
      }
  }
  //5    
  let faceRegex = try NSRegularExpression(pattern: "(?<=face=\")[^\"]+",
                                          options: NSRegularExpression.Options(rawValue: 0))
  faceRegex.enumerateMatches(in: tag, 
    options: NSRegularExpression.MatchingOptions(rawValue: 0), 
    range: NSMakeRange(0, tag.characters.count)) { (match, _, _) in

      if let match = match,
        let range = tag.range(from: match.range) {
          fontName = String(tag[range])
      }
  }
} //end of font parsing
  1. If less than two parts, skip the rest of the loop body. Otherwise, store that second part as tag.
  2. If tag starts with "font", create a regex to find the font's "color" value, then use that regex to enumerate through tag's matching "color" values. In this case, there should be only one matching color value.
  3. If enumerateMatches(in:options:range:using:) returns a valid match with a valid range in tag, find the indicated value (ex. <font color="red"> returns "red") and append "Color" to form a UIColor selector. Perform that selector then set your class's color to the returned color if it exists, to black if not.
  4. Similarly, create a regex to process the font's "face" value. If it finds a match, set fontName to that string.

Great job! Now parseMarkup(_:) can take markup and produce an NSAttributedString for Core Text.

It's time to feed your app to some zombies! I mean, feed some zombies to your app... zombies.txt, that is. ;]

It's actually the job of a UIView to display content given to it, not load content. Open CTView.swift and add the following above draw(_:):

// MARK: - Properties
var attrString: NSAttributedString!

// MARK: - Internal
func importAttrString(_ attrString: NSAttributedString) {
  self.attrString = attrString
}

Next, delete let attrString = NSAttributedString(string: "Hello World") from draw(_:).

Here you've created an instance variable to hold an attributed string and a method to set it from elsewhere in your app.

Next, open ViewController.swift and add the following to viewDidLoad():

// 1
guard let file = Bundle.main.path(forResource: "zombies", ofType: "txt") else { return }
  
do {
  let text = try String(contentsOfFile: file, encoding: .utf8)
  // 2
  let parser = MarkupParser()
  parser.parseMarkup(text)
  (view as? CTView)?.importAttrString(parser.attrString)
} catch _ {
}

Let’s go over this step-by-step.

  1. Load the text from the zombie.txt file into a String.
  2. Create a new parser, feed in the text, then pass the returned attributed string to ViewController's CTView.

Build and run the app!

That's awesome? Thanks to about 50 lines of parsing you can simply use a text file to hold the contents of your magazine app.