导读:本期聚焦于俊华创作的《如何用Core Text实现iOS端文本编辑器光标定位与选中高亮?》,敬请观看详情。自绘文本编辑器里,光标定位不准和选中高亮错位通常源于对Core Text字形布局的理解停留在字符层面。Core Text的排版单元是字形而不是字符,连字、emoji、中文标点压缩、换行符都会让字符索引与屏幕坐标出现偏差。实现可靠的光标定位,需要先通过CTFrameGetLines拿到行和行起点,再调用CTLineGetOffsetForStringIndex把字符索引转换为行内横向偏移,结合行高和descent还原出插入点矩形。触摸命中测试则要先把UIKit坐标翻转到Core Text坐标系,逐行比较点击点是否落在行的排版区域内,然后用CTLineGetStringIndexForPosition反查字符索引,并对UTF-16代理对边界做夹取,避免光标卡在emoji中间。选中高亮不能简单给每个字符画小矩形,应当按行计算选区与行范围的重叠区间,用起始和结束索引的x偏移生成连续矩形,再在绘制文本前填充。这套方案可以稳定支撑自绘编辑器的光标、点击和拖动选择交互。

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

如何用Core Text实现iOS端文本编辑器光标定位与选中高亮?

一、先理解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依然是最直接的选择。掌握字形范围计算和触摸命中测试这两套映射,是自绘编辑器最核心的能力。

Core Text字形范围触摸命中测试修改时间:2026-10-07 03:39:26

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/1007/66714.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。