找回密码
 立即注册
首页 业界区 安全 iOS/Swift:深入理解iOS CoreText API

iOS/Swift:深入理解iOS CoreText API

后沛若 昨天 23:00
这篇文章是从0到1自定义富文本渲染的原理篇之一,此外你还可能感兴趣:

  • 一文读懂字符与编码
  • 一文读懂字符、字形、字体
  • 一文读懂字体文件
  • 从0到1自定义文字排版引擎:原理篇
  • 逆向分析CoreText中的字体级联/Font Fallback机制
  • 新手小白也能看懂的LLDB技巧/逆向技巧
更多内容可订阅公众号「非专业程序员Ping」,文中所有代码可在公众号后台回复 “CoreText” 获取。
一、引言

CoreText是iOS/macOS中的文字排版引擎,提供了一系列对文本精确操作的API;UIKit中UILabel、UITextView等文本组件底层都是基于CoreText的,可以看官方提供的层级图:
1.png

本文的目的是结合实际使用例子,来介绍和总结CoreText中的重要概念和API。
二、重要概念

CoreText中有几个重要概念:CTTypesetter、CTFramesetter、CTFrame、CTLine、CTRun;它们之间的关系可以看官方提供的层级图:
2.png

一篇文档可以分为:文档 -> 段落 -> 段落中的行 -> 行中的文字,类似的,CoreText也是按这个结构来组织和管理API的,我们也可以根据诉求来选择不同层级的API。
2.1 CTFramesetter

CTFramesetter类似于文档的概念,它负责将多段文本进行排版,管理多个段落(CTFrame)。
CTFramesetter的输入是属性字符串(NSAttributedString)和路径(CGPath),负责将文本在指定路径上进行排版。
2.2 CTFrame

CTFrame类似于段落的概念,其中包含了若干行(CTLine)以及对应行的位置、方向、行间距等信息。
2.3 CTLine

CTLine类似于行的概念,其中包含了若干个字形(CTRun)以及对应字形的位置等信息。
2.4 CTRun

需要注意CTRun不是单个的字符,而是一段连续的且具有相同属性(字体、颜色等)的字形(Glyph)。
如下,每个虚线框都代表一个CTRun:
3.png

2.5 CTTypesetter

CTTypesetter支持对属性字符串进行换行,可以通过CTTypesetter来自定义换行(比如按word换行、按char换行等)或控制每行的内容,可以理解成更精细化的控制。
三、重要API

3.1 CTFramesetter

1)CTFramesetterCreateWithAttributedString
  1. func CTFramesetterCreateWithAttributedString(_ attrString: CFAttributedString) -> CTFramesetter
复制代码
通过属性字符串来创建CTFramesetter。
我们可以构造不同字体、颜色、大小的属性字符串,然后从属性字符串构造CTFramesetter,之后可以继续往下拆分得到段落、行、字形等信息,这样可以实现自定义排版、图文混排等复杂富文本样式。
2)CTFramesetterCreateWithTypesetter
  1. func CTFramesetterCreateWithTypesetter(_ typesetter: CTTypesetter) -> CTFramesetter
复制代码
通过CTTypesetter来创建CTFramesetter,当我们需要对文本实现更精细控制,比如自定义换行时,可以自己构造CTTypesetter。
3)CTFramesetterCreateFrame
  1. func CTFramesetterCreateFrame(
  2.     _ framesetter: CTFramesetter,
  3.     _ stringRange: CFRange,
  4.     _ path: CGPath,
  5.     _ frameAttributes: CFDictionary?
  6. ) -> CTFrame
复制代码
生成CTFrame:在指定路径(path)为属性字符串的指定范围(stringRange)生成CTFrame。

  • framesetter
  • stringRange:字符范围,注意需要以UTF-16编码格式计算;当 stringRange.length = 0 时,表示从起点(stringRange.location)到字符结束为止;比如当 CFRangeMake(0, 0) 表示全字符范围
  • path:排版路径,可以是不规则矩形,这意味着可以传入不规则图形来实现文字环绕等高级效果
  • frameAttributes:一个可选的字典,可以用于控制段落级别的布局行为,比如行间距等,一般用不到,可传 nil
