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
tag
starts 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
Dictionary
containing the image's size, filename and text location toimages
. - Define
RunStruct
to hold the properties that will delineate the empty spaces. Then initialize a pointer to contain aRunStruct
with anascent
equal to the image height and awidth
property equal to the image width. - Create a
CTRunDelegateCallbacks
that returns the ascent, descent and width properties belonging to pointers of typeRunStruct
. - Use
CTRunDelegateCreate
to 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
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 CTColumnView
s.
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
'sCTLine
objects. - Use
CTFrameGetOrigins
to copyctframe
's line origins into theorigins
array. By setting a range with a length of 0,CTFrameGetOrigins
will know to traverse the entireCTFrame
. - Set
nextImage
to contain the attributed data of the current image. IfnextImage
contain'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
CTRunGetTypographicBounds
and set the height to the found ascent. - Get the line's x offset with
CTLineGetOffsetForStringIndex
then 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
nextImage
andimgLocation
so 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! :]