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:
- Click the project file in the Project navigator (the strip on the left hand side)
- Under “General”, scroll down to “Linked Frameworks and Libraries” at the bottom
- Click the “+” and search for “CoreText”
- 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.
- Upon view creation,
draw(_:)
will run automatically to render the view’s backing layer. - Unwrap the current graphic context you’ll use for drawing.
- Create a path which bounds the drawing area, the entire view’s bounds in this case
- In Core Text, you use
NSAttributedString
, as opposed toString
orNSString
, to hold the text and its attributes. Initialize “Hello World” as an attributed string. CTFramesetterCreateWithAttributedString
creates aCTFramesetter
with the supplied attributed string.CTFramesetter
will manage your font references and your drawing frames.- Create a
CTFrame
, by havingCTFramesetterCreateFrame
render the entire string withinpath
. CTFrameDraw
draws theCTFrame
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:
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 CTRun
s 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 _ {
}
attrString
starts out empty, but will eventually contain the parsed markup.- 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).”
- Search the entire range of the markup for
regex
matches, then produce an array of the resultingNSTextCheckingResult
s.
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)
}
- Loop through
chunks
. - Get the current
NSTextCheckingResult
's range, unwrap theRange<String.Index>
and proceed with the block as long as it exists. - Break
chunk
into parts separated by "<". The first part contains the magazine text and the second part contains the tag (if it exists). - Create a font using
fontName
, currently "Arial" by default, and a size relative to the device screen. IffontName
doesn't produce a validUIFont
, setfont
to the default font. - 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
- If less than two parts, skip the rest of the loop body. Otherwise, store that second part as
tag
. - If
tag
starts with "font", create a regex to find the font's "color" value, then use that regex to enumerate throughtag
's matching "color" values. In this case, there should be only one matching color value. - If
enumerateMatches(in:options:range:using:)
returns a validmatch
with a valid range intag
, find the indicated value (ex.<font color="red">
returns "red") and append "Color" to form aUIColor
selector. Perform that selector then set your class'scolor
to the returned color if it exists, to black if not. - 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.
- Load the text from the
zombie.txt
file into aString
. - Create a new parser, feed in the text, then pass the returned attributed string to
ViewController
'sCTView
.
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.
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:
- The view's frame.
- The
CTFrame
to draw into the context. - 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)
}
}
- 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.
- 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.
- Inset the entire bounds of the page by the size of the margin to calculate
pageRect
. - Divide
pageRect
's width by the number of columns per page and inset that new frame with the margin forcolumnRect
.
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 {
}
}
}
buildFrames(withAttrString:andImages:)
will createCTColumnView
s then add them to the scrollview.- 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.
CTFramesetter
framesetter
will create each column'sCTFrame
of attributed text.UIView
pageView
s 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; andsettings
gives you access to the app's margin size, columns per page, page frame and column frame settings.- 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)
- 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. - Increment the
pageIndex
. - Divide
pageView
's width bysettings.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 standardcolumnRect
and offsetting its x origin bycolumnOffset
.
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
- Create a
CGMutablePath
the size of the column, then starting fromtextPos
, render a newCTFrame
with as much text as can fit. - Create a
CTColumnView
with aCGRect
columnFrame
andCTFrame
ctframe
then add the column topageView
. - Use
CTFrameGetVisibleStringRange(_:)
to calculate the range of text contained within the column, then incrementtextPos
by that range length to reflect the current text position. - 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:
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)
}
}
}
- 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! :]
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.