4)CTFramesetterSuggestFrameSizeWithConstraints
  1. func CTFramesetterSuggestFrameSizeWithConstraints(
  2.     _ framesetter: CTFramesetter,
  3.     _ stringRange: CFRange,
  4.     _ frameAttributes: CFDictionary?,
  5.     _ constraints: CGSize,
  6.     _ fitRange: UnsafeMutablePointer<CFRange>?
  7. ) -> CGSize
复制代码
计算文本宽高:在给定约束尺寸(constraints)下计算文本范围(stringRange)的实际宽高。
如下,我们可以计算出在宽高 100 x 100 的范围内排版,实际能放下的文本范围(fitRange)以及实际的文本尺寸:
  1. let attr = NSAttributedString(string: "这是一段测试文本,通过调用CTFramesetterSuggestFrameSizeWithConstraints来计算文本的宽高信息,并返回实际的range", attributes: [
  2.     .font: UIFont.systemFont(ofSize: 16),
  3.     .foregroundColor: UIColor.black
  4. ])
  5. let framesetter = CTFramesetterCreateWithAttributedString(attr)
  6. var fitRange = CFRange(location: 0, length: 0)
  7. let size = CTFramesetterSuggestFrameSizeWithConstraints(
  8.     framesetter,
  9.     CFRangeMake(0, 0),
  10.     nil,
  11.     CGSize(width: 100, height: 100),
  12.     &fitRange
  13. )
  14. print(size, fitRange, attr.length)
复制代码
这个API在分页时非常有用,比如微信读书的翻页效果,需要知道在哪个地方截断,PDF的分页排版等。
3.1.1 CTFramesetter使用示例

1)实现一个支持AutoLayout且高度靠内容撑开的富文本View
4.png

2)在圆形路径中绘制文本
5.png

3)文本分页:模拟微信读书的分页逻辑
6.png

3.2 CTFrame

1)CTFramesetterCreateFrame
  1. func CTFramesetterCreateFrame(
  2.     _ framesetter: CTFramesetter,
  3.     _ stringRange: CFRange,
  4.     _ path: CGPath,
  5.     _ frameAttributes: CFDictionary?
  6. ) -> CTFrame
复制代码
创建CTFrame,在CTFramesetter一节中有介绍过,这是创建CTFrame的唯一方式。
2)CTFrameGetStringRange
  1. func CTFrameGetStringRange(_ frame: CTFrame) -> CFRange
复制代码
获取CTFrame包含的字符范围。
我们在调用CTFramesetterCreateFrame创建CTFrame时,会传入一个 stringRange 的参数,CTFrameGetStringRange也可以理解成获取这个 stringRange,区别是处理了当 stringRange.length 为0的情况。
3)CTFrameGetVisibleStringRange
  1. func CTFrameGetVisibleStringRange(_ frame: CTFrame) -> CFRange
