Quantcast
Channel: Kodeco | High quality programming tutorials: iOS, Android, Swift, Kotlin, Unity, and more
Viewing all articles
Browse latest Browse all 4387

Core Text Tutorial for iOS: Making a Magazine App

$
0
0

Magazines, Core Text and Brains!

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.substring(with: 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.substring(with: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 = tag.substring(with: 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.

A Basic Magazine Layout

If you thought a monthly magazine of Zombie news could possibly fit onto one measly page, you'd be very wrong! Luckily Core Text becomes particularly useful when laying out columns since CTFrameGetVisibleStringRange can tell you how much text will fit into a given frame. Meaning, you can create a column, then once its full, you can create another column, etc.

For this app, you'll have to print columns, then pages, then a whole magazine lest you offend the undead, so... time to turn your CTView subclass into a UIScrollView.

Open CTView.swift and change the class CTView line to:

class CTView: UIScrollView {

See that, zombies? The app can now support an eternity of undead adventures! Yep -- with one line, scrolling and paging is now available.

happy zombie

Up until now, you've created your framesetter and frame inside draw(_:), but since you'll have many columns with different formatting, it's better to create individual column instances instead.

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

Open CTColumnView.swift and add the following starter code:

import UIKit
import CoreText

class CTColumnView: UIView {

  // MARK: - Properties
  var ctFrame: CTFrame!

  // MARK: - Initializers
  required init(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)!
  }

  required init(frame: CGRect, ctframe: CTFrame) {
    super.init(frame: frame)
    self.ctFrame = ctframe
    backgroundColor = .white
  }

  // MARK: - Life Cycle
  override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext() else { return }

    context.textMatrix = .identity
    context.translateBy(x: 0, y: bounds.size.height)
    context.scaleBy(x: 1.0, y: -1.0)

    CTFrameDraw(ctFrame, context)
  }
}

This code renders a CTFrame just as you'd originally done in CTView. The custom initializer, init(frame:ctframe:), sets:

  1. The view's frame.
  2. The CTFrame to draw into the context.
  3. And the view's backgound color to white.

Next, create a new swift file named CTSettings.swift which will hold your column settings.

Replace the contents of CTSettings.swift with the following:

import UIKit
import Foundation

class CTSettings {
  //1
  // MARK: - Properties
  let margin: CGFloat = 20
  var columnsPerPage: CGFloat!
  var pageRect: CGRect!
  var columnRect: CGRect!

  // MARK: - Initializers
  init() {
    //2
    columnsPerPage = UIDevice.current.userInterfaceIdiom == .phone ? 1 : 2
    //3
    pageRect = UIScreen.main.bounds.insetBy(dx: margin, dy: margin)
    //4
    columnRect = CGRect(x: 0,
                        y: 0,
                        width: pageRect.width / columnsPerPage,
                        height: pageRect.height).insetBy(dx: margin, dy: margin)
  }
}
  1. The properties will determine the page margin (default of 20 for this tutorial); the number of columns per page; the frame of each page containing the columns; and the frame size of each column per page.
  2. Since this magazine serves both iPhone and iPad carrying zombies, show two columns on iPad and one column on iPhone so the number of columns is appropriate for each screen size.
  3. Inset the entire bounds of the page by the size of the margin to calculate pageRect.
  4. Divide pageRect's width by the number of columns per page and inset that new frame with the margin for columnRect.

Open, CTView.swift, replace the entire contents with the following:

import UIKit
import CoreText

class CTView: UIScrollView {

  //1
  func buildFrames(withAttrString attrString: NSAttributedString,
                   andImages images: [[String: Any]]) {
    //3
    isPagingEnabled = true
    //4
    let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
    //4
    var pageView = UIView()
    var textPos = 0
    var columnIndex: CGFloat = 0
    var pageIndex: CGFloat = 0
    let settings = CTSettings()
    //5
    while textPos < attrString.length {
    }
  }
}
  1. buildFrames(withAttrString:andImages:) will create CTColumnViews then add them to the scrollview.
  2. Enable the scrollview's paging behavior; so, whenever the user stops scrolling, the scrollview snaps into place so exactly one entire page is showing at a time.
  3. CTFramesetter framesetter will create each column's CTFrame of attributed text.
  4. UIView pageViews will serve as a container for each page's column subviews; textPos will keep track of the next character; columnIndex will keep track of the current column; pageIndex will keep track of the current page; and settings gives you access to the app's margin size, columns per page, page frame and column frame settings.
  5. You're going to loop through attrString and lay out the text column by column, until the current text position reaches the end.

Time to start looping attrString. Add the following within while textPos < attrString.length {.:

//1
if columnIndex.truncatingRemainder(dividingBy: settings.columnsPerPage) == 0 {
  columnIndex = 0
  pageView = UIView(frame: settings.pageRect.offsetBy(dx: pageIndex * bounds.width, dy: 0))
  addSubview(pageView)
  //2
  pageIndex += 1
}
//3
let columnXOrigin = pageView.frame.size.width / settings.columnsPerPage
let columnOffset = columnIndex * columnXOrigin
let columnFrame = settings.columnRect.offsetBy(dx: columnOffset, dy: 0)
  1. If the column index divided by the number of columns per page equals 0, thus indicating the column is the first on its page, create a new page view to hold the columns. To set its frame, take the margined settings.pageRect and offset its x origin by the current page index multiplied by the width of the screen; so within the paging scrollview, each magazine page will be to the right of the previous one.
  2. Increment the pageIndex.
  3. Divide pageView's width by settings.columnsPerPage to get the first column's x origin; multiply that origin by the column index to get the column offset; then create the frame of the current column by taking the standard columnRect and offsetting its x origin by columnOffset.

Next, add the following below columnFrame initialization:

//1
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: columnFrame.size))
let ctframe = CTFramesetterCreateFrame(framesetter, CFRangeMake(textPos, 0), path, nil)
//2
let column = CTColumnView(frame: columnFrame, ctframe: ctframe)
pageView.addSubview(column)
//3
let frameRange = CTFrameGetVisibleStringRange(ctframe)
textPos += frameRange.length
//4
columnIndex += 1
  1. Create a CGMutablePath the size of the column, then starting from textPos, render a new CTFrame with as much text as can fit.
  2. Create a CTColumnView with a CGRect columnFrame and CTFrame ctframe then add the column to pageView.
  3. Use CTFrameGetVisibleStringRange(_:) to calculate the range of text contained within the column, then increment textPos by that range length to reflect the current text position.
  4. Increment the column index by 1 before looping to the next column.

Lastly set the scroll view's content size after the loop:

contentSize = CGSize(width: CGFloat(pageIndex) * bounds.size.width,
                     height: bounds.size.height)

By setting the content size to the screen width times the number of pages, the zombies can now scroll through to the end.

Open ViewController.swift, and replace

(view as? CTView)?.importAttrString(parser.attrString)

with the following:

(view as? CTView)?.buildFrames(withAttrString: parser.attrString, andImages: parser.images)

Build and run the app on an iPad. Check that double column layout! Drag right and left to go between pages. Lookin' good. :]

You've columns and formatted text, but you're missing images. Drawing images with Core Text isn't so straightforward - it's a text framework after all - but with the help of the markup parser you've already created, adding images shouldn't be too bad.

Drawing Images in Core Text

Although Core Text can't draw images, as a layout engine, it can leave empty spaces to make room for images. By setting a CTRun's delegate, you can determine that CTRun's ascent space, descent space and width. Like so:

Adding a CTRunDelegate to control ascent and descent spacing

When Core Text reaches a CTRun with a CTRunDelegate it asks the delegate, "How much space should I leave for this chunk of data?" By setting these properties in the CTRunDelegate, you can leave holes in the text for your images.

First add support for the "img" tag. Open MarkupParser.swift and find "} //end of font parsing". Add the following immediately after:

//1
else if tag.hasPrefix("img") {

  var filename:String = ""
  let imageRegex = try NSRegularExpression(pattern: "(?<=src=\")[^\"]+",
                                           options: NSRegularExpression.Options(rawValue: 0))
  imageRegex.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) {
        filename = tag.substring(with: range)
    }
  }
  //2
  let settings = CTSettings()
  var width: CGFloat = settings.columnRect.width
  var height: CGFloat = 0

  if let image = UIImage(named: filename) {
    height = width * (image.size.height / image.size.width)
    // 3
    if height > settings.columnRect.height - font.lineHeight {
      height = settings.columnRect.height - font.lineHeight
      width = height * (image.size.width / image.size.height)
    }
  }
}
  1. If tag starts with "img", use a regex to search for the image's "src" value, i.e. the filename.
  2. Set the image width to the width of the column and set its height so the image maintains its height-width aspect ratio.
  3. If the height of the image is too long for the column, set the height to fit the column and reduce the width to maintain the image's aspect ratio. Since the text following the image will contain the empty space attribute, the text containing the empty space information must fit within the same column as the image; so set the image height to settings.columnRect.height - font.lineHeight.

