原文:https://www.raywenderlich.com/153591/core-text-tutorial-ios-making-magazine-app
Core Text 是一个底层文本引擎,当与 Core Graphics/ Quartz 框架一起使用时,它可以对布局和格式进行细粒度的控制。
在 iOS 7 时候,Apple 发布了 TextKit 类库,它可以存储、列出和显示带有各种排版特征的文本。虽然 TextKit 功能强大,在布局文本时已经足够强大,但相对而言, Core Text 可以提供更多的控制。例如,如果你需要直接使用 Quartz,那么 Core Text 就可以。如果你需要构建自己的布局引擎,Core Text 将帮助你生成 “字形”,并将它们相对地放置在精细排版中。
本教程通过使用 Core Text 创建一个非常简的单杂志应用… Zombies !
开始
打开 Xcode ,基于 Single View Application Template 创建一个新的 Swift universal project ,并命名为 CoreTextMagazine。
然后,添加 Core Text framework 到我们的工程中:
- 单击项目导航器中的项目文件。
- 在 "General" 下,滚动到下面的 "Linked Frameworks and Libraries"。
- 单击 "+", 并搜索 "CoreText"。
- 选择 "CoreText.framework" ,然后单击 "Add" 按钮。
现在工程已经建好了,是时候开始编码了。
添加一个 Core Text View
作为开始,我们将创建一个 UIView,在它的 draw(_:) 方法内将使用 Core Text。
创建一个 Cocoa Touch Class 文件,它继承自 UIView 。将其命名为 CTView 。
打开 CTView.swift,添加如下代码:
import CoreText
而后,设置这个自定义的 view 为应用的主视图。打开 Main.storyboard,打开右边的 Utilities 菜单,选中它上边 toolbar 的 Identity Inspector 按钮,Interface Builder 左边菜单,选中 View。Utilities 菜单的 Class 文本框现在应该为 UIView。键入文本框 CTView ,将其设置为主视图控制器的 View。
然后,打开 CTView.swift ,替换方法如下:
//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)
}
让我逐行解释下上面的代码。
- 一旦 view 创建,draw(_:) 将自动执行来渲染 view 下的 layer。
- 打开我们将用于绘图的当前图形上下文。
- 创建一个路径,该路径限制绘图区域,整个视图的边界在该控制下。
- 在 Core Text 中,使用 NSAttributedString 来保存文本及其属性,而不是 String 或 NSString。初始 “Hello World” 作为一个带属性的字符串。
- CTFramesetterCreateWithAttributedString 创建一个使用提供的属性字符串的 CTFramesetter,CTFramesetter 将管理我们的字体引用和绘图框架。
- 创建 CTFrame,通过拥有 CTFramesetterCreateFrame 使整个字符串在路径中呈现。
- CTFrameDraw 在给定的上下文中绘制 CTFrame 。
这就是我们要绘制一些简单的文字所需要的所有步骤。 Build and Run,我们可以看到运行结果。
Uh-oh… 这似乎哪里不对,不是吗?和许多低级 api 一样,Core Text 使用了一个 “y -翻转” 坐标系。更糟糕的是,内容也跟着垂直翻转了!
在 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)
此代码通过向视图的上下文中应用转换来翻转内容。
Build and run 应用。先忽略状态栏的重叠,以后我们将会学习如何解决这个问题。
恭喜,我们已经创建了属于自己的第一个 Core Text 应用。
Core Text Object Model
如果你对 CTFramesetter 和 CTFrame 有点困惑,没关系,我们现在讲解一下它。
以下为 Core Text 对象模型的样子:
当我们创建一个 CTFramesetter 引用并为它提供一个 NSAttributedString 时,将自动创建一个 CTTypesetter 实例来管理字体。接下来,使用 CTFramesetter 创建一个或多个将呈现文本的框架。
当我们创建一个 frame 时,我们提供文本的 subrange,并在它的矩形内渲染文本。Core Text 自动为每一行文本创建一个 CTLine,每段文本 的 CTLine 有相同的格式。例如,如果在一行中有多个红色单词,Core Text 将为这几个词创建一个 CTRun,然后为剩下的纯文本创建另一个 CTRun,再为另一些粗体的词句创建一个 CTRun,等等。此外,Core Text 根据提供的 NSAttributedString 的属性为我们创建 CTRuns。每一个 CTRuns 控件都可以采用不同的属性,因此我们可以对 kerning、ligUNK、width、height 等属性进行很好的控制。
Onto the Magazine App!
下载资源压缩包the zombie magazine materials.
拖拽文件夹到我们的 Xcode 工程中,当弹框提醒的时候,确定 Copy items if needed 和 Create groups 是选中的。
创建 app ,我们需要将各种属性应用于文本。我们将创建一个简单的文本标记解析器,它将使用标记来设置杂志的格式。
创建一个新的 Cocoa Touch 类,让它继承自 NSObject ,命名为 MarkupParser。
首先,快速浏览一下 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 <font color="red">red<font color="black"> and
<font color="blue">blue <font color="black">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 匹配的整个标记范围,然后生成一个 NSTextCheckingResults 结果数组。
注:想更多了解正则表达式,看这里 NSRegularExpression Tutorial.
现在我们已经将所有文本和格式化标记解析成块,接下来我们将遍历块来构建属性字符串。
但在此之前,我们是否注意到如何匹配 (in:options:range:) 接受一个 NSRange 作为参数呢?当你将 ns 正则表达式函数应用于你的标记字符串时,将会有很多 NSRange 到 Range 转换。Swift 一直是我们所有人的好朋友,关键时刻,它给予我们帮助。
在 MarkupParser.swift 中,将下面的扩展添加到文件的末尾:
// 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
}
}
该函数将字符串的开始和结束索引转换为由 NSRange表示的字符串,String.UTF16View.Index 格式,即字符串中 utf - 16 代码单元集合中的位置; 然后转换每个 String.UTF16View.Index 到 String.Index 格式。只要索引是有效的,该方法将返回代表原 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[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<String.Index> ,只要存在,就继续进行。
- 将块分成 “<” 分隔的部分。第一部分包含杂志文本,第二部分包含标签(如果存在的话)。
- 创建一个使用 fontName 的字体,默认情况下是 “Arial”,创建一个与设备屏幕相等的大小。如果 fontName 没有生成有效的 UIFont,则将字体设置为默认字体。
- 创建一个字体格式的字典,将它应用于 parts[0] 来创建带属性的字符串,然后将该字符串附加到结果字符串。
要处理 “font” tag,请在 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[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 = String(tag[range])
}
}
} //end of font parsing
如果 parts.count 小于2,跳过循环体的其余部分。否则,将第二部分存储为 tag。
如果 tag 以 “font” 开始,创建一个 regex 来查找字体的 “color” 值,然后使用该 regex 通过标记的匹配 “color” 值来枚举。在本例中,应该只有一个匹配的颜色值。
如果 enumerateMatches(in:options:range:using:) 返回一个有效的 match , match 中含有一个有效的 tag,查找指定的值(ex . <font color="red" > returns " red "),并追加" color "以形成UIColor选择器。
同样,创建一个 regex 来处理字体的 “face” 值。如果找到匹配,则将 fontName 设置为该字符串。
Great job! 现在,parseMarkup(:) 可以获取标记并为Core Text 生成一个NSAttributedString。
现在是时候把你的应用程序喂给一些僵尸了!我的意思是,给你的应用喂一些僵尸… zombies.txt )
它实际上是一个 UIView 的工作,显示给它的内容,而不是载入内容。打开 CTView.swift 并添加以下的draw(:):
// MARK: - Properties
var attrString: NSAttributedString!
// MARK: - Internal
func importAttrString(_ attrString: NSAttributedString) {
self.attrString = attrString
}
接下来,从 draw(_:) 中删除 attrString = NSAttributedString(string: "Hello World")。
在这里,我们创建了一个实例变量来保存带属性的字符串,以及将其从 app 的其他地方设置的方法。
接下来, 打开 ViewController.wift 并将以下内容添加到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 中的文本到一个 String。
- 创建一个新的解析器,在文本中输入,然后将返回的属性字符串传递给 ViewController 的 CTView。
Build and run the app!
太棒了!得助于 50 行解析代码,我们可以简单地使用一个文本文件来保存杂志应用程序的内容了。
A Basic Magazine Layout
如果你认为一个僵尸新闻的月杂志可以放到一个微不足道的页面上,那你就大错特错了! 幸运的是 Core Text 布局列时尤为有用, CTFrameGetVisibleStringRange 可以告诉我们多少文本将适合一个给定的框架。也就是说,你可以创建一个列,然后当它的全部被填充后,你可以再创建另一个列,等等。
对于这个 app,你必须打印列,然后是页面,然后是一本完整的杂志,以免冒犯不死族,所以……是时候将 CTView 子类转换为 UIScrollView 了。
打开 CTView.swift 和更改类 CTView 一行:
class CTView: UIScrollView {
看到僵尸了吧? 这个应用现在可以支持不死的冒险了! 是的——仅仅一行代码,就可以滚动和分页了。
到目前为止,我们已经创建了框架和框架内 draw(_:),但是由于我们有许多不同格式的列,所以最好创建单独的列实例。
创建一个新的 Cocoa Touch 类文件,命名为 CTColumnView,令它继承自 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)
}
}
这段代码呈现的是 CTFrame,就像我们之前在 CTView 中所做的那样。自定义初始化程序,init(框架:ctframe:) 集合:
- The view's frame.
- 在上下文渲染的 CTFrame。
- 设置 view 的背景颜色为白色。
接下来,创建一个名为 CTSettings.swift 的新的文件,它用来进行列设置。
用以下代码替换 CTSettings.swift 内容:
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)
}
}
- 属性将决定页边距(本教程的默认值为20),每页的列数,每一页的 frame,每一页 frame 大小。
- 由于该杂志同时提供 iPhone 和 iPad 上的僵尸,在 iPad 上显示两列,在 iPhone 上显示一列,因此每一个屏幕的大小都是合适的。
- 设置 pageRect 为 UIScreen.main.bounds.insetBy(dx: margin, dy: margin)。
- 设置 columnRect 为将 pageRect 的宽度除以每一页的列数,并除去边距。
打开 CTView.swift, 做如下替换 :
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:) 将创建 CTColumnViews,然后添加它们到 scrollview。
- 启用scrollview的分页功能;每当用户停止滚动时,滚动视图就会快速地显示出一个完整的页面。
- CTFramesetter framesetter 将为属性文本创建每个列的CTFrame。
- UIView pageViews 作为每个页面的列子视图的容器; textPos 将跟踪下一个字符; columnIndex 将跟踪当前列; pageIndex 将跟踪当前页面; settings 允许你访问应用程序的 margin 大小,每一页的列,page frame 和 column frame 设置。
- 我们将遍历 attrString 并按列列出文本列,直到当前文本位置到达结束为止。
是时候添加 looping attrString 了,在 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)
- 如果 column index 被每页的列数相除等于 0 ,则表示该列是其页面上的第一个列,创建一个新的 page视图来保存列。使用边缘 settings.pageRect 设置它的帧。x offset 为当前页 index 乘以屏幕宽度。当页面滚动时,每个杂志页面将位于前一个页面的右侧。
- 自增 pageIndex。
- pageView 的宽度除以 settings.columnsPerPage 获得第一列的 x 坐标; x 坐标乘以 column index 获得列偏移。然后用标准列向量来创建当前列的 frame,并通过 columnOffset 来抵消它的 x 原点。
接下来,在 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
- 创建一个 CGMutablePath,然后从textPos 开始,呈现一个新的 CTFrame,包含尽可能多的文本。
- 使用 CGRect columnFrame 和 CTFrame ctframe 创建一个 CTColumnView ,添加列到 pageView。
- 使用 CTFrameGetVisibleStringRange(_:) 来计算文本列中包含的范围,然后 textPos +frameRange.length 来反映当前文本的位置。
- 在循环到下一列之前,将列索引增加1。
最后,在循环之后设置滚动视图的内容大小:
contentSize = CGSize(width: CGFloat(pageIndex) * bounds.size.width,
height: bounds.size.height)
通过将内容大小设置为屏幕宽度乘以页面数,zombies 现在可以滚动到最后了。
打开 ViewController.swift ,替换
(view as? CTView)?.importAttrString(parser.attrString)
为
(view as? CTView)?.buildFrames(withAttrString: parser.attrString, andImages: parser.images)
在 iPad 上运行 app,左右拖动到页面之间,检查双列布局。看起来不错.:]
我们有了列和格式化的文本,但还缺少图像。使用 Core Text 绘制图像并不是那么简单——毕竟它是一个文本框架——但是在我们已经创建的标记解析器的帮助下,添加图像不应该太糟糕。
Drawing Images in Core Text
虽然 Core Text 不能绘制图像,但作为一个布局引擎,它可以留出空白空间来为图像腾出空间。通过设置 CTRun 的 delegate,我们可以确定 CTRun 的 ascent space, descent space and width。像下面这样:
当 Core Text 获得一个带有 CTRunDelegate 的 CTRun 类时,它会询问委托,“我应该留出多少空间来处理这段数据?” 通过在CTRunDelegate中设置这些属性,我们可以在文本中为我们的图像留下空间。
首先,添加 "img" 标签。打开 MarkupParser.swift,找到 "} //end of font parsing",在其后添加如下代码:
//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)
}
}
}
- 如果 tag 以 "img" 开始,使用正则表达式寻找 图像的 "src" ,即 filename。
- 将图像宽设置为列的宽度,并设置其高度,使图像保持其高宽比。
- 如果图像的高度太长,则设置高为适合的列,并减小宽度以保持图像的纵横比。由于图像后面的文本将包含空的空间属性,包含空空间信息的文本必须与图像匹配在同一列中。设置图像的高度为 settings.columnRect.height - font.lineHeight。
接下来,在 if let image 代码块下添加如下代码:
//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))
- 赋值字典给变量 images,字典包含 image's size, filename and text location。
- 定义 RunStruct 来保存描述空空间的属性。然后初始化一个指针,以包含一个 ascent 等于图像高度的 RunStruct,以及一个与图像宽度相等的 width 属性。
- 创建一个 CTRunDelegateCallbacks ,它返回类型指针 RunStruct 的 ascent, descent 以及 width 属性。
- 使用 CTRunDelegateCreate 创建一个 delegate 实例,来绑定 callbacks 和 参数数据(data parameter)。
- 创建一个包含 delegate 实例的属性字典,然后赋值空字符串给 attrString, attrString 包含了文本中空洞的位置和大小信息。
现在,MarkupParser 正在处理“img”标记,我们需要调整 CTColumnView 和 CTView 来呈现它们。
打开 CTColumnView.swift,在 var ctFrame:CTFrame! 添加如下代码,以此控制列中的图片和frames:
var images: [(image: UIImage, frame: CGRect)] = []
接下来,添加如下代码到 draw(_:) 方法的底部:
for imageData in images {
if let image = imageData.image.cgImage {
let imgBounds = imageData.frame
context.draw(image, in: imgBounds)
}
}
这里我们遍历每个 image 并绘制它到合适的 frame。
接下来,打开 CTView.swift 并在 class 的顶部添加如下属性:
// MARK: - Properties
var imageIndex: Int!
当你绘制 CTColumnViews 时,imageIndex 将追踪当前的图像 index。接下来,在 buildFrames(withAttrString:andImages:) 上面添加如下代码:
imageIndex = 0
这标记 images 数组的第一个元素。
接着,在 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 {
}
}
}
}
- 获取 ctframe's CTLine objects 的数组。
- 使用 CTFrameGetOrigins 拷贝 ctframe's line origins 到 origins array。通过设置 range length 为 0,CTFrameGetOrigins 知道穿越整个 CTFrame。
- 设置 nextImage 来包含当前图像的属性数据。如果 nextImage 包含图像的位置,打开它并继续;否则,提前返回。
- 循环遍历 text's lines 。
- 如果 line's glyph runs, filename 和 filename 的image 都存在,循环 glyph runs 。
接下来,添加如下代码到 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
}
- 如果当前运行的范围不包含下一个图像,则跳过循环其余部分。否则,在这里渲染图像。
- 使用 CTRunGetTypographicBounds 计算 image width, 将 width 赋值给 ascent。
- 使用 CTLineGetOffsetForStringIndex 获取 line 的 x offset,然后添加它到 imgBounds' origin。
- 添加 image 和它的 frame到当前 CTColumnView。
- 增加 imageIndex。如果有图像在 imges 数组中,更新 nextImage 和 imgLocation,以便它们指向下一个图像。
OK! Great! 只剩下最后一步了。
在方法 buildFrames(withAttrString:andImages:) 中pageView.addSubview(column) 的上部添加如下代码:
if images.count > imageIndex {
attachImagesWithFrame(images, ctframe: ctframe, margin: settings.margin, columnView: column)
}
如果它们存在,附加图像。
在 iPhone 和 iPad 上面运行,效果如下:
恭喜,大功告成。