如何在NSTextView中提取行号与光标字符位置?
实现文本编辑器的光标行号与字符偏移量展示功能
开发带有文本编辑器的应用时,向用户展示光标所在的行号和字符偏移量会很实用。以下是实现该功能的示例方法:
/// 获取光标所在的行号与列偏移 /// 查找indexStarts中第一个大于光标位置的行起始索引,计算光标与上一行起始位置的距离 func setColumnAndRow() { // 将NSRange转换为Range<String.Index> let selectionRange = Range(selectedRange, in: string)! // 找到第一个大于光标位置的行起始索引 if let nextIndex = indexStarts.firstIndex( where: {$0 > selectionRange.lowerBound} ) { // 光标所在行是该索引的前一行 let lineIndex = nextIndex - 1 // 使用<String.Index>.distance计算列偏移 let distance = string.distance(from: indexStarts[lineIndex] , to: selectionRange.lowerBound ) print("column: \(distance), row: \(lineIndex)") } else { print("column: 0, row: \(indexStarts.count-1)") } }
经调研,Apple并未提供此功能的官方API,甚至Xcode编辑器本身也没有该功能。因此我们需要构建一个存储每行起始字符位置的数组(如上述代码中的indexStarts),且该数组需在NSTextField内容发生任何变化时更新,所以生成该数组的方法必须高效快速。
我整理了四种生成行起始位置数组的方法:
方法一
使用glyphs数量与lineFragmentRect——目前最慢的实现
func lineStartsWithLayout() -> [Int] { // 比后续方法慢约100倍 let start = ProcessInfo.processInfo.systemUptime var lineStarts:[Int] = [] let layoutManager = layoutManager! let numberOfGlyphs = layoutManager.numberOfGlyphs var lineRange: NSRange = NSRange() var indexOfGlyph: Int = 0 lineStarts.append(indexOfGlyph) while indexOfGlyph < numberOfGlyphs { layoutManager.lineFragmentRect( forGlyphAt: indexOfGlyph , effectiveRange: &lineRange , withoutAdditionalLayout: false ) indexOfGlyph = NSMaxRange(lineRange) lineStarts.append(indexOfGlyph) } lineStarts.append(Int.max) Logger.write("\(ProcessInfo.processInfo.systemUptime-start) s") return lineStarts }
方法二
使用paragraphs数组获取每行长度——Apple可能不推荐此方法(可能生成大量对象),但此处仅读取数组未做修改,实际效率接近最快实现,适合Objective-C开发者。
func lineStartsWithParagraphs() -> [Int] { // 比方法一快约100倍 let start = ProcessInfo.processInfo.systemUptime; var lineStarts:[Int] = [] var lineStart = 0 lineStarts = [] lineStarts.append(lineStart) for p in textStorage?.paragraphs ?? [] { lineStart += p.length lineStarts.append(lineStart) } lineStarts.append(Int.max) Logger.write("\(ProcessInfo.processInfo.systemUptime-start) s") return lineStarts }
方法三
使用enumerateLines——原本预期速度很快,但实际比方法二慢近一倍,不过代码风格更符合Swift特性。
func lineStartsByEnumerating() -> [Int] { let start = ProcessInfo.processInfo.systemUptime; var lineStarts:[Int] = [] var lineStart = 0 lineStarts = [] lineStarts.append(lineStart) string.enumerateLines { line, stop in lineStart += line.count lineStarts.append(lineStart) } lineStarts.append(Int.max) Logger.write("\(ProcessInfo.processInfo.systemUptime-start) s") return lineStarts }
方法四
使用Swift的lineRange——最快的实现,也是Swift的最佳方案,无法在Objective-C中使用。使用时需注意:若NSTextView.selectedRange返回NSRange,需转换为Range<String.Index>。
func indexStartsByLineRange() -> [String.Index] { /* // 将Range<String.Index>转换为NSRange: let range = s[s.startIndex..<s.endIndex] let nsRange = NSRange(range, in: s) // 将NSRange转换为Range<String.Index>: let nsRange = NSMakeRange(0, 4) let range = Range(nsRange, in: s) */ let start = ProcessInfo.processInfo.systemUptime; var indexStarts:[String.Index] = [] var index = string.startIndex indexStarts.append(index) while index != string.endIndex { let range = string.lineRange(for: index..<index) index = range.upperBound indexStarts.append(index) } Logger.write("\(ProcessInfo.processInfo.systemUptime-start) s") return indexStarts }
基准测试
| 方法 | 搭载Ventura系统的M2芯片设备上32000行NSTextView耗时 |
|---|---|
| 1. lineStartsWithLayout | 1.452 s |
| 2. lineStartsWithParagraphs | 0.020 s |
| 3. lineStartsByEnumerating | 0.065 s |
| 4. indexStartsByLineRange | 0.019 s |
我更倾向于使用indexStartsByLineRange方法,也欢迎其他意见。在Objective-C中,建议采用lineStartsWithParagraphs的算法,只需调整部分调用即可。
内容的提问来源于stack exchange,提问作者Lego Esprit
相关产品推荐
相关产品推荐

