在iOS上自绘文本编辑器,光标定位和选区高亮是核心体验的一部分。UITextView虽然功能完整,但在排版控制、性能调优和复杂富文本场景下常常不够灵活,因此不少编辑器方案会选择Core Text直接绘制。Core Text用字符索引映射到屏幕坐标的规则与直觉不同,尤其在中文和emoji混排时容易出现光标跳动、高亮区域错位。要解决这些问题,需要把字符索引、字形范围、行坐标和触摸点统一到一套几何模型里。

一、先理解Core Text的排版层级与坐标翻转
Core Text的绘制流程围绕几张核心结构展开:CTFramesetter负责根据富文本生成排版帧,CTFrame表示一个完整排布区域,内部由多行CTLine组成,每行又包含若干CTRun,而字形实际对应到CTRun内部的CGGlyph。要计算光标位置,不必深入字形级别,大多数时候只需在CTLine层面完成索引到偏移的转换。理解这个层级关系,后续的矩形计算会清晰很多。
需要特别注意的是Core Text使用的是左下角为原点的坐标系,而UIView的默认坐标系原点在左上角。如果在draw中先不对上下文做翻转,直接调用CTFrameDraw会把文本倒置绘制。通常第一步先设置context.textMatrix为单位矩阵,然后translateBy到视图底部,再scaleBy把y轴反向,这样Core Text的坐标就和日常视觉位置一致了。
下面的代码展示一个自定义UIView子类的基础绘制方法,它创建CTFrame并保存为属性,避免每次绘制时重复排版。
private func buildFrame() -> CTFrame {
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
let path = CGPath(rect: bounds, transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, nil)
return frame
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.textMatrix = .identity
context.translateBy(x: 0, y: bounds.height)
context.scaleBy(x: 1.0, y: -1.0)
let frame = buildFrame()
CTFrameDraw(frame, context)
}
这段代码中,frame被重复创建是因为示例保持简洁。真实编辑器里应该缓存CTFrame,在文本内容或视图尺寸变化时才重新构建,因为创建frame和拆分行会消耗明显时间。
二、字形范围计算:从字符索引到光标矩形
光标本质上是一个宽度很窄的矩形,它的高度应该与所在行的排版高度一致,x坐标由字符索引映射得到。Core Text提供了CTLineGetOffsetForStringIndex函数,它返回行内某个字符索引相对于行起点的横向偏移。结合CTFrameGetLineOrigins得到的行起点,就能得到该索引在视图内的光标起始x。垂直方向需要用到行的ascent、descent和leading,其中descent决定基线以下部分,光标顶部通常在行顶,底部在行底。
遍历行时,先通过CTLineGetStringRange拿到每行覆盖的字符范围。如果目标索引落在当前行的闭区间内,就调用偏移函数。这里要小心索引恰好等于行末位置的情况,例如行末是换行符,偏移函数可能返回该行末字符后的位置,这正是行尾插入点。safeIndex用于避免越界。若目标索引超出当前frame覆盖范围,可以回退到最后一行末尾。
private func caretRect(for index: Int, in frame: CTFrame) -> CGRect {
let lines = CTFrameGetLines(frame) as NSArray
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
for (lineIndex, lineObject) in lines.enumerated() {
let line = lineObject as! CTLine
let lineRange = CTLineGetStringRange(line)
let maxLocation = lineRange.location + lineRange.length
if index >= lineRange.location && index <= maxLocation {
let safeIndex = min(index, maxLocation)
let offset = CTLineGetOffsetForStringIndex(line, safeIndex, nil)
let origin = origins[lineIndex]
var ascent: CGFloat = 0
var descent: CGFloat = 0
var leading: CGFloat = 0
let lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
_ = lineWidth
return CGRect(x: origin.x + offset,
y: origin.y - descent,
width: 1.5,
height: ascent + descent + leading)
}
}
return CGRect(x: bounds.width - 1.5, y: 0, width: 1.5, height: 16)
}
这里返回的高度包含了leading,也就是行间距。有些编辑器希望光标高度只覆盖ascent加descent,不含行距,这样光标不会贯穿行与行之间的空白。这个细节可以根据交互设计调整。宽度通常取1.5到2个像素,避免在Retina屏幕上模糊。
对中文和英文混排来说,这个函数依然有效,因为CTLine内部的偏移已经正确处理了字符宽度差异。不要自己用NSString的size方法按字符累加,中文标点压缩、字偶距和比例宽度都会让累加结果偏离真实排版。
三、触摸命中测试:从触摸点到字符索引
触摸命中测试是光标定位的反向过程:给定一个屏幕坐标,找到它对应的字符索引。流程同样是先拿到行数组和行起点,把触摸点的y从UIKit坐标翻转为Core Text坐标。因为绘制时已经把上下文翻转,触摸坐标需要做同样的处理,即用视图高度减去point.y。
遍历每一行时,先根据行的排版高度构造一个命中区域。这个区域一般从origin.y减去descent开始,到origin.y加上ascent结束,横向范围可以宽松一些。如果触摸点落在这个区域内,就调用CTLineGetStringIndexForPosition,并传入触摸点相对于行起点的x偏移。该函数会返回行内最近的字符索引,不需要自己去做二分查找。
private func characterIndex(at point: CGPoint, in frame: CTFrame) -> Int {
let lines = CTFrameGetLines(frame) as NSArray
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
let coreTextPoint = CGPoint(x: point.x, y: bounds.height - point.y)
for (lineIndex, lineObject) in lines.enumerated() {
let line = lineObject as! CTLine
var ascent: CGFloat = 0
var descent: CGFloat = 0
var leading: CGFloat = 0
let lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
let origin = origins[lineIndex]
let lineRect = CGRect(x: origin.x,
y: origin.y - descent,
width: CGFloat(lineWidth),
height: ascent + descent)
if lineRect.contains(coreTextPoint) {
let relativePoint = CGPoint(x: coreTextPoint.x - origin.x,
y: coreTextPoint.y - origin.y)
let stringIndex = CTLineGetStringIndexForPosition(line, relativePoint)
let lineRange = CTLineGetStringRange(line)
let maxIndex = lineRange.location + lineRange.length - 1
return min(max(stringIndex, lineRange.location), maxIndex)
}
}
return attributedString.length
}
命中区域没有包含leading,是因为行与行之间的空白通常不响应点击,这样用户点到两行中间时,系统会返回空区域,进而由外层逻辑决定落到上一行还是下一行。实际使用中可以把leading的一半归入上一行,另一半归入下一行,改善拇指操作。
当文本较长需要滚动时,point要先加上contentOffset,或者把frame的绘制起点向下偏移。不要简单用bounds计算,因为滚动后可见区域对应的文本坐标已经改变。建议维护一个文本内容视图,手势坐标转换到该视图后再做命中测试。
四、选中高亮的矩形合并与绘制
实现选中高亮不能只对每个选中字符画一个矩形。那样会出现大量相邻矩形,在Retina屏幕上可能产生半像素缝隙,而且无法自然覆盖行尾空白。正确做法是先按行计算选区范围与行范围的重叠区间,再根据重叠区间的起始和结束索引调用偏移函数,得到连续的横向矩形。
对于行尾字符,特别是换行符或最后一个字符,如果直接取结束索引的x偏移,得到的可能只是一个字符宽度,视觉上高亮没有延伸到行右边缘。为了更好的观感,当重叠区间触及行末时,可以把结束x设置成整行排版宽度,这样高亮覆盖到行尾。这个处理尤其适合移动端夜间模式或深色背景。
private func selectionRects(for range: NSRange, in frame: CTFrame) -> [CGRect] {
let lines = CTFrameGetLines(frame) as NSArray
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
var rects: [CGRect] = []
for (lineIndex, lineObject) in lines.enumerated() {
let line = lineObject as! CTLine
let lineRange = CTLineGetStringRange(line)
let lineStart = lineRange.location
let lineEnd = lineStart + lineRange.length - 1
let selectionStart = max(lineStart, range.location)
let selectionEnd = min(lineEnd, NSMaxRange(range) - 1)
if selectionStart <= selectionEnd {
let startOffset = CTLineGetOffsetForStringIndex(line, selectionStart, nil)
var endOffset = CTLineGetOffsetForStringIndex(line, selectionEnd, nil)
var ascent: CGFloat = 0
var descent: CGFloat = 0
var leading: CGFloat = 0
let lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &leading)
if selectionEnd == lineEnd {
endOffset = CGFloat(lineWidth)
}
let origin = origins[lineIndex]
let y = origin.y - descent
let height = ascent + descent
rects.append(CGRect(x: origin.x + CGFloat(startOffset),
y: y,
width: max(0, CGFloat(endOffset) - CGFloat(startOffset)),
height: height))
}
}
return rects
}
变量命名里出现了endOffset和startOffset,两者都是CGFloat。CTLineGetOffsetForStringIndex返回Double,在Swift中可以直接桥接为CGFloat,但为统一类型这里显式转换。实际写代码时可以直接声明为CGFloat,让编译器自动处理。
绘制顺序很关键:高亮矩形应该在文本之前填充,否则会把文字盖住。也可以使用混合模式,例如设置normal加alpha通道,保持文字清晰。为了让选区边缘不突兀,个别编辑器会在此基础上增加2像素的横向内边距,但会影响光标对齐,一般不推荐。
五、emoji边界与代理对校正
Core Text的字符索引基于UTF-16码元,与NSString的length一致。英文字母和大部分中文占一个码元,但emoji如笑脸通常由一对代理码元表示。触摸命中测试返回的索引可能落在代理对中间,如果此时设置选区或光标,会出现半个字符被高亮、光标无法继续移动的问题。需要在触摸结果返回后做边界校正。
校正方法是检查索引前后两个码元是否构成一对代理。如果前一个是高代理而后一个是低代理,说明索引落在合成字符中间,应该把索引向前调整一位。反之,如果前一个是低代理而后一个是高代理,说明这对代理已经分开,当前索引是安全的。这个逻辑在光标移动、删除和选区扩展时都要调用,否则emoji输入框中会频繁出现异常。
private func adjustToCharacterBoundary(_ index: Int, in string: String) -> Int {
let utf16 = string.utf16
let length = utf16.count
guard index > 0 && index < length else {
return min(max(index, 0), length)
}
let previousIndex = utf16.index(utf16.startIndex, offsetBy: index - 1)
let currentIndex = utf16.index(utf16.startIndex, offsetBy: index)
let previous = utf16[previousIndex]
let current = utf16[currentIndex]
if UTF16.isLeadSurrogate(previous) && UTF16.isTrailSurrogate(current) {
return index - 1
}
return index
}
这个函数只处理了最常见的一对代理情况。某些复杂组合字符可能由基础字符加变音符号组成,它们虽然显示为一个视觉单位,但UTF-16索引并不要求合并,只有在用到CTLine偏移时,字体排版负责把组合字符的宽度合并到同一个cluster。因此对普通组合字符不必强行合并索引,重点放在代理对即可。
另外,连字也值得留意。某些字体把fi渲染成单个字形,但Core Text的偏移计算仍然按照字符索引给出正确位置。这个特性确保我们的光标算法不会因为字体连字而失效,所以不需要在字形级别做额外处理。
六、交互闭环与性能建议
将前述三个核心函数组合起来,就能形成一个可用的自绘编辑器。touchesBegan时把触摸点转换为字符索引并作为选区锚点,touchesMoved时动态计算当前索引,与锚点形成范围,setNeedsDisplay触发重绘。draw方法中先填充selectionRects返回的矩形,再绘制CTFrame,最后根据光标显示状态填充光标矩形。为了让触摸移动跟手,UITouch的坐标采样已经足够,不需要额外的手势识别器。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let point = touch.location(in: self)
let rawIndex = characterIndex(at: point, in: buildFrame())
let index = adjustToCharacterBoundary(rawIndex, in: attributedString.string)
selectionAnchorIndex = index
selectionRange = NSRange(location: index, length: 0)
setNeedsDisplay()
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let point = touch.location(in: self)
let rawIndex = characterIndex(at: point, in: buildFrame())
let index = adjustToCharacterBoundary(rawIndex, in: attributedString.string)
let start = min(selectionAnchorIndex, index)
let end = max(selectionAnchorIndex, index)
selectionRange = NSRange(location: start, length: end - start)
setNeedsDisplay()
}
上面的代码中,buildFrame每次都在触摸事件中调用,适合文本量较小的场景。如果编辑长文档,需要把CTFrame缓存到属性中,文本变化时更新,视图尺寸变化时重建。更好的做法是只在draw中生成frame,在触摸函数中复用同一个frame缓存,这样能保持索引和绘制结果一致。
光标闪烁可以通过一个Timer或CADisplayLink控制showCaret布尔值,每0.5秒翻转一次并调用setNeedsDisplay,但要避免在后台持续刷新。滚动场景下,触摸命中和光标矩形都需要加上contentOffset的偏移,或者把所有坐标转换到一个统一的文本坐标系里再计算,能减少很多边界处理。
最后提醒一点,Core Text在iOS 13及之后仍然稳定可用,虽然TextKit 2提供了更丰富的交互能力,但在需要精细控制排版参数时,Core Text依然是最直接的选择。掌握字形范围计算和触摸命中测试这两套映射,是自绘编辑器最核心的能力。