自定义NSTextContentManager时,recordEditAction的文本范围如何设置?
问题背景
我自定义了NSTextContentManager子类(不基于NSTextStorage/NSTextContentStorage,因为它们依赖NSAttributedString,我需要不同的存储方式)。编辑完成后尝试通过func recordEditAction(in originalTextRange: NSTextRange, newTextRange: NSTextRange)通知NSTextLayoutManager,但不清楚该传入什么样的范围。
例如现有文本“This”,将“s”替换为空字符串时,传入等效的(3,1)作为originalTextRange、(3,0)作为newTextRange,会导致NSTextLayoutManager异常(仍尝试布局(3,1)范围的文本)。
为逆向分析NSTextContentStorage的实现逻辑,我在Playground中编写了以下测试代码:
class CustomTextStorage: NSTextContentStorage { override func recordEditAction(in originalTextRange: NSTextRange, newTextRange: NSTextRange) { print("recordEditAction in \(originalTextRange) newTextRange \(newTextRange)") super.recordEditAction(in: originalTextRange, newTextRange: newTextRange) } override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions, range newCharRange: NSRange, changeInLength delta: Int, invalidatedRange invalidatedCharRange: NSRange) { print("processEditing for \(textStorage) edited: \(editMask) range: \(newCharRange) changeInLength: \(delta) invalidatedRange: \(invalidatedCharRange)") super.processEditing(for: textStorage, edited: editMask, range: newCharRange, changeInLength: delta, invalidatedRange: invalidatedCharRange) } } let layoutManager = CustomTextLayoutManager() let container = NSTextContainer(size: NSSize(width: 200, height: 200)) layoutManager.textContainer = container let storage = CustomTextStorage() storage.addTextLayoutManager(layoutManager) storage.textStorage?.replaceCharacters(in: NSRange(location: 0, length: 0), with: "T") storage.textStorage?.replaceCharacters(in: NSRange(location: 1, length: 0), with: "h") storage.textStorage?.replaceCharacters(in: NSRange(location: 2, length: 0), with: "i") storage.textStorage?.replaceCharacters(in: NSRange(location: 3, length: 0), with: "s") storage.textStorage?.replaceCharacters(in: NSRange(location: 3, length: 1), with: "")
输出结果如下:
processEditing for T{ } edited: [.editedCharacters] range: {0, 1} changeInLength: 1 invalidatedRange: {0, 1} recordEditAction in 0...0 newTextRange 0...1 processEditing for Th{ } edited: [.editedCharacters] range: {1, 1} changeInLength: 1 invalidatedRange: {1, 1} recordEditAction in 0...0 newTextRange 0...1 processEditing for Thi{ } edited: [.editedCharacters] range: {2, 1} changeInLength: 1 invalidatedRange: {2, 1} recordEditAction in 0...0 newTextRange 0...1 processEditing for This{ } edited: [.editedCharacters] range: {3, 1} changeInLength: 1 invalidatedRange: {3, 1} recordEditAction in 0...0 newTextRange 0...1 processEditing for Thi{ } edited: [.editedCharacters] range: {3, 0} changeInLength: -1 invalidatedRange: {3, 0} recordEditAction in 0...0 newTextRange 0...9223372036854775807
我疑惑:传入recordEditAction的范围与参数名指示不符,总是0...0和0...1;移除字符时newTextRange是类似Int.max的值。这些值的含义是什么?我应该传入什么?另外,processEditing属于NSTextStorageObserving而非NSTextContentManager的方法,它能获取正确的范围和编辑操作,推测它和recordEditAction配合让布局管理器知晓编辑情况。那在不使用NSTextStorage的自定义NSTextContentManager中,该如何实现类似功能?
关键疑问解析
特殊范围的含义
0...0和0...1:这是NSTextContentStorage内部的适配逻辑导致的——它把NSTextStorage的字符编辑,统一映射为针对内容模型的增量锚点范围。0...0代表编辑发生前的空锚点范围,0...1代表编辑后新增的内容范围,本质是告诉布局管理器:内容在锚点位置发生了增量更新。0...9223372036854775807:这个值是Int.max,对应NSTextRange的内容末尾位置。当删除内容时,这个范围表示:原范围的内容已被移除,布局管理器需要重新计算从编辑位置到内容末尾的所有布局。
正确的传参逻辑
recordEditAction的参数不是基于原文本的字符范围,而是基于你自定义内容模型的位置偏移体系,需遵循以下规则:
- 插入操作:
originalTextRange:插入位置的空范围(比如在位置3插入,就是3...3对应的NSTextRange)newTextRange:插入后新增内容的范围(比如插入1个字符,就是3...4对应的NSTextRange)
- 删除操作:
originalTextRange:被删除内容的完整范围(比如删除位置3的1个字符,就是3...4对应的NSTextRange)newTextRange:删除后的空范围(即3...3对应的NSTextRange)
- 替换操作:
originalTextRange:被替换内容的范围newTextRange:替换后新增内容的范围
注意:这些范围必须和你在NSTextContentManager子类中实现的textRange(from:)、location(from:)等范围转换方法逻辑完全一致,确保布局管理器能正确解析。
无NSTextStorage时的实现方案
因为不依赖NSTextStorage,需要手动模拟processEditing的核心逻辑,配合recordEditAction通知布局管理器:
- 追踪编辑元数据:在自定义内容模型完成编辑后,记录三个关键信息:
- 编辑发生的起始位置(基于你的内容模型偏移量)
- 被删除的内容长度
- 新增的内容长度
- 构造合规的NSTextRange:
- 基于编辑前的位置和删除长度,构造
originalTextRange - 基于编辑后的位置和新增长度,构造
newTextRange
- 基于编辑前的位置和删除长度,构造
- 调用recordEditAction:将构造好的两个范围传入,触发布局管理器的更新逻辑
- 强制刷新布局:如果布局管理器未自动刷新,调用
layoutManager.invalidateLayout(for:)传入受影响的范围,确保布局重新计算
内容的提问来源于stack exchange,提问作者georgemp