Next, add the following immediately after the if let image block:

//1
images += [["width": NSNumber(value: Float(width)),
            "height": NSNumber(value: Float(height)),
            "filename": filename,
            "location": NSNumber(value: attrString.length)]]
//2
struct RunStruct {
  let ascent: CGFloat
  let descent: CGFloat
  let width: CGFloat
}

let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: height, descent: 0, width: width))
//3
var callbacks = CTRunDelegateCallbacks(version: kCTRunDelegateVersion1, dealloc: { (pointer) in
}, getAscent: { (pointer) -> CGFloat in
  let d = pointer.assumingMemoryBound(to: RunStruct.self)
  return d.pointee.ascent
}, getDescent: { (pointer) -> CGFloat in
  let d = pointer.assumingMemoryBound(to: RunStruct.self)
  return d.pointee.descent
}, getWidth: { (pointer) -> CGFloat in
  let d = pointer.assumingMemoryBound(to: RunStruct.self)
  return d.pointee.width
})
//4
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
//5
let attrDictionaryDelegate = [(kCTRunDelegateAttributeName as NSAttributedStringKey): (delegate as Any)]
attrString.append(NSAttributedString(string: " ", attributes: attrDictionaryDelegate))
  1. Append an Dictionary containing the image's size, filename and text location to images.
  2. Define RunStruct to hold the properties that will delineate the empty spaces. Then initialize a pointer to contain a RunStruct with an ascent equal to the image height and a width property equal to the image width.
  3. Create a CTRunDelegateCallbacks that returns the ascent, descent and width properties belonging to pointers of type RunStruct.
  4. Use CTRunDelegateCreate to create a delegate instance binding the callbacks and the data parameter together.
  5. Create an attributed dictionary containing the delegate instance, then append a single space to attrString which holds the position and sizing information for the hole in the text.

