iOS/Swift:深入理解iOS CoreText API
這篇文章是從0到1自定義富文本渲染的原理篇之一,此外你還可能感興趣:
- 一文讀懂字符與編碼
- 一文讀懂字符、字形、字體
- 一文讀懂字體文件
- 從0到1自定義文字排版引擎:原理篇
- 逆向分析CoreText中的字體級聯/Font Fallback機制
- 新手小白也能看懂的LLDB技巧/逆向技巧
更多內容可訂閱公眾號「非專業程序員Ping」,文中所有代碼可在公眾號后臺回復 “CoreText” 獲取。
一、引言
CoreText是iOS/macOS中的文字排版引擎,提供了一系列對文本精確操作的API;UIKit中UILabel、UITextView等文本組件底層都是基于CoreText的,可以看官方提供的層級圖:

本文的目的是結合實際使用例子,來介紹和總結CoreText中的重要概念和API。
二、重要概念
CoreText中有幾個重要概念:CTTypesetter、CTFramesetter、CTFrame、CTLine、CTRun;它們之間的關系可以看官方提供的層級圖:

一篇文檔可以分為:文檔 -> 段落 -> 段落中的行 -> 行中的文字,類似的,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:

2.5 CTTypesetter
CTTypesetter支持對屬性字符串進行換行,可以通過CTTypesetter來自定義換行(比如按word換行、按char換行等)或控制每行的內容,可以理解成更精細化的控制。
三、重要API
3.1 CTFramesetter
1)CTFramesetterCreateWithAttributedString
func CTFramesetterCreateWithAttributedString(_ attrString: CFAttributedString) -> CTFramesetter
通過屬性字符串來創建CTFramesetter。
我們可以構造不同字體、顏色、大小的屬性字符串,然后從屬性字符串構造CTFramesetter,之后可以繼續往下拆分得到段落、行、字形等信息,這樣可以實現自定義排版、圖文混排等復雜富文本樣式。
2)CTFramesetterCreateWithTypesetter
func CTFramesetterCreateWithTypesetter(_ typesetter: CTTypesetter) -> CTFramesetter
通過CTTypesetter來創建CTFramesetter,當我們需要對文本實現更精細控制,比如自定義換行時,可以自己構造CTTypesetter。
3)CTFramesetterCreateFrame
func CTFramesetterCreateFrame(
_ framesetter: CTFramesetter,
_ stringRange: CFRange,
_ path: CGPath,
_ frameAttributes: CFDictionary?
) -> CTFrame
生成CTFrame:在指定路徑(path)為屬性字符串的指定范圍(stringRange)生成CTFrame。
framesetterstringRange:字符范圍,注意需要以UTF-16編碼格式計算;當 stringRange.length = 0 時,表示從起點(stringRange.location)到字符結束為止;比如當 CFRangeMake(0, 0) 表示全字符范圍path:排版路徑,可以是不規則矩形,這意味著可以傳入不規則圖形來實現文字環繞等高級效果frameAttributes:一個可選的字典,可以用于控制段落級別的布局行為,比如行間距等,一般用不到,可傳 nil
4)CTFramesetterSuggestFrameSizeWithConstraints
func CTFramesetterSuggestFrameSizeWithConstraints(
_ framesetter: CTFramesetter,
_ stringRange: CFRange,
_ frameAttributes: CFDictionary?,
_ constraints: CGSize,
_ fitRange: UnsafeMutablePointer<CFRange>?
) -> CGSize
計算文本寬高:在給定約束尺寸(constraints)下計算文本范圍(stringRange)的實際寬高。
如下,我們可以計算出在寬高 100 x 100 的范圍內排版,實際能放下的文本范圍(fitRange)以及實際的文本尺寸:
let attr = NSAttributedString(string: "這是一段測試文本,通過調用CTFramesetterSuggestFrameSizeWithConstraints來計算文本的寬高信息,并返回實際的range", attributes: [
.font: UIFont.systemFont(ofSize: 16),
.foregroundColor: UIColor.black
])
let framesetter = CTFramesetterCreateWithAttributedString(attr)
var fitRange = CFRange(location: 0, length: 0)
let size = CTFramesetterSuggestFrameSizeWithConstraints(
framesetter,
CFRangeMake(0, 0),
nil,
CGSize(width: 100, height: 100),
&fitRange
)
print(size, fitRange, attr.length)
這個API在分頁時非常有用,比如微信讀書的翻頁效果,需要知道在哪個地方截斷,PDF的分頁排版等。
3.1.1 CTFramesetter使用示例
1)實現一個支持AutoLayout且高度靠內容撐開的富文本View

2)在圓形路徑中繪制文本

3)文本分頁:模擬微信讀書的分頁邏輯

