干货:CoreText教程:制作一个简单图文混排的杂志(翻译向)
学习CoreText入门时发现的文章,特此翻译下来,以资他人学习之用 原文链接 : https://www.raywenderlich.com/153591/core-text-tutorial-ios-making-magazine-app
Core Text
是一个可以结合Core Graphics/Quartz
框架的底层文本渲染引擎,可以提供精细的布局和格式化控制
在iOS7
,苹果发布了高层级的Text Kit
库,它可以存储,列出并显示具有各种排版特征的文本。尽管Text Kit
非常强大,也满足日常文本排版的需要,但是Core Text
可以提供更精细的控制。例如,如果你需要直接使用Quartz
框架,可以使用Core Text
, 如果你需要打造自己独有的排版引擎,Core Text
能帮你实现 对字体与相对位置相关的特征,进行精细的控制
此教程将使用Core Text
从0到1创建一个简单的杂志应用,那么开始吧
热身
-
打开Xcode新建一个swift项目,命名为
新建项目CoreTextMagazine
-
将
导入CoreText.frameworkCoreText.framework
导入工程
-
添加 Core Text View
创建CTView.swift
, 复写draw(_:)
方法
override func draw(_ rect: CGRect) {
// 获取当前上下文
guard let context = UIGraphicsGetCurrentContext() else {
return;
}
//转换成uikit坐标系
context.textMatrix = .identity
context.translateBy(x: 0, y: rect.height)
context.scaleBy(x: 1, y: -1)
// 绘制区域路径
let path = CGMutablePath.init()
path.addRect(rect)
// 初始化富文本
let attString = NSAttributedString.init(string: "hello word")
// 创建 CTFramesetter
let frameSetter = CTFramesetterCreateWithAttributedString(attString as CFAttributedString)
// 创建 CTFrame
let frame = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attString.length), path, nil)
// 在指定上下文绘制CTFrame
CTFrameDraw(frame, context)
}
由于Quartz
坐标系与UIKit坐标系略有不同,所以需要对坐标系进行一次转换,否则绘制出来的文本将会是倒置的
context.textMatrix = .identity
context.translateBy(x: 0, y: rect.height)
context.scaleBy(x: 1, y: -1)
项目跑一下, 成功渲染
渲染文本
- CoreText 对象模型
CTFramesetter
是啥?CTFrame
又是啥?祭出此图
CoreText对象模型
当你创建了一个CTFramesetter
,并且为它提供了一个NSAttributedString
,将会自动创建一个CTTypesetter
来管理字体,接下来就可以使用CTFramesetter
创建一个或者多个CTFrame
来渲染文本
创建CTFrame
的时候,CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, attString.length), path, nil)
我们给定了范围和文本的绘制路径范围,Core Text
自动为每一行文本创建了一个CTLine
,每一块文本创建了一个CTRun
, 每个CTRun
能够设置不同的属性,例如你可以创建一个红色字体的CTRun
,再创建另一个为粗体的CTRun
,这就是为什么说Core Text
能对文本进行精细控制的原因了
撸起袖子,开干
请下载素材链接:百度云链接:https://pan.baidu.com/s/1dF5zVkH 解压之后导入工程即可
我们需要对不同的文本设置不同的属性,我们需要新建一个文本修饰格式解析器来解析 zombies.txt
中的属性标签来格式化文本
- 新建
MarkupParser.swift
继承NSObject
我们首先可以粗看下zombies.txt
中内容
img src
标签引用了图片,font color/face
标签决定了文本的颜色和字体
在MarkupParser.swift
键入如下代码
// MARK: - 属性
var color: UIColor = .black
var fontName: String = "Arial"
var attrString: NSMutableAttributedString!
var images: [[String: Any]] = []
// MARK: - 初始化方法
override init() {
super.init()
}
// MARK: - 内部方法,解析html文本
func parseMarkup(_ markup: String) {
}
类中带有字体颜色,字体等属性,parseMarkup(_:)
将从文本中解析成属性文本
对于以下文本
These are <font color="red">red<font color="black"> and
<font color="blue">blue <font color="black">words.
将被解析渲染成如下样式
- 解析标签
将如下代码填入parseMarkup(_:)
方法
// attrString初始化为空,最终会被赋值最终解析结果
attrString = NSMutableAttributedString(string: "")
// 解析
do {
// 匹配标签块
let regex = try NSRegularExpression.init(pattern: "(.*?)(<[^>]+>|\\Z)", options: NSRegularExpression.Options.dotMatchesLineSeparators)
let chunks = regex.matches(in: markup, options: NSRegularExpression.MatchingOptions.init(rawValue: 0), range: NSRange.init(location: 0, length: markup.count))
} catch _ {
}
正则匹配出所有的标签块
现在所有的匹配结果都在
chunks
变量中,只需要遍历chunks
来创建AttributedString
即可
在此之前,我们注意到matches(in:options:range:)
接受了一个NSRange
类型参数,接下来还会有许多用到NSRange
转换成 Range
的地方,添加如下代码,可将 NSRange
转换成 Range
:
// MARK: - String NSRange 转换成 Range
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
}
}
在parseMarkup(_:)
继续添加如下代码
// 设定默认字体
let defaultFont: UIFont = .systemFont(ofSize: UIScreen.main.bounds.size.height / 40)
// 遍历匹配结果
for chunk in chunks {
// 获取当前匹配结果 NSTextCheckingResult 在原文本中的范围
guard let markupRange = markup.range(from: chunk.range) else { continue }
// 以符号 "<" 分割句子
let parts = markup[markupRange].components(separatedBy: "<")
// 从 fontName 属性(Arial)创建字体, 若无该字体,则使用默认字体 defaultFont
let font = UIFont(name: fontName, size: UIScreen.main.bounds.size.height / 40) ?? defaultFont
// 为 NSAttributedString 创建 字体颜色和字体 属性
let attrs = [NSAttributedStringKey.foregroundColor: color, NSAttributedStringKey.font: font] as [NSAttributedStringKey : Any]
// 将属性 应用于 parts[0]
let text = NSMutableAttributedString(string: parts[0], attributes: attrs)
attrString.append(text)
}
为了解析处理font
标签,继续添加如下代码
// 如果分割后的模式数组长度小于等于1,则略过 说明不带有形如 <> 的匹配
if parts.count <= 1 {
continue
}
let tag = parts[1]
// 如果 parts[1] ( < 之后的文本,也就是标签名) 是 font
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
// 利用 NSObject perform 方法对 color 属性 赋值获取到的颜色
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
}
}
// 正则匹配 face 字体属性
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
到现在为止,已经能够解析出 NSAttributedString
了
在我们的CTView.swift
中添加
// MARK: - Properties
var attrString: NSAttributedString!
// MARK: - Internal
func importAttrString(_ attrString: NSAttributedString) {
self.attrString = attrString
}
然后 draw(_ rect: CGRect)
中删除
let attrString = NSAttributedString(string: "Hello World") from draw(_:)
ViewController.swift
中设置入口
let ctView = CTView()
ctView.frame = view.frame
view.addSubview(ctView)
guard let file = Bundle.main.path(forResource: "zombies", ofType: "txt") else { return }
do {
let text = try String(contentsOfFile: file, encoding: .utf8)
// 解析器解析
let parser = MarkupParser()
parser.parseMarkup(text)
ctView.importAttrString(parser.attrString)
} catch _ {
}
运行一下,Cool, 效果如下
image.png杂志布局
我们不仅仅满足一个只显示单页面的应用,CTFrameGetVisibleStringRange
使我们能控制一个frame
中能显示多少文字,你可以创建列,显示满了之后,可以再次创建一列
在这个应用中,我们将以列为单位,构建多个页面,最终构成一个杂志APP
Let us down
我们先将CTView.swift
中基类换成UIScrollView
, 使App能够支持多页滚动
class CTView: UIScrollView {
到目前为止我们在CTView.swift
中创建了一个framesetter
,生成了并绘制了一个CTFrame
接下来创建一个新的类CTColumnView.swift
继承于UIView
class CTColumnView: UIView {
var ctFrame: CTFrame!
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: -
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
// 转换成UIKit坐标系
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.size.height)
context.scaleBy(x: 1.0, y: -1.0)
// 在上下文中绘制 CTFrame
CTFrameDraw(ctFrame, context)
}
}
接下来我们需要一个CTSettings.swift
来对Column
列进行配置
class CTSettings {
// MARK: - 属性
let margin: CGFloat = 20 // 边距
var columnsPerPage: CGFloat! // 每页列数
var pageRect: CGRect! // 页面大小
var columnRect: CGRect! // 列大小
// MARK: - 初始化
init() {
// 如果是iphone 每页显示1列,否则每页两列
columnsPerPage = UIDevice.current.userInterfaceIdiom == .phone ? 1 : 2
// 页面frame 边距设置为 margin大小
pageRect = UIScreen.main.bounds.insetBy(dx: margin, dy: margin)
// 设置列的frame
columnRect = CGRect(x: 0,
y: 0,
width: pageRect.width / columnsPerPage,
height: pageRect.height).insetBy(dx: margin, dy: margin)
}
}
打开CTView.swift
, 删去已有代码, 添加如下代码
class CTView: UIScrollView {
func buildFrames(withAttrString attrString: NSAttributedString,
andImages images: [[String: Any]]) {
// 允许UIScrollview 翻页进行翻动
isPagingEnabled = true
// CTFrameSetter 将创建每列对应的 CTFrame
let framesetter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
// 属性
var pageView = UIView()
var textPos = 0 //当前字符所在位置
var columnIndex: CGFloat = 0 //当前列下标
var pageIndex: CGFloat = 0 //当前页面下标
let settings = CTSettings() //配置
// 循环遍历,生成列
while textPos < attrString.length {
继续向遍历,生成列的循环代码添加:
// columnIndex %s ettings.columnsPerPage为零(truncatingRemainder:对浮点数取余),说明为页面第一列,需要新建一个页,并设置frame
if columnIndex.truncatingRemainder(dividingBy: settings.columnsPerPage) == 0 {
columnIndex = 0
pageView = UIView(frame: settings.pageRect.offsetBy(dx: pageIndex * bounds.width, dy: 0))
addSubview(pageView)
// 页面索引自增
pageIndex += 1
}
// 列宽度
let columnXOrigin = pageView.frame.size.width / settings.columnsPerPage
// 列偏移量
let columnOffset = columnIndex * columnXOrigin
// 计算列的frame
let columnFrame = settings.columnRect.offsetBy(dx: columnOffset, dy: 0)
// 创建位置路径,确定text分绘制范围
let path = CGMutablePath()
path.addRect(CGRect(origin: .zero, size: columnFrame.size))
// 创建 CTFramesetter 用来创建 CTFrame
let ctframe = CTFramesetterCreateFrame(framesetter, CFRangeMake(textPos, 0), path, nil)
// 创建列视图
let column = CTColumnView(frame: columnFrame, ctframe: ctframe)
pageView.addSubview(column)
// 获取CTFrame 能容纳多少文本,从而更新textPos
let frameRange = CTFrameGetVisibleStringRange(ctframe)
textPos += frameRange.length
// 列数指针自增
columnIndex += 1
}
上述代码中,确定和计算出了每一列视图的位置和应该显示的文本范围
最后,代码末尾,只需要重新设定下UIScrollView
的contentSize
即可
// 更新UIScrollview的contentSize
contentSize = CGSize(width: CGFloat(pageIndex) * bounds.size.width,
height: bounds.size.height)
ViewController
中调用ctView.buildFrames
即可
let text = try String(contentsOfFile: file, encoding: .utf8)
let parser = MarkupParser()
parser.parseMarkup(text)
//ctView.importAttrString(parser.attrString)
ctView.buildFrames(withAttrString: parser.attrString, andImages: parser.images)
运行一下,wonderful,一个可翻页效果的App就有了
预览
为App渲染图片
尽管Core text
无法直接绘制图片,但是它可以为图片预留显示空间 ,通过CTRun
的代理CTRunDelegate
,我们可以设定CTRun
的上升下降高度,和它的宽度,模型如下图所示
每当Core Text
遇到一个CTRun
,它就会询问代理我需要为这数据块预留多少空间?
,通过CTRunDelegate
,我们就能为图片显示预留出空间了
首先在MarkupParser.swift
中添加解析img
标签的代码
// image 数组 添加 图片属性字典
images += [["width": NSNumber(value: Float(width)),
"height": NSNumber(value: Float(height)),
"filename": filename,
"location": NSNumber(value: attrString.length)]]
// 定义CTRun属性结构体
struct RunStruct {
let ascent: CGFloat
let descent: CGFloat
let width: CGFloat
}
// Memory指针 相当于RunStruct 结构体指针
let extentBuffer = UnsafeMutablePointer<RunStruct>.allocate(capacity: 1)
extentBuffer.initialize(to: RunStruct(ascent: height, descent: 0, width: width))
// 创建CTRunDelegateCallbacks 控制占位
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
})
// 创建绑定了回调的代理
let delegate = CTRunDelegateCreate(&callbacks, extentBuffer)
// 将代理封装至属性字典
let attrDictionaryDelegate = [(kCTRunDelegateAttributeName as NSAttributedStringKey): (delegate as Any)]
attrString.append(NSAttributedString(string: " ", attributes: attrDictionaryDelegate))
}
现在MarkupParser
可以解析处理img
标签了,现在我们只需让CTColumnView && CTView
绘制出来就行了
对于CTColumnView.swift
添加属性
var images: [(image: UIImage, frame: CGRect)] = []
并在draw(_ rect: CGRect)
中添加绘制图片代码
// 绘制图片
for imageData in images {
if let image = imageData.image.cgImage {
let imgBounds = imageData.frame
context.draw(image, in: imgBounds)
}
}
在CTView.swift
中添加属性
var imageIndex: Int!
并且在buildFrames(withAttrString:andImages:):
方法中做初始化
imageIndex = 0
再次添加attachImagesWithFrame(_:ctframe:margin:columnView)
方法
func attachImagesWithFrame(_ images: [[String: Any]],
ctframe: CTFrame,
margin: CGFloat,
columnView: CTColumnView) {
// 获取ctframe 的`CTLine`数组
let lines = CTFrameGetLines(ctframe) as NSArray
// 使用CTFrameGetLineOrigins 将ctframe中的行origin 复制到数组 origins
var origins = [CGPoint](repeating: .zero, count: lines.count)
// CFRangeMake(0, 0)代表转换整个CTFrame
CTFrameGetLineOrigins(ctframe, CFRangeMake(0, 0), &origins)
// 获取图片对象的location属性,如果没有值直接返回
var nextImage = images[imageIndex]
guard var imgLocation = nextImage["location"] as? Int else {
return
}
// 遍历CTLine
for lineIndex in 0..<lines.count {
}
}
继续在循环中添加代码
// 遍历CTLine
for lineIndex in 0..<lines.count {
let line = lines[lineIndex] as! CTLine
// 如果CTRun, 文件名,图片都存在
if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun],
let imageFilename = nextImage["filename"] as? String,
let img = UIImage(named: imageFilename) {
for run in glyphRuns {
// 如果当前CTRun的范围range没有包含nextImage,直接进入一下循环
let runRange = CTRunGetStringRange(run)
if runRange.location > imgLocation || runRange.location + runRange.length <= imgLocation {
continue
}
// 通过 CTRunGetTypographicBounds 计算图片的大小
var imgBounds: CGRect = .zero
var ascent: CGFloat = 0
imgBounds.size.width = CGFloat(CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &ascent, nil, nil))
imgBounds.size.height = ascent
// 通过 CTLineGetOffsetForStringIndex 计算 CTLine x轴的偏移量,
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
// 偏移量需要加上 imgBounds 的 origin
imgBounds.origin.x = origins[lineIndex].x + xOffset
imgBounds.origin.y = origins[lineIndex].y
// 将image 和 image绘制的位置 加入 columnView
columnView.images += [(image: img, frame: imgBounds)]
// 图片下标自增,更新imgLocation
imageIndex! += 1
if imageIndex < images.count {
nextImage = images[imageIndex]
imgLocation = (nextImage["location"] as AnyObject).intValue
}
}
}
}
最终,在buildFrames(withAttrString:andImages:)
方法中,语句pageView.addSubview(column)
之前调用即可
if images.count > imageIndex {
attachImagesWithFrame(images, ctframe: ctframe, margin: settings.margin, columnView: column)
}
大功告成
APP
github完整源码地址(注释完备) 传送门:https://github.com/madaoCN/CoreTextMagzine 给个start呗