前言
我实现了这个方法来在我的应用程序中实现类似的东西。请记住,这个 API 的文档记录很差,所以我的解决方案是基于反复试验,而不是深入了解这里的所有活动部分。
简而言之:它应该可以工作,但使用风险自负:)
另请注意,我在此答案中详细介绍了许多细节,希望任何 Swift 开发人员都可以使用它,即使是没有 Objective-C 或 C 背景的开发人员。您可能已经知道后面详述的一些事情。
关于 TextKit 和 Glyphs
重要的理解之一是字形是一个或多个字符的视觉表示,如 WWDC 2018 Session 221 "TextKit Best Practices" 中所述:

我建议观看整个演讲。在理解如何layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)
工作的特定情况下,它并不是很有帮助,但它提供了大量关于 TextKit 一般如何工作的信息。
理解shouldGenerateGlyphs
所以。据我了解,每次 NSLayoutManager 在渲染它们之前要生成一个新的字形时,它都会让你有机会通过调用来修改这个字形layoutManager(_:shouldGenerateGlyphs:properties:characterIndexes:font:forGlyphRange:)
。
修改字形
根据文档,如果你想修改字形,你应该在这个方法中调用setGlyphs(_:properties:characterIndexes:font:forGlyphRange:)
.
对我们来说幸运的是,setGlyphs
期望与传递给我们的参数完全相同shouldGenerateGlyphs
。这意味着理论上你可以shouldGenerateGlyphs
通过调用来实现setGlyphs
,一切都会好起来的(但这不会超级有用)。
返回值
该文档还说返回值shouldGenerateGlyphs
应该是“存储在此方法中的实际字形范围”。这没有多大意义,因为预期的返回类型Int
并不NSRange
像人们所期望的那样。通过反复试验,我认为框架希望我们在这里返回已修改字形的数量glyphRange
,从索引 0 开始(稍后会详细介绍)。
此外,“存储在此方法中的字形范围”指的是对 的调用setGlyphs
,它将在内部存储新生成的字形(imo 这措辞非常糟糕)。
一个不太有用的实现
所以这是一个正确的实现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
。这就是在 Swift 层中泄漏的 TextKit C API,也是首先使实现此方法变得麻烦的原因之一。
一个关键点是这里所有类型的参数UnsafePointer
都是数组(在 C 中SomeType *
——或者它的 Swift 等价物UnsafePointer<SomeType>
——是我们表示数组的方式),并且这些数组都是 lengthglyphRange.length
。这间接记录在setGlyphs
方法中:
每个数组都有 glyphRange.length 项
这意味着通过UnsafePointer
Apple 提供给我们的优秀 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 {
// This contains the default properties for the glyph at index i set by the framework.
var glyphProperties = properties[i]
// We add the property we want to the mix. GlyphProperty is an OptionSet, we can use `.insert()` to do that.
glyphProperties.insert(.null)
// Append this glyph properties to our properties array.
modifiedGlyphProperties.append(glyphProperties)
}
// Convert our Swift array to the UnsafePointer `setGlyphs` expects.
modifiedGlyphProperties.withUnsafeBufferPointer { modifiedGlyphPropertiesBufferPointer in
guard let modifiedGlyphPropertiesPointer = modifiedGlyphPropertiesBufferPointer.baseAddress else {
fatalError("Could not get base address of modifiedGlyphProperties")
}
// Call setGlyphs with the modified array.
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代码单元的索引†</sup>。
大多数时候,每个 UTF-16 代码单元将用于生成一个唯一的字形,但在某些情况下,多个代码单元将用于生成一个唯一的字形(这称为 UTF-16 代理对,常见于用表情符号处理字符串)。我建议使用一些更“异国情调”的字符串来测试您的代码,例如:
textView.text = "Officiellement nous () vivons dans un cha\u{0302}teau 海"
因此,为了能够比较我们的字符,我们首先需要将它们转换为我们通常所说的“字符”的简单表示:
/// Returns the extended grapheme cluster at `index` in an UTF16View, merging a UTF-16 surrogate pair if needed.
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 中提取字符,并测试它们:
// First, make sure we'll be able to access the NSTextStorage.
guard let textStorage = layoutManager.textStorage else {
fatalError("No textStorage was associated to this layoutManager")
}
// Access the characters.
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)
// Do something with `character`, e.g.:
if character == "*" {
glyphProperties.insert(.null)
}
modifiedGlyphProperties.append(glyphProperties)
}
// Convert our Swift array to the UnsafePointer `setGlyphs` expects.
modifiedGlyphProperties.withUnsafeBufferPointer { modifiedGlyphPropertiesBufferPointer in
guard let modifiedGlyphPropertiesPointer = modifiedGlyphPropertiesBufferPointer.baseAddress else {
fatalError("Could not get base address of modifiedGlyphProperties")
}
// Call setGlyphs with the modified array.
layoutManager.setGlyphs(glyphs, properties: modifiedGlyphPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
}
return glyphRange.length
请注意,在代理对的情况下,循环将执行两次(一次在前导代理上,一次在跟踪代理上),您最终将比较相同的结果字符两次。这很好,因为您需要在生成的字形的两个“部分”上应用您想要的相同修改。
基于 TextStorage 字符串属性的隐藏
这不是您在问题中所要求的,而是为了完成(并且因为这是我在我的应用程序中所做的),在这里您可以如何访问您的 textStorage 字符串属性以隐藏一些字形(在此示例中,我将隐藏所有带有超文本链接的文本部分):
// First, make sure we'll be able to access the NSTextStorage.
guard let textStorage = layoutManager.textStorage else {
fatalError("No textStorage was associated to this layoutManager")
}
// Get the first and last characters indexes for this glyph range,
// and from that create the characters indexes range.
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)
}
// Convert our Swift array to the UnsafePointer `setGlyphs` expects.
modifiedGlyphProperties.withUnsafeBufferPointer { modifiedGlyphPropertiesBufferPointer in
guard let modifiedGlyphPropertiesPointer = modifiedGlyphPropertiesBufferPointer.baseAddress else {
fatalError("Could not get base address of modifiedGlyphProperties")
}
// Call setGlyphs with the modified array.
layoutManager.setGlyphs(glyphs, properties: modifiedGlyphPropertiesPointer, characterIndexes: characterIndexes, font: font, forGlyphRange: glyphRange)
}
return glyphRange.length
†</sup> 要了解它们之间的区别,我建议阅读有关 "Strings and Characters" 的 Swift 文档。另请注意,框架在这里所说的“字符”与Swift 所说的 a Character
(或“扩展字素簇”)不同。同样,TextKit 框架的“字符”是一个 UTF-16 代码单元(在 Swift 中由 表示Unicode.UTF16.CodeUnit
)。
2020-04-16 更新:利用.withUnsafeBufferPointer
将modifiedGlyphProperties
数组转换为 UnsafePointer。它消除了拥有数组的实例变量以使其在内存中保持活动状态的需要。