3.2 CTFrame
1)CTFramesetterCreateFrame
func CTFramesetterCreateFrame(
_ framesetter: CTFramesetter,
_ stringRange: CFRange,
_ path: CGPath,
_ frameAttributes: CFDictionary?
) -> CTFrame
創建CTFrame,在CTFramesetter一節中有介紹過,這是創建CTFrame的唯一方式。
2)CTFrameGetStringRange
func CTFrameGetStringRange(_ frame: CTFrame) -> CFRange
獲取CTFrame包含的字符范圍。
我們在調用CTFramesetterCreateFrame創建CTFrame時,會傳入一個 stringRange 的參數,CTFrameGetStringRange也可以理解成獲取這個 stringRange,區別是處理了當 stringRange.length 為0的情況。
3)CTFrameGetVisibleStringRange
func CTFrameGetVisibleStringRange(_ frame: CTFrame) -> CFRange
獲取CTFrame實際可見的字符范圍。
我們在調用CTFramesetterCreateFrame創建CTFrame時,會傳入path,可能會把字符截斷,CTFrameGetVisibleStringRange返回的就是可見的字符范圍。
需要注意和CTFrameGetStringRange進行區分,可以用如下Demo驗證:
let longText = String(repeating: "這是一個分欄布局的例子。Core Text 允許我們將一個長的屬性字符串(CFAttributedString)流動到多個不同的路徑(CGPath)中。我們只需要創建一個 CTFramesetter,然后循環調用 CTFramesetterCreateFrame。每次調用后,我們使用 CTFrameGetStringRange 來找出有多少文本被排入了當前的框架,然后將下一個框架的起始索引設置為這個范圍的末尾。 ", count: 10)
let attributedText = NSAttributedString(string: longText, attributes: [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor.darkText
])
let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString)
let path = CGPath(rect: .init(x: 10, y: 100, width: 400, height: 200), transform: nil)
let frame = CTFramesetterCreateFrame(
framesetter,
CFRange(location: 100, length: 0),
path,
nil
)
// 輸出:CFRange(location: 100, length: 1980)
print(CTFrameGetStringRange(frame))
// 輸出:CFRange(location: 100, length: 584)
print(CTFrameGetVisibleStringRange(frame))
4)CTFrameGetPath
func CTFrameGetPath(_ frame: CTFrame) -> CGPath
獲取創建CTFrame時傳入的path。
5)CTFrameGetLines
func CTFrameGetLines(_ frame: CTFrame) -> CFArray
獲取CTFrame中所有的行(CTLine)。
6)CTFrameGetLineOrigins
func CTFrameGetLineOrigins(
_ frame: CTFrame,
_ range: CFRange,
_ origins: UnsafeMutablePointer<CGPoint>
)
獲取每一行的起點坐標。
用法示例:
let lines = CTFrameGetLines(frame) as! [CTLine]
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
7)CTFrameDraw
func CTFrameDraw(
_ frame: CTFrame,
_ context: CGContext
)
繪制CTFrame。
3.2.1 CTFrame使用示例
1)繪制CTFrame

2)高亮某一行

3)檢測點擊字符

3.3 CTLine
1)CTLineCreateWithAttributedString
func CTLineCreateWithAttributedString(_ attrString: CFAttributedString) -> CTLine
從屬性字符串創建單行CTLine,如果字符串中有換行符(\n)的話,換行符會被轉換成空格,如下:
let line = CTLineCreateWithAttributedString(
NSAttributedString(string: "Hello CoreText\nWorld", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)
2)CTLineCreateTruncatedLine
func CTLineCreateTruncatedLine(
_ line: CTLine,
_ width: Double,
_ truncationType: CTLineTruncationType,
_ truncationToken: CTLine?
) -> CTLine?
創建一個被截斷的新行。
line:待截斷的行width:在多少寬度截斷truncationType:start/end/middle,截斷類型- truncationToken:在截斷處添加的字符,nil表示不添加,一般使用省略符(...)
let truncationToken = CTLineCreateWithAttributedString(
NSAttributedString(string: "…", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)
let truncated = CTLineCreateTruncatedLine(line, 100, .end, truncationToken)
3)CTLineCreateJustifiedLine
func CTLineCreateJustifiedLine(
_ line: CTLine,
_ justificationFactor: CGFloat,
_ justificationWidth: Double
) -> CTLine?
創建一個兩端對齊的新行,類似書籍或報紙中兩端對齊的排版效果。
line:原始行justificationFactor:justificationFactor <= 0表示不縮放,即與原始行相同;justificationFactor >= 1表示完全縮放到指定寬度;0 < justificationFactor < 1表示部分縮放到指定寬度,可以看示例代碼justificationWidth:縮放指定寬度
示例:

4)CTLineDraw
func CTLineDraw(
_ line: CTLine,
_ context: CGContext
)
繪制行。
5)CTLineGetGlyphCount
func CTLineGetGlyphCount(_ line: CTLine) -> CFIndex
獲取行內字形總數。
6)CTLineGetGlyphRuns
func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray
獲取行內所有的CTRun。
7)CTLineGetStringRange
func CTLineGetStringRange(_ line: CTLine) -> CFRange
獲取該行對應的字符范圍。
8)CTLineGetPenOffsetForFlush
func CTLineGetPenOffsetForFlush(
_ line: CTLine,
_ flushFactor: CGFloat,
_ flushWidth: Double
) -> Double
獲取在指定寬度繪制時的水平偏移,一般配合 CGContext.textPosition 使用,可用于實現在固定寬度下文本的左對齊、右對齊、居中對齊及自定義水平偏移等。
示例:

9)CTLineGetImageBounds
func CTLineGetImageBounds(
_ line: CTLine,
_ context: CGContext?
) -> CGRect
獲取行的?視覺邊界?;注意 CTLineGetImageBounds 獲取的是?相對于CTLine局部坐標系的矩形?,即以textPosition為原點的矩形。
視覺邊界可以看下面的例子,與之相對的是布局邊界;這個API在實際應用中不常見,除非有特殊訴求,比如要檢測精確的內容點擊范圍,給行繪制緊貼背景等。

10)CTLineGetTypographicBounds
func CTLineGetTypographicBounds(
_ line: CTLine,
_ ascent: UnsafeMutablePointer<CGFloat>?,
_ descent: UnsafeMutablePointer<CGFloat>?,
_ leading: UnsafeMutablePointer<CGFloat>?
) -> Double
獲取上行(ascent)、下行(descent)、行距(leading)。
這幾個概念不熟悉的可以參考:一文讀懂字符、字形、字體
想了解這幾個數值最終是從哪個地方讀取的可以參考:一文讀懂字體文件
通過這個API我們可以手動構造?布局邊界?(見上面的例子),一般用于點擊檢測、繪制行背景等。
11)CTLineGetTrailingWhitespaceWidth
func CTLineGetTrailingWhitespaceWidth(_ line: CTLine) -> Double
獲取行尾空白字符的寬度(比如空格、制表符 (\t) 等),一般用于實現對齊時基于可見文本對齊等。
示例:
let line = CTLineCreateWithAttributedString(
NSAttributedString(string: "Hello ", attributes: [.font: UIFont.systemFont(ofSize: 16)])
)
let totalWidth = CTLineGetTypographicBounds(line, nil, nil, nil)
let trailingWidth = CTLineGetTrailingWhitespaceWidth(line)
print("總寬度: \(totalWidth)")
print("尾部空白寬度: \(trailingWidth)")
print("可見文字寬度: \(totalWidth - trailingWidth)")
12)CTLineGetStringIndexForPosition
func CTLineGetStringIndexForPosition(
_ line: CTLine,
_ position: CGPoint
) -> CFIndex
獲取給定位置處的字符串索引。
?注意:?雖然官方文檔說這個API一般用于點擊檢測,但實際測試下來?這個API返回的點擊索引不準確?,比如雖然點擊的是當前字符,但實際返回的索引是后一個字符的,如下:

查了下,發現這個API一般是用于計算光標位置的,比如點擊「行」的左半部分,希望光標出現在「行」左側,如果點擊「行」的右半部分,希望光標出現在「行」的右側。
如果我們想精確做字符的點擊檢測,推薦使用字符/行的bounds來計算,參考「CTFrame使用示例-3」例子。
13)CTLineGetOffsetForStringIndex
func CTLineGetOffsetForStringIndex(
_ line: CTLine,
_ charIndex: CFIndex,
_ secondaryOffset: UnsafeMutablePointer<CGFloat>?
) -> CGFloat
獲取指定字符索引相對于行的 x 軸偏移量。
line:待查詢的行charIndex:要查詢的字符在原始屬性字符串中的索引secondaryOffset:次要偏移值,在簡單的LTR文本中,可以忽略(傳nil即可),但在復雜的雙向文本(BiDi)中會用到
使用場景:
- 字符點擊檢測:見「CTFrame使用示例-3」例子
- 給某段字符繪制高亮和下劃線
- 定位某個字符:比如想在一段文本中的某個字符上方顯示彈窗,可以用這個API先定位該字符
14)CTLineEnumerateCaretOffsets
func CTLineEnumerateCaretOffsets(
_ line: CTLine,
_ block: @escaping (Double, CFIndex, Bool, UnsafeMutablePointer<Bool>) -> Void
)
遍歷一行中光標所有的有效位置。
lineblock- 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)高亮特定字符