复制代码
获取CTFrame实际可见的字符范围。
我们在调用CTFramesetterCreateFrame创建CTFrame时,会传入path,可能会把字符截断,CTFrameGetVisibleStringRange返回的就是可见的字符范围。
需要注意和CTFrameGetStringRange进行区分,可以用如下Demo验证:
  1. let longText = String(repeating: "这是一个分栏布局的例子。Core Text 允许我们将一个长的属性字符串(CFAttributedString)流动到多个不同的路径(CGPath)中。我们只需要创建一个 CTFramesetter,然后循环调用 CTFramesetterCreateFrame。每次调用后,我们使用 CTFrameGetStringRange 来找出有多少文本被排入了当前的框架,然后将下一个框架的起始索引设置为这个范围的末尾。 ", count: 10)
  2. let attributedText = NSAttributedString(string: longText, attributes: [
  3.     .font: UIFont.systemFont(ofSize: 12),
  4.     .foregroundColor: UIColor.darkText
  5. ])
  6. let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
  7. let path = CGPath(rect: .init(x: 10, y: 100, width: 400, height: 200), transform: nil)
  8. let frame = CTFramesetterCreateFrame(
  9.     framesetter,
  10.     CFRange(location: 100, length: 0),
  11.     path,
  12.     nil
  13. )
  14. // 输出:CFRange(location: 100, length: 1980)
  15. print(CTFrameGetStringRange(frame))
  16. // 输出:CFRange(location: 100, length: 584)
  17. print(CTFrameGetVisibleStringRange(frame))
复制代码
4)CTFrameGetPath
  1. func CTFrameGetPath(_ frame: CTFrame) -> CGPath
复制代码
获取创建CTFrame时传入的path。
5)CTFrameGetLines
  1. func CTFrameGetLines(_ frame: CTFrame) -> CFArray
复制代码
获取CTFrame中所有的行(CTLine)。
6)CTFrameGetLineOrigins
  1. func CTFrameGetLineOrigins(
  2.     _ frame: CTFrame,
  3.     _ range: CFRange,
  4.     _ origins: UnsafeMutablePointer<CGPoint>
  5. )
复制代码
获取每一行的起点坐标。
用法示例:
  1. let lines = CTFrameGetLines(frame) as! [CTLine]
  2. var origins = [CGPoint](repeating: .zero, count: lines.count)
  3. CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
复制代码
7)CTFrameDraw
  1. func CTFrameDraw(
  2.     _ frame: CTFrame,
  3.     _ context: CGContext
  4. )
复制代码
绘制CTFrame。
3.2.1 CTFrame使用示例

1)绘制CTFrame
7.png

2)高亮某一行
8.png

3)检测点击字符
9.png

3.3 CTLine

1)CTLineCreateWithAttributedString
  1. func CTLineCreateWithAttributedString(_ attrString: CFAttributedString) -> CTLine
复制代码
从属性字符串创建单行CTLine,如果字符串中有换行符(\n)的话,换行符会被转换成空格,如下:
  1. let line = CTLineCreateWithAttributedString(
  2.     NSAttributedString(string: "Hello CoreText\nWorld", attributes: [.font: UIFont.systemFont(ofSize: 16)])
  3. )
复制代码
2)CTLineCreateTruncatedLine
  1. func CTLineCreateTruncatedLine(
  2.     _ line: CTLine,
  3.     _ width: Double,
  4.     _ truncationType: CTLineTruncationType,
  5.     _ truncationToken: CTLine?
  6. ) -> CTLine?
复制代码
创建一个被截断的新行。

  • line:待截断的行
  • width:在多少宽度截断
  • truncationType:start/end/middle,截断类型
  • truncationToken:在截断处添加的字符,nil表示不添加,一般使用省略符(...)
  1. let truncationToken = CTLineCreateWithAttributedString(
  2.     NSAttributedString(string: "…", attributes: [.font: UIFont.systemFont(ofSize: 16)])
  3. )
  4. let truncated = CTLineCreateTruncatedLine(line, 100, .end, truncationToken)
复制代码
3)CTLineCreateJustifiedLine
  1. func CTLineCreateJustifiedLine(
  2.     _ line: CTLine,
  3.     _ justificationFactor: CGFloat,
  4.     _ justificationWidth: Double
  5. ) -> CTLine?
复制代码
创建一个两端对齐的新行,类似书籍或报纸中两端对齐的排版效果。

  • line:原始行
  • justificationFactor:justificationFactor = 1表示完全缩放到指定宽度;0 < justificationFactor < 1表示部分缩放到指定宽度,可以看示例代码
  • justificationWidth:缩放指定宽度