Now MarkupParser is handling "img" tags, you'll need to adjust CTColumnView and CTView to render them.

Open CTColumnView.swift. Add the following below var ctFrame:CTFrame! to hold the column's images and frames:

var images: [(image: UIImage, frame: CGRect)] = []

Next, add the following to the bottom of draw(_:):

for imageData in images {
  if let image = imageData.image.cgImage {
    let imgBounds = imageData.frame
    context.draw(image, in: imgBounds)
  }
}

Here you loop through each image and draw it into the context within its proper frame.

Next open CTView.swift and the following property to the top of the class:

// MARK: - Properties
var imageIndex: Int!

imageIndex will keep track of the current image index as you draw the CTColumnViews.

Next, add the following to the top of buildFrames(withAttrString:andImages:):

imageIndex = 0

This marks the first element of the images array.

Next add the following, attachImagesWithFrame(_:ctframe:margin:columnView), below buildFrames(withAttrString:andImages:):

func attachImagesWithFrame(_ images: [[String: Any]],
                           ctframe: CTFrame,
                           margin: CGFloat,
                           columnView: CTColumnView) {
  //1
  let lines = CTFrameGetLines(ctframe) as NSArray
  //2
  var origins = [CGPoint](repeating: .zero, count: lines.count)
  CTFrameGetLineOrigins(ctframe, CFRangeMake(0, 0), &origins)
  //3
  var nextImage = images[imageIndex]
  guard var imgLocation = nextImage["location"] as? Int else {
    return
  }
  //4
  for lineIndex in 0..<lines.count {
    let line = lines[lineIndex] as! CTLine
    //5
    if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun],
      let imageFilename = nextImage["filename"] as? String,
      let img = UIImage(named: imageFilename)  {
        for run in glyphRuns {

        }
    }
  }
}
  1. Get an array of ctframe's CTLine objects.
  2. Use CTFrameGetOrigins to copy ctframe's line origins into the origins array. By setting a range with a length of 0, CTFrameGetOrigins will know to traverse the entire CTFrame.
  3. Set nextImage to contain the attributed data of the current image. If nextImage contain's the image's location, unwrap it and continue; otherwise, return early.
  4. Loop through the text's lines.
  5. If the line's glyph runs, filename and image with filename all exist, loop through the glyph runs of that line.

Next, add the following inside the glyph run for-loop:

// 1
let runRange = CTRunGetStringRange(run)
if runRange.location > imgLocation || runRange.location + runRange.length <= imgLocation {
  continue
}
//2
var imgBounds: CGRect = .zero
var ascent: CGFloat = 0
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil))
imgBounds.size.height = ascent
//3
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
imgBounds.origin.x = origins[lineIndex].x + xOffset
imgBounds.origin.y = origins[lineIndex].y
//4
columnView.images += [(image: img, frame: imgBounds)]
//5
imageIndex! += 1
if imageIndex < images.count {
  nextImage = images[imageIndex]
  imgLocation = (nextImage["location"] as AnyObject).intValue
}
  1. If the range of the present run does not contain the next image, skip the rest of the loop. Otherwise, render the image here.
  2. Calculate the image width using CTRunGetTypographicBounds and set the height to the found ascent.
  3. Get the line's x offset with CTLineGetOffsetForStringIndex then add it to the imgBounds' origin.
  4. Add the image and its frame to the current CTColumnView.
  5. Increment the image index. If there's an image at images[imageIndex], update nextImage and imgLocation so they refer to that next image.

Image depicting the margin, pageRect and line origin information from the above explanation

OK! Great! Almost there - one final step.

Add the following right above pageView.addSubview(column) inside buildFrames(withAttrString:andImages:) to attach images if they exist:

if images.count > imageIndex {
  attachImagesWithFrame(images, ctframe: ctframe, margin: settings.margin, columnView: column)
}

Build and run on both iPhone and iPad!

Congrats! As thanks for all that hard work, the zombies have spared your brains! :]

Where to Go From Here?

Check out the finished project here.

As mentioned in the intro, Text Kit can usually replace Core Text; so try writing this same tutorial with Text Kit to see how it compares. That said, this Core Text lesson won't be in vain! Text Kit offers toll free bridging to Core Text so you can easily cast between the frameworks as needed.

Have any questions, comments or suggestions? Join in the forum discussion below!

The post Core Text Tutorial for iOS: Making a Magazine App appeared first on Ray Wenderlich.


Viewing all articles
Browse latest Browse all 4387

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>