杂志,Core Text和大脑!
更新说明:本教程已经由Lyndsey Scott升级为Swift 4和Xcode 9。最初的教程由Marin Todorov所创作。
Core Text是一个底层的文本引擎,当与Core Graphics/Quartz框架配合使用的时候,可以对布局和格式进行细粒度的控制。
随着iOS 7的发布,Apple公司发布了一个名叫Text Kit的高级库,可以用来储存、布局和显示具有各种排版特征的文本。虽然Text Kit在布局文本时不仅强大而且大部分情况下已经足够用了,但是Core Text可以提供更多的控制。例如,如果你想直接使用Quartz的话,那就请用Core Text吧。如果你需要构建你自己的布局引擎的话,Core Text将会帮助你生成字形(glyphs)并且根据互相之间的关系摆放好这些字形,并具有好的排版的所有特性。
本教程将会引导你使用Core Text去创作一本非常简单的杂志应用...给僵尸看的!
呃,僵尸月刊的读者朋友们已经宽容的答应了,只要你本教程认真使用Core Text的话,就不会吃掉你的大脑了...所以呢,你还是尽快开始吧!
说明:要充分读懂本教程,你首先需要了解iOS开发的基础。如果你是iOS开发的新人的话,你应该首先查看本网站的其他教程。
开始
打开Xcode,用Single View Application模板创建一个新的Swift universal project,命名为CoreTextMagazine。
然后,将Core Text框架加到你的工程中:
单击工程导航器中的工程文件(在左边的导航条上)
在"General"按钮下,滚动到底部的"Linked Frameworks and Libraries"
单击"+"按钮然后找到"CoreText"
选中"CoreText.framework"然后点击"Add"按钮。就这么简单!
现在工程已经配置好了,是时候开始写代码了。
添加一个Core Text View
首先,你将要创建一个自定义的UIView,在这个UIView的draw(_:)方法中将会用到Core Text。
创建一个新的继承于UIView的Cocoa Touch Class file,命名为CTView。打开CTView.swift,然后在
import UIKit
语句下面加上下面的代码:
import CoreText
然后,将这个自定义的view设置为应用的主视图。打开Main.storyboard,在右边打开Utilities菜单,然后在顶部工具条单击Identity Inspector图标。在Interface Builder的左侧菜单中,选中View。现在在Utilities菜单的Class字段中应该写着UIView。在Class字段输入CTView以子类化主视图控制器的视图,然后点击回车键。
接下来,打开CTView.swift并将被注释掉的draw(_:)方法全都替换成下面的代码:
//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)
}
让我们一步一步地分析一下代码:
-
在视图创建的时候,draw(_:)会自动运行,渲染这个视图的背景图层。
-
打开用于绘制的当前图形上下文。
-
创建一条用来限定绘图区域的路径,在这个例子中就是整个视图的bounds。
-
在Core Text,使用NSAttributedString而不是String或者NSString,保存文本和属性(attributes)。初始化一个"Hello World"的属性字符串。
-
CTFramesetterCreateWithAttributedString使用提供的属性字符串创建一个CTFramesetter。CTFramesetter会管理你引用的字体和绘图区域。
-
通过使CTFramesetterCreateFrame在路径内渲染整个字符串,可以创建一个CTFrame。
-
CTFrameDraw在给定的上下文中绘制CTFrame。
这就是你绘制简单文本所需要做的全部了!Build,运行然后查看结果。
噢不...似乎看起来不太对。就像很多的底层API一样,Core Text使用的是Y-flipped坐标系统。更糟糕的是,内容在竖直方向上也翻转了!
添加以下代码到
guard let context
语句以修正内容的方向:
// Flip the coordinate system
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
这段代码通过应用变换(transformation)到视图的上下文来将内容翻转。
Build然后运行app。别担心状态栏重叠的问题,你接下来会学到怎样通过约束解决这个问题。
祝贺你的第一个Core Text软件!僵尸们很高兴看到你的进步。
Core Text对象模型
如果你对CTFramesetter和CTFrame感到有点疑惑也是正常的,也是时候说明一下它们了。:]
Core Text对象模型如下所示:
当你提供一个NSAttributedString创建一个CTFramesetter对象实例的时候,一个CTTypesetter的实例对象会自动为你创建用以管理你的字体。接下来你会在渲染文本的时候用到这个CTFramesetter去创建一个或者多个frame。
当你创建了一个frame,你可以为这个frame提供文本的一个subrange去渲染这段文本。Core Text会自动为文本的每一行创建一个CTLine,并为每个具有相同格式的字符创建一个CTRun。举个例子,Core Text只会创建一个CTRun用于同一行中的几个红色的单词,创建一个CTRun用于接下来的纯文本,创建一个CTRun用于粗体段落等等。Core Text创建会根据你提供的NSAttributedString中的属性创建CTRun。此外,上面说到的每一个CTRun对象都可以采用不同的属性,也就是说,你可以很好地控制字距、连字、宽度、高度等。
深入杂志App!
下载并解压the zombie magazine materials。拖拽解压出来的文件夹到你的Xcode工程中。当弹出对话框时,确保Copy items if needed和Create groups选中。
为了创建这个app,你需要对文本应用各种属性。你将要创建一个用标签设置杂志格式的简单文本标记解析器。
创建一个新的Cocoa Touch Class file,命名为MarkupParser,继承于NSObject。
首先,我们快速看一下zombies.txt。看看它是如何在整个文本中包含括号内的格式化标签的。"img src"标签指向杂志的图片,而"font color/face"标签则确定了文本的颜色和字体。
打开MarkupParser.swift然后将它的内容替换为以下代码:
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) {
}
}
你在这段代码中添加了属性持有字体和文本颜色,设置了它们的初始值。创建了一个变量去持有parseMarkup(_:)生成的属性字符串。还创建了一个数组用来持有定义了尺寸、位置以及从文本中解析出来的图片文件名等信息的键值对。
通常来说,写一个解析器并不是一个轻松的工作,但是本教程所实现的解析器将会非常简易,只提供开放标签的支持,也就意味着一个标签会决定紧随着这个标签的文本的样式,直到找到一个新的标签。这段文本的标记如下所示:
These are
red
and
blue
words.
输出如下所示:
将以下代码加到 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初始为空,但是最后会包含解析出来的标记。
-
这个正则表达式,匹配了紧跟着这些标签的文本块。它就好像在说:“去查看字符串直到你找到一个开头的括号,然后查看字符串直到你找到一个结束的括号(或者文档的末尾)”。
-
搜索regex匹配到的整个标记范围,然后生成一个NSTextCheckingResult的数组。
想要学习更多有关正则表达式的内容,访问NSRegularExpression Tutorial吧。
现在你已经解析了所有的文本并将所有格式化的标签都放进了chunks中,你要做的就是遍历chunks数组去生成对应的属性字符串。
但在那之前,你是否留意到matches(in:options:range:)方法是如何接受一个NSRange作为参数的吗?在你应用NSRegularExpression到你的标记String的时候会有大量NSRange到Range的转化。Swift已经成为了我们所有人的好帮手,所以它值得帮助。
还是在MarkupParser.swift中,将下面的extension加到文件的最后面:
// MARK: - String
extension String {
func range(from range: NSRange) -> Range
? {
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
}
}
上面这个函数将以NSRange表示的字符串的起止索引转换为了String.UTF16View.Index格式,即UTF-16字符串中的位置(position)集合,然后将每个String.UTF16View.Index格式转换为String.Index格式。String.Index格式在组合时,会生成Swift的范围格式:Range。只要索引是有效的,这个函数就会返回原始NSRange格式对应的Range格式。
现在是时候回头处理文本和标签数组了。
在parseMarkup(_:)函数中添加一下代码到 let chunks 到下面(在do循环语句块中):
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)
}
-
循环chunks数组。
-
获取当前NSTextCheckingResult的range,展开Range
并且只要range存在就继续执行以下的语句块。
-
将chunk用"
-
用fontName生成字体,现在的默认字体是"Arial",并且根据设备屏幕创建了字体的大小。假如fontName不能产生有效的UIFont的话,将默认字体设为当前字体。
-
创建字体格式的字典,将其应用于parts[0]以创建属性字符串,然后将该字符串添加到结果字符串后面。
将下面用来处理"font"标签的代码插到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
-
如果parts数组元素少于2个,则跳过这个循环语句块。否则的话,将parts的第二部分存为tag。
-
如果tag以"font"开始则创建一个正则表达式去匹配字体的"color"值,然后用这个正则去枚举匹配到的tag中的"color"值。在这种情况下,只应该有一个匹配到的颜色值。
-
如果enumerateMatches(in:options:range:using:)函数返回标签的一个有效的match和一个有效的range的话,就去搜索出指示值(比如,
返回"red"),接着用这个颜色值生成一个UIColor的selector。执行这个selector所返回得到的color(如果存在的话)会赋值到你的类的color属性上,如果返回的color不存在的话,color属性会被赋值为black。
-
同样的,创建一个正则表达式去处理文本中字体的"face"值。如果匹配到一个"face"值,则将类的fontName属性设置为匹配的"face"值。
干得漂亮!现在parseMarkup(_:)函数已经可以获取文本中的标记并生成一个对应的NSAttributedString了。
现在也是时候把你的app喂给一些僵尸了!我的意思是,喂一些僵尸给你的app... 也就是说,(开始处理)zombies.txt。
事实上,显示出被赋予的内容才是UIView的职责所在,而不是去加载内容。打开CTView.swift然后将下面代码添加到draw(_:)方法之前:
// MARK: - Properties
var attrString: NSAttributedString!
// MARK: - Internal
func importAttrString(_ attrString: NSAttributedString) {
self.attrString = attrString
}
接下来,将
let attrString = NSAttributedString(string: "Hello World")
从draw(_:)函数中删除。
这段代码中你创建了一个实例变量持有属性字符串和一个函数以便app的其他地方可以设置这个属性字符串。
然后,打开ViewController.swift并将以下代码添加到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 _ {
}
然后一步步的过一下这段代码:
-
从zombie.txt文件中加载文本。
-
创建一个新的解析器,传入文本作为参数,然后将返回的属性字符串赋给ViewController的CTView。
Build并且运行这个app!
简直太棒了!归功于这50多行解析代码你可以轻松的用文本文件持有你杂志app的内容了。
基本的杂志布局
如果你认为僵尸新闻的每月杂志只能全塞在一个可怜的页面中,那么你就错了!幸运的是,Core Text在文本列布局时相当有用,因为CTFrameGetVisibleStringRange可以告诉给定frame的情况下显示多少文本才是合适的。也就是说,你可以创建一列文本,当这一列塞满文本之后,你可以知道并开始新的一列。
就本app而言,你需要先打印出列,然后集列成页,再集页成文。未免冒犯这些亡灵,所以。。。你还是尽快把把你的CTView改成继承于UIScrollView。
打开CTView.swift然后将 class CTView 一行改成以下代码:
class CTView: UIScrollView {
看到了吗,僵尸老爷?现在这个app已经支持永恒不死了!对的,行、滚动以及翻页现在都是可用的了。
到现在为止,你已经在draw(_:)方法里创建了framesetter和frame了,不过由于你有很多不同格式的文本列,所以最好还是创建一个独立的实例表征所述的文本列。
创建一个新的名为CTColumnView的Cocoa Touch Class file,继承于UIView。
打开CTColumnView.swift并添加下列初始代码:
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)
}
}
跟开始在CTView里做的工作一样,这段代码生成了一个CTFrame。自定义的初始化函数init(frame:ctframe:)设置了:
-
这个视图的frame。