示例:
10.png

4)CTLineDraw
  1. func CTLineDraw(
  2.     _ line: CTLine,
  3.     _ context: CGContext
  4. )
复制代码
绘制行。
5)CTLineGetGlyphCount
  1. func CTLineGetGlyphCount(_ line: CTLine) -> CFIndex
复制代码
获取行内字形总数。
6)CTLineGetGlyphRuns
  1. func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray
复制代码
获取行内所有的CTRun。
7)CTLineGetStringRange
  1. func CTLineGetStringRange(_ line: CTLine) -> CFRange
复制代码
获取该行对应的字符范围。
8)CTLineGetPenOffsetForFlush
  1. func CTLineGetPenOffsetForFlush(
  2.     _ line: CTLine,
  3.     _ flushFactor: CGFloat,
  4.     _ flushWidth: Double
  5. ) -> Double
复制代码
获取在指定宽度绘制时的水平偏移,一般配合 CGContext.textPosition 使用,可用于实现在固定宽度下文本的左对齐、右对齐、居中对齐及自定义水平偏移等。
示例:
11.png

9)CTLineGetImageBounds
  1. func CTLineGetImageBounds(
  2.     _ line: CTLine,
  3.     _ context: CGContext?
  4. ) -> CGRect
复制代码
获取行的​视觉边界​;注意 CTLineGetImageBounds 获取的是​相对于CTLine局部坐标系的矩形​,即以textPosition为原点的矩形。
视觉边界可以看下面的例子,与之相对的是布局边界;这个API在实际应用中不常见,除非有特殊诉求,比如要检测精确的内容点击范围,给行绘制紧贴背景等。
12.png

10)CTLineGetTypographicBounds
  1. func CTLineGetTypographicBounds(
  2.     _ line: CTLine,
  3.     _ ascent: UnsafeMutablePointer<CGFloat>?,
  4.     _ descent: UnsafeMutablePointer<CGFloat>?,
  5.     _ leading: UnsafeMutablePointer<CGFloat>?
  6. ) -> Double
复制代码
获取上行(ascent)、下行(descent)、行距(leading)。
这几个概念不熟悉的可以参考:一文读懂字符、字形、字体
想了解这几个数值最终是从哪个地方读取的可以参考:一文读懂字体文件
通过这个API我们可以手动构造​布局边界​(见上面的例子),一般用于点击检测、绘制行背景等。
11)CTLineGetTrailingWhitespaceWidth
  1. func CTLineGetTrailingWhitespaceWidth(_ line: CTLine) -> Double
复制代码
获取行尾空白字符的宽度(比如空格、制表符 (\t) 等),一般用于实现对齐时基于可见文本对齐等。
示例:
  1. let line = CTLineCreateWithAttributedString(
  2.     NSAttributedString(string: "Hello  ", attributes: [.font: UIFont.systemFont(ofSize: 16)])
  3. )
  4. let totalWidth = CTLineGetTypographicBounds(line, nil, nil, nil)
  5. let trailingWidth = CTLineGetTrailingWhitespaceWidth(line)
  6. print("总宽度: \(totalWidth)")
  7. print("尾部空白宽度: \(trailingWidth)")
  8. print("可见文字宽度: \(totalWidth - trailingWidth)")
复制代码
12)CTLineGetStringIndexForPosition
  1. func CTLineGetStringIndexForPosition(
  2.     _ line: CTLine,
  3.     _ position: CGPoint
  4. ) -> CFIndex
复制代码
获取给定位置处的字符串索引。
​注意:​虽然官方文档说这个API一般用于点击检测,但实际测试下来​这个API返回的点击索引不准确​,比如虽然点击的是当前字符,但实际返回的索引是后一个字符的,如下:
13.png