3.4 CTRun
CTRun相關API比較基礎,這里主要介紹常用的。
1)CTLineGetGlyphRuns
func CTLineGetGlyphRuns(_ line: CTLine) -> CFArray
獲取CTRun的唯一方式。
2)CTRunGetAttributes
func CTRunGetAttributes(_ run: CTRun) -> CFDictionary
獲取CTRun的屬性;比如想知道這個CTRun是不是粗體,是不是鏈接,是不是目標Run等,都可以通過這個API。
示例:
guard let attributes = CTRunGetAttributes(run) as? [NSAttributedString.Key: Any] else { continue }
// 現在你可以檢查屬性
if let color = attributes[.foregroundColor] as? UIColor {
// ...
}
if let font = attributes[.font] as? UIFont {
// ...
}
if let link = attributes[NSAttributedString.Key("my_custom_link_key")] {
// 這就是那個可點擊的 run!
}
3)CTRunGetStringRange
func CTRunGetStringRange(_ run: CTRun) -> CFRange
獲取CTRun對應于原始屬性字符串的哪個范圍。
4)CTRunGetTypographicBounds
func CTRunGetTypographicBounds(
_ run: CTRun,
_ range: CFRange,
_ ascent: UnsafeMutablePointer<CGFloat>?,
_ descent: UnsafeMutablePointer<CGFloat>?,
_ leading: UnsafeMutablePointer<CGFloat>?
) -> Double
獲取CTRun的度量信息,同上面許多API一樣,當 range.length 為0時表示直到CTRun文本末尾。
5)CTRunGetPositions
func CTRunGetPositions(
_ run: CTRun,
_ range: CFRange,
_ buffer: UnsafeMutablePointer<CGPoint>
)
獲取CTRun中每一個字形的位置,注意這里的位置是相對于CTLine原點的。
6)CTRunDelegate
CTRunDelegate允許為屬性字符串中的一段文本提供自定義布局測量信息,一般用于在文本中插入圖片、自定義View等非文本元素。
比如在文本中間插入圖片:

3.4.1 CTRun使用示例
1)基礎繪制

2)鏈接點擊識別

3.5 CTTypesetter
CTFramesetter會自動處理換行,當我們想手動控制換行時,可以用CTTypesetter。
1)CTTypesetterSuggestLineBreak
func CTTypesetterSuggestLineBreak(
_ typesetter: CTTypesetter,
_ startIndex: CFIndex,
_ width: Double
) -> CFIndex
按單詞(word)換行。
如下示例,輸出:Try word 和wrapping
let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
let totalLength = attributedString.length // UTF-16 長度
var startIndex = 0
var lineCount = 1
while startIndex < totalLength {
let charCount = CTTypesetterSuggestLineBreak(typesetter, startIndex, 100)
// 如果返回 0,意味著一個字符都放不下(或已結束)
if charCount == 0 {
if startIndex < totalLength {
print("Line \(lineCount): (Error) 無法放下剩余字符。")
}
break
}
// 獲取這一行的子字符串
let range = NSRange(location: startIndex, length: charCount)
let lineString = (attributedString.string as NSString).substring(with: range)
print("Line \(lineCount): '\(lineString)' (UTF-16 字符數: \(charCount))")
// 更新下一次循環的起始索引
startIndex += charCount
lineCount += 1
}
2)CTTypesetterSuggestClusterBreak
func CTTypesetterSuggestClusterBreak(
_ typesetter: CTTypesetter,
_ startIndex: CFIndex,
_ width: Double
) -> CFIndex
按字符(char)換行。
如下示例,輸出:Try word wr和apping
let attrStringWith = NSAttributedString(string: "Try word wrapping", attributes: [.font: UIFont.systemFont(ofSize: 18)])
let typesetter = CTTypesetterCreateWithAttributedString(attributedString)
let totalLength = attributedString.length // UTF-16 長度
var startIndex = 0
var lineCount = 1
while startIndex < totalLength {
let charCount = CTTypesetterSuggestClusterBreak(typesetter, startIndex, 100)
// 如果返回 0,意味著一個字符都放不下(或已結束)
if charCount == 0 {
if startIndex < totalLength {
print("Line \(lineCount): (Error) 無法放下剩余字符。")
}
break
}
// 獲取這一行的子字符串
let range = NSRange(location: startIndex, length: charCount)
let lineString = (attributedString.string as NSString).substring(with: range)
print("Line \(lineCount): '\(lineString)' (UTF-16 字符數: \(charCount))")
// 更新下一次循環的起始索引
startIndex += charCount
lineCount += 1
}
四、總結
以上是CoreText中常用的API及其場景代碼舉例,完整示例代碼可在公眾號「非專業程序員Ping」回復 “CoreText” 獲取。
posted on 2025-10-19 21:42 非專業程序員Ping 閱讀(31) 評論(0) 收藏 舉報
浙公網安備 33010602011771號