2022免责声明
虽然当我最初提交这个答案时,我在运行这段代码时得到了一些不错的结果,但另一个SO用户(Tim S.)警告我,在某些情况下将.null
字形属性应用于某些字形可能会导致应用程序挂起或崩溃。
据我所知,这只会发生在.null
属性上,并且大约在8192(2^13)字形处...我不知道为什么,而且老实说,它看起来像是一个TextKit bug(或者至少不是TextKit工程师预期的框架使用方式)。
对于现代应用程序,我建议您查看一下TextKit 2,该框架应该可以抽象出字形处理并简化所有这些东西(免责声明:我还没有尝试过它)。
前言
我实现了这种方法来实现我的应用程序中类似的功能。请记住,这个API的文档非常不好,因此我的解决方案基于试验和错误,而不是对所有移动部件的深入理解。
简而言之:它应该可以工作,但使用风险自负:)
还要注意,我在这个答案中详细讲述了很多细节,希望可以让任何Swift开发人员都可以看懂,即使没有Objective-C或C的背景。您可能已经知道以下某些内容。
关于TextKit和字形
重要的一点是要理解的是,一个字形是一个或多个字符的视觉表示,正如在WWDC 2018 Session 221“TextKit最佳实践”中所解释的那样:
![slide of session 221 explaining the difference between characters and glyphs](https://istack.dev59.com/WcRer.webp)
我建议您观看整个演讲。虽然在理解layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)
方法的特定情况下不是非常有用,但它提供了有关TextKit的一般信息。
理解shouldGenerateGlyphs
因此,从我所理解的来看,每次NSLayoutManager即将生成新的字形以在渲染之前,它都会给您一个机会通过调用layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)
方法来修改此字形。
修改字形
根据文档,如果您要修改字形,则应在此方法中通过调用setGlyphs(_:properties:characterIndexes:font:forGlyphRange:)
来实现。
幸运的是,setGlyphs
期望与我们在shouldGenerateGlyphs
中传递的完全相同的参数。这意味着理论上您可以使用对setGlyphs
的调用来实现shouldGenerateGlyphs
,一切都会很好(但这不会非常有用)。
返回值
文档还指出,shouldGenerateGlyphs
的返回值应为
func layoutManager(_ layoutManager: NSLayoutManager, shouldGenerateGlyphs glyphs: UnsafePointer<CGGlyph>, properties: UnsafePointer<NSLayoutManager.GlyphProperty>, characterIndexes: UnsafePointer<Int>, font: UIFont, forGlyphRange glyphRange: NSRange) -> Int {
layoutManager.setGlyphs(glyphs, properties: fixedPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
return glyphRange.length
}
这个方法等价于仅仅返回0
:
通过返回0,可以通知布局管理器执行默认处理。
做一些有用的事情
现在,我们如何编辑字形属性,使得该方法执行一些有用的操作(如隐藏字形)?
访问参数值
shouldGenerateGlyphs
的大多数参数都是UnsafePointer
类型,这是TextKit C API泄漏到Swift层的结果,也是实现此方法的麻烦之一。
关键点是这里所有类型为UnsafePointer
的参数都是数组(在C中,SomeType *
——或其Swift等效项UnsafePointer<SomeType>
——是表示数组的方式),这些数组的长度都为glyphRange.length
。这在setGlyphs
方法中间接地记录了:
每个数组都有glyphRange.length项
这意味着使用Apple提供的好用的UnsafePointer
API,我们可以使用以下循环遍历这些数组中的元素:
for i in 0 ..< glyphRange.length {
print(properties[i])
}
UnsafePointer
会根据传递给下标的任何索引来执行指针算术以访问正确地址上的内存。我建议阅读UnsafePointer
文档,这非常酷。
向setGlyphs
传递有用的内容
现在我们能够打印参数的内容并检查框架为每个字形提供的属性。那么,如何修改这些属性并将结果传递给setGlyphs
呢?
首先,需要注意的是,虽然我们可以直接修改properties
参数,但这可能是一个坏主意,因为该内存块不归我们所有,我们不知道一旦退出方法后框架会对该内存块做什么。
因此,正确的方法是创建自己的字形属性数组,然后将其传递给setGlyphs
:
var modifiedGlyphProperties = [NSLayoutManager.GlyphProperty]()
for i in 0 ..< glyphRange.length {
var glyphProperties = properties[i]
glyphProperties.insert(.null)
modifiedGlyphProperties.append(glyphProperties)
}
modifiedGlyphProperties.withUnsafeBufferPointer { modifiedGlyphPropertiesBufferPointer in
guard let modifiedGlyphPropertiesPointer = modifiedGlyphPropertiesBufferPointer.baseAddress else {
fatalError("Could not get base address of modifiedGlyphProperties")
}
layoutManager.setGlyphs(glyphs, properties: modifiedGlyphPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
}
return glyphRange.length
从 properties
数组中读取原始字形属性,并在此基础值上 添加 自定义属性(使用 .insert()
方法)。否则,您将覆盖字形的默认属性,导致奇怪的问题发生(例如我曾看到过换行符 \n
不再插入视觉换行符)。
决定隐藏哪些字形
先前的实现应该工作良好,但现在我们无条件地隐藏所有生成的字形,如果只有某些字形被隐藏会更加有用(例如当字形为 *
时)。
根据字符值来隐藏
为了实现这一点,您可能需要访问用于生成最终字形的字符。然而,框架并不提供字符本身,而是提供每个生成字形所在字符串中的索引值。您需要遍历这些索引并查找 NSTextStorage 中对应的字符。
不幸的是,这不是一个简单的任务:Foundation 使用 UTF-16 码元来内部表示字符串(NSString 和 NSAttributedString 在底层使用它)。因此,框架提供的 characterIndexes
并不是通常意义下的“字符”索引,而是 UTF-16 码元的索引†。
大多数情况下,每个 UTF-16 码元将被用于生成唯一的字形,但在某些情况下,多个码元将被用于生成唯一的字形(这被称为 UTF-16 代理对,在处理带有表情符号的字符串时很常见)。我建议您使用一些更“外国”的字符串来测试代码,例如:
textView.text = "Officiellement nous () vivons dans un cha\u{0302}teau 海"
因此,为了能够比较我们的字符,我们首先需要将它们转换为通常所指的“字符”的简单表示形式:
因此,为了能够比较我们的字符,我们首先需要将它们转换为通常所指的“字符”的简单表示形式:
private func characterFromUTF16CodeUnits(_ utf16CodeUnits: String.UTF16View, at index: Int) -> Character {
let codeUnitIndex = utf16CodeUnits.index(utf16CodeUnits.startIndex, offsetBy: index)
let codeUnit = utf16CodeUnits[codeUnitIndex]
if UTF16.isLeadSurrogate(codeUnit) {
let nextCodeUnit = utf16CodeUnits[utf16CodeUnits.index(after: codeUnitIndex)]
let codeUnits = [codeUnit, nextCodeUnit]
let str = String(utf16CodeUnits: codeUnits, count: 2)
return Character(str)
} else if UTF16.isTrailSurrogate(codeUnit) {
let previousCodeUnit = utf16CodeUnits[utf16CodeUnits.index(before: codeUnitIndex)]
let codeUnits = [previousCodeUnit, codeUnit]
let str = String(utf16CodeUnits: codeUnits, count: 2)
return Character(str)
} else {
let unicodeScalar = UnicodeScalar(codeUnit)!
return Character(unicodeScalar)
}
}
然后我们可以使用这个函数从我们的textStorage中提取字符,并对它们进行测试:
guard let textStorage = layoutManager.textStorage else {
fatalError("No textStorage was associated to this layoutManager")
}
let utf16CodeUnits = textStorage.string.utf16
var modifiedGlyphProperties = [NSLayoutManager.GlyphProperty]()
for i in 0 ..< glyphRange.length {
var glyphProperties = properties[i]
let character = characterFromUTF16CodeUnits(utf16CodeUnits, at: characterIndex)
if character == "*" {
glyphProperties.insert(.null)
}
modifiedGlyphProperties.append(glyphProperties)
}
modifiedGlyphProperties.withUnsafeBufferPointer { modifiedGlyphPropertiesBufferPointer in
guard let modifiedGlyphPropertiesPointer = modifiedGlyphPropertiesBufferPointer.baseAddress else {
fatalError("Could not get base address of modifiedGlyphProperties")
}
layoutManager.setGlyphs(glyphs, properties: modifiedGlyphPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
}
return glyphRange.length
注意,在代理对的情况下,循环将执行两次(一次在主代理上,一次在辅助代理上),你最终会比较相同的结果字符两次。虽然如此,由于需要对生成的字形的“部分”应用相同的修改,这是可以接受的。
根据TextStorage字符串属性隐藏
这并不是你在问题中要求的,但为了完整起见(并且因为这是我在我的应用程序中所做的),这里介绍一下如何访问textStorage字符串属性以隐藏某些字形(在此示例中,我将隐藏所有具有超文本链接的文本部分):
guard let textStorage = layoutManager.textStorage else {
fatalError("No textStorage was associated to this layoutManager")
}
let firstCharIndex = characterIndexes[0]
let lastCharIndex = characterIndexes[glyphRange.length - 1]
let charactersRange = NSRange(location: firstCharIndex, length: lastCharIndex - firstCharIndex + 1)
var hiddenRanges = [NSRange]()
textStorage.enumerateAttributes(in: charactersRange, options: []) { attributes, range, _ in
for attribute in attributes where attribute.key == .link {
hiddenRanges.append(range)
}
}
var modifiedGlyphProperties = [NSLayoutManager.GlyphProperty]()
for i in 0 ..< glyphRange.length {
let characterIndex = characterIndexes[i]
var glyphProperties = properties[i]
let matchingHiddenRanges = hiddenRanges.filter { NSLocationInRange(characterIndex, $0) }
if !matchingHiddenRanges.isEmpty {
glyphProperties.insert(.null)
}
modifiedGlyphProperties.append(glyphProperties)
}
modifiedGlyphProperties.withUnsafeBufferPointer { modifiedGlyphPropertiesBufferPointer in
guard let modifiedGlyphPropertiesPointer = modifiedGlyphPropertiesBufferPointer.baseAddress else {
fatalError("Could not get base address of modifiedGlyphProperties")
}
layoutManager.setGlyphs(glyphs, properties: modifiedGlyphPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
}
return glyphRange.length
† 为了理解它们之间的区别,我建议阅读Swift文档中关于“字符串和字符”的部分。请注意,这里框架所称的“character”与Swift中所称的Character
(或“扩展字形集群”)不同。在TextKit框架中,“character”是一个UTF-16代码单元(由Swift中的Unicode.UTF16.CodeUnit
表示)。
更新2020-04-16:使用.withUnsafeBufferPointer
将modifiedGlyphProperties
数组转换为UnsafePointer。这样就不需要在内存中保留数组的实例变量了。