查了下,发现这个API一般是用于计算光标位置的,比如点击「行」的左半部分,希望光标出现在「行」左侧,如果点击「行」的右半部分,希望光标出现在「行」的右侧。
如果我们想精确做字符的点击检测,推荐使用字符/行的bounds来计算,参考「CTFrame使用示例-3」例子。
13)CTLineGetOffsetForStringIndex
  1. func CTLineGetOffsetForStringIndex(
  2.     _ line: CTLine,
  3.     _ charIndex: CFIndex,
  4.     _ secondaryOffset: UnsafeMutablePointer<CGFloat>?
  5. ) -> CGFloat
复制代码
获取指定字符索引相对于行的 x 轴偏移量。

  • line:待查询的行
  • charIndex:要查询的字符在原始属性字符串中的索引
  • secondaryOffset:次要偏移值,在简单的LTR文本中,可以忽略(传nil即可),但在复杂的双向文本(BiDi)中会用到
使用场景:

  • 字符点击检测:见「CTFrame使用示例-3」例子
  • 给某段字符绘制高亮和下划线
  • 定位某个字符:比如想在一段文本中的某个字符上方显示弹窗,可以用这个API先定位该字符
14)CTLineEnumerateCaretOffsets
  1. func CTLineEnumerateCaretOffsets(
  2.     _ line: CTLine,
  3.     _ block: @escaping (Double, CFIndex, Bool, UnsafeMutablePointer<Bool>) -> Void
  4. )
复制代码
遍历一行中光标所有的有效位置。

  • line
  • block

    • Double:offset,相对于行的 x 轴偏移
    • CFIndex:与此光标位置相关的字符串索引
    • Bool:true 表示光标位于字符的前边(在 LTR 中即左侧),false 表示光标位于字符的后边(在 LTR 中即右侧);在 BiDi 中需要特殊同一个字符可能会回调两次(比如 BiDi 边界的地方),需要用这个值区分前后
    • UnsafeMutablePointer:stop 指针,赋值为 true 会停止遍历

使用场景:

  • 绘制光标:富文本选区或者文本编辑器中,要绘制光标时,可以先通过 CTLineGetStringIndexForPosition 获取字符索引,再通过这个函数或者 CTLineGetOffsetForStringIndex 获取光标偏移
  • 实现光标的左右键移动:可以用这个API将所有的光标位置存储到数组,并按offset排序,当用户按下右箭头 -> 时,可以找到当前光标index,将index + 1即是下一个光标位置
3.3.1 CTLine使用示例

除了上面例子,再举一个:
1)高亮特定字符
14.png

3.4 CTRun

CTRun相关API比较基础,这里主要介绍常用的。
1)CTLineGetGlyphRuns
  1. func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray
复制代码
获取CTRun的唯一方式。
2)CTRunGetAttributes
  1. func CTRunGetAttributes(_ run: CTRun) -> CFDictionary
复制代码
获取CTRun的属性;比如想知道这个CTRun是不是粗体,是不是链接,是不是目标Run等,都可以通过这个API。
示例:
  1. guard let attributes = CTRunGetAttributes(run) as? [NSAttributedString.Key: Any] else { continue }
  2. // 现在你可以检查属性
  3. if let color = attributes[.foregroundColor] as? UIColor {
  4.     // ...
  5. }
  6. if let font = attributes[.font] as? UIFont {
  7.     // ...
  8. }
  9. if let link = attributes[NSAttributedString.Key("my_custom_link_key")] {
  10.     // 这就是那个可点击的 run!
  11. }
复制代码
3)CTRunGetStringRange
  1. func CTRunGetStringRange(_ run: CTRun) -> CFRange
复制代码
获取CTRun对应于原始属性字符串的哪个范围。
4)CTRunGetTypographicBounds
  1. func CTRunGetTypographicBounds(
  2.     _ run: CTRun,
  3.     _ range: CFRange,
  4.     _ ascent: UnsafeMutablePointer<CGFloat>?,
  5.     _ descent: UnsafeMutablePointer<CGFloat>?,
  6.     _ leading: UnsafeMutablePointer<CGFloat>?
  7. ) -> Double
