You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS开发:如何识别用户点击UITextView/TextEditor的行号?

如何判断UITextView/SwiftUI TextEditor中用户点击的行号(支持富文本)

我来帮你解决这个问题——不管是UIKit里的UITextView还是SwiftUI的TextEditor,都有办法准确获取用户点击的行号,而且完全适配你用Attributed Strings设置不同样式的场景,下面分两种情况详细说明:


一、UITextView 实现方案

1. 核心思路:借助UITextViewDelegate和LayoutManager

当用户点击文本时,textViewDidChangeSelection代理方法会触发,我们可以在这里获取点击位置的字符偏移量,然后通过UITextView的layoutManager(负责文本布局的核心类)计算出对应的行号——这个方法会自动考虑富文本带来的行高、换行差异,比单纯按\n分割文本准确得多。

直接上代码示例:

class YourViewController: UIViewController, UITextViewDelegate {
    @IBOutlet weak var textView: UITextView!
    // 存储每行文本的数组(可选,根据你的需求维护)
    private var textLines: [String] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        textView.delegate = self
        // 初始化富文本示例
        setupAttributedText()
        // 同步初始化行数组
        updateTextLines()
    }
    
    private func setupAttributedText() {
        let attrText = NSMutableAttributedString(string: "第一行\n第二行(加粗样式)\n第三行(斜体小字号)")
        // 给第二行加粗
        attrText.addAttributes([.font: UIFont.boldSystemFont(ofSize: 17)], 
                               range: NSRange(location: 4, length: 7))
        // 给第三行设置斜体小字号
        attrText.addAttributes([.font: UIFont.italicSystemFont(ofSize: 13)], 
                               range: NSRange(location: 12, length: 8))
        textView.attributedText = attrText
    }
    
    // 代理方法:用户点击/选中文本时触发
    func textViewDidChangeSelection(_ textView: UITextView) {
        // 确保选中的是单个位置(用户点击时的情况)
        guard let selectedRange = textView.selectedTextRange,
              let startPos = selectedRange.start as? UITextPosition else {
            return
        }
        
        // 获取点击位置对应的字符偏移量
        let charOffset = textView.offset(from: textView.beginningOfDocument, to: startPos)
        let layoutManager = textView.layoutManager
        let textContainer = textView.textContainer
        
        // 计算点击位置所在的行号(注意:这里返回的行号从0开始)
        let glyphIndex = layoutManager.glyphIndexForCharacter(at: charOffset)
        let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil)
        let lineNumber = layoutManager.numberOfLines(for: textContainer, upTo: lineRect.origin.y)
        
        // 获取该行的文本内容
        let lineCharRange = layoutManager.characterRange(forGlyphRange: layoutManager.glyphRange(for: lineRect), 
                                                         actualGlyphRange: nil)
        let lineText = (textView.attributedText.string as NSString).substring(with: lineCharRange)
        
        // 输出结果(或者执行你需要的操作,比如打印该行)
        print("用户点击了第 \(lineNumber + 1) 行,内容:\(lineText)")
        
        // 如果用数组存储,直接通过行号取对应元素
        if lineNumber < textLines.count {
            let clickedLine = textLines[lineNumber]
            print("数组中对应的行内容:\(clickedLine)")
        }
    }
    
    // 同步更新行数组(文本变化时调用)
    func textViewDidChange(_ textView: UITextView) {
        updateTextLines()
    }
    
    private func updateTextLines() {
        textLines.removeAll()
        let fullText = textView.attributedText.string as NSString
        let layoutManager = textView.layoutManager
        let textContainer = textView.textContainer
        
        var currentOffset = 0
        let totalChars = fullText.length
        
        while currentOffset < totalChars {
            let glyphIndex = layoutManager.glyphIndexForCharacter(at: currentOffset)
            let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil)
            let lineCharRange = layoutManager.characterRange(forGlyphRange: layoutManager.glyphRange(for: lineRect), 
                                                             actualGlyphRange: nil)
            
            let lineText = fullText.substring(with: lineCharRange).trimmingCharacters(in: .newlines)
            textLines.append(lineText)
            
            currentOffset = lineCharRange.location + lineCharRange.length
        }
    }
}

