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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Core Text Tutorial for iOS: Making a Magazine App
35 mins
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:

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 = String(tag[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)
    }
  }
}
- If tagstarts with "img", use a regex to search for the image's "src" value, i.e. the filename.
- Set the image width to the width of the column and set its height so the image maintains its height-width aspect ratio.
- 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))
- Append an Dictionarycontaining the image's size, filename and text location toimages.
-  Define RunStructto hold the properties that will delineate the empty spaces. Then initialize a pointer to contain aRunStructwith anascentequal to the image height and awidthproperty equal to the image width.
-  Create a CTRunDelegateCallbacksthat returns the ascent, descent and width properties belonging to pointers of typeRunStruct.
- Use CTRunDelegateCreateto create a delegate instance binding the callbacks and the data parameter together.
- Create an attributed dictionary containing the delegate instance, then append a single space to attrStringwhich 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 {
        }
    }
  }
}
- Get an array of ctframe'sCTLineobjects.
- Use CTFrameGetOriginsto copyctframe's line origins into theoriginsarray. By setting a range with a length of 0,CTFrameGetOriginswill know to traverse the entireCTFrame.
- Set nextImageto contain the attributed data of the current image. IfnextImagecontain's the image's location, unwrap it and continue; otherwise, return early.
- Loop through the text's lines.
- 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
}
- If the range of the present run does not contain the next image, skip the rest of the loop. Otherwise, render the image here.
- Calculate the image width using CTRunGetTypographicBoundsand set the height to the found ascent.
- Get the line's x offset with CTLineGetOffsetForStringIndexthen add it to theimgBounds' origin.
- Add the image and its frame to the current CTColumnView.
- Increment the image index. If there's an image at images[imageIndex], update nextImageandimgLocationso they refer to that next image.
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! :]