复制代码
获取CTRun的度量信息,同上面许多API一样,当 range.length 为0时表示直到CTRun文本末尾。
5)CTRunGetPositions
  1. func CTRunGetPositions(
  2.     _ run: CTRun,
  3.     _ range: CFRange,
  4.     _ buffer: UnsafeMutablePointer<CGPoint>
  5. )
复制代码
获取CTRun中每一个字形的位置,注意这里的位置是相对于CTLine原点的。
6)CTRunDelegate
CTRunDelegate允许为属性字符串中的一段文本提供自定义布局测量信息,一般用于在文本中插入图片、自定义View等非文本元素。
比如在文本中间插入图片:
15.png

3.4.1 CTRun使用示例

1)基础绘制
16.png

2)链接点击识别
17.png

3.5 CTTypesetter

CTFramesetter会自动处理换行,当我们想手动控制换行时,可以用CTTypesetter。
1)CTTypesetterSuggestLineBreak
  1. func CTTypesetterSuggestLineBreak(
  2.     _ typesetter: CTTypesetter,
  3.     _ startIndex: CFIndex,
  4.     _ width: Double
  5. ) -> CFIndex
复制代码
按单词(word)换行。
如下示例,输出:Try word 和wrapping
  1. let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
  2. let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
  3. let totalLength = attributedString.length // UTF-16 长度
  4. var startIndex = 0
  5. var lineCount = 1
  6. while startIndex < totalLength {
  7.     let charCount = CTTypesetterSuggestLineBreak(typesetter, startIndex, 100)
  8.     // 如果返回 0,意味着一个字符都放不下(或已结束)
  9.     if charCount == 0 {
  10.         if startIndex < totalLength {
  11.             print("Line \(lineCount): (Error) 无法放下剩余字符。")
  12.         }
  13.         break
  14.     }
  15.     // 获取这一行的子字符串
  16.     let range = NSRange(location: startIndex, length: charCount)
  17.     let lineString = (attributedString.string as NSString).substring(with: range)
  18.     print("Line \(lineCount): '\(lineString)' (UTF-16 字符数: \(charCount))")
  19.     // 更新下一次循环的起始索引
  20.     startIndex += charCount
  21.     lineCount += 1
  22. }
复制代码
2)CTTypesetterSuggestClusterBreak
  1. func CTTypesetterSuggestClusterBreak(
  2.     _ typesetter: CTTypesetter,
  3.     _ startIndex: CFIndex,
  4.     _ width: Double
  5. ) -> CFIndex
复制代码
按字符(char)换行。
如下示例,输出:Try word wr和apping
  1. let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
  2. let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
  3. let totalLength = attributedString.length // UTF-16 长度
  4. var startIndex = 0
  5. var lineCount = 1
  6. while startIndex < totalLength {
  7.     let charCount = CTTypesetterSuggestClusterBreak(typesetter, startIndex, 100)
  8.     // 如果返回 0,意味着一个字符都放不下(或已结束)
  9.     if charCount == 0 {
  10.         if startIndex < totalLength {
  11.             print("Line \(lineCount): (Error) 无法放下剩余字符。")
  12.         }
  13.         break
  14.     }
  15.     // 获取这一行的子字符串
  16.     let range = NSRange(location: startIndex, length: charCount)
  17.     let lineString = (attributedString.string as NSString).substring(with: range)
  18.     print("Line \(lineCount): '\(lineString)' (UTF-16 字符数: \(charCount))")
  19.     // 更新下一次循环的起始索引
  20.     startIndex += charCount
  21.     lineCount += 1
  22. }
复制代码
四、总结

以上是CoreText中常用的API及其场景代码举例,完整示例代码可在公众号「非专业程序员Ping」回复 “CoreText” 获取。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册