2. 和你的数组存储方案适配

上面的代码已经包含了数组同步逻辑——每当文本变化时,textViewDidChange会调用updateTextLines,通过layoutManager把当前文本分割成真实的行(考虑富文本布局),更新你的数组。这样点击行号时,直接用lineNumber作为数组索引就能拿到对应内容,完全解决你的核心问题。


二、SwiftUI TextEditor 实现方案

SwiftUI的TextEditor是UITextView的封装,所以我们需要通过UIViewRepresentable桥接到底层的UITextView,复用上面的逻辑。

代码示例:

// 自定义可检测行点击的TextEditor
struct LineDetectableTextEditor: UIViewRepresentable {
    @Binding var attributedText: AttributedString
    // 行点击回调:传递行号(从1开始)和该行内容
    var onLineTap: (Int, String) -> Void
    
    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator
        textView.isScrollEnabled = true
        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.attributedText = NSAttributedString(attributedText)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    // 代理协调器
    class Coordinator: NSObject, UITextViewDelegate {
        let parent: LineDetectableTextEditor
        
        init(parent: LineDetectableTextEditor) {
            self.parent = parent
        }
        
        func textViewDidChangeSelection(_ textView: UITextView) {
            // 复用UITextView的行号计算逻辑
            guard let selectedRange = textView.selectedTextRange,
                  let startPos = selectedRange.start as? UITextPosition else {
                return
            }
            
            let charOffset = textView.offset(from: textView.beginningOfDocument, to: startPos)
            let layoutManager = textView.layoutManager
            let textContainer = textView.textContainer
            
            let glyphIndex = layoutManager.glyphIndexForCharacter(at: charOffset)
            let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil)
            let lineNumber = layoutManager.numberOfLines(for: textContainer, upTo: lineRect.origin.y)
            
            let lineCharRange = layoutManager.characterRange(forGlyphRange: layoutManager.glyphRange(for: lineRect), 
                                                             actualGlyphRange: nil)
            let lineText = (textView.attributedText.string as NSString).substring(with: lineCharRange)
            
            // 回调给SwiftUI层
            parent.onLineTap(lineNumber + 1, lineText)
        }
        
        // 同步文本变化到SwiftUI的绑定值
        func textViewDidChange(_ textView: UITextView) {
            parent.attributedText = AttributedString(textView.attributedText)
        }
    }
}

// 使用示例
struct ContentView: View {
    @State private var editorText: AttributedString = {
        var attrString = AttributedString("SwiftUI第一行\nSwiftUI第二行(加粗)\nSwiftUI第三行(斜体)")
        attrString[attrString.range(of: "SwiftUI第二行(加粗)")!].font = .boldSystemFont(ofSize: 17)
        attrString[attrString.range(of: "SwiftUI第三行(斜体)")!].font = .italicSystemFont(ofSize: 14)
        return attrString
    }()
    
    var body: some View {
        VStack {
            Text("点击文本任意行查看行号")
                .padding()
            
            LineDetectableTextEditor(attributedText: $editorText) { lineNum, lineContent in
                print("SwiftUI中点击了第 \(lineNum) 行,内容:\(lineContent)")
            }
            .padding()
            .border(Color.gray)
        }
        .padding()
    }
}

关键注意事项

  • 富文本兼容性:用layoutManager计算行号的核心优势是,它会考虑富文本的行高、行间距、字体大小等因素,不会因为样式不同导致行号计算错误。
  • 行号起始值:layoutManager.numberOfLines返回的是从0开始的索引,如果你习惯从1开始计数,记得加1。
  • 数组同步:一定要在文本变化时更新你的行数组,避免数组内容和实际文本不一致,上面的代码已经处理了这个逻辑。

内容的提问来源于stack exchange,提问作者Sami Almalki

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.11 07:48:12