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

自定义带行号CodeEditor切换文件时数据异常问题求助

问题描述
  • 基于SwiftUI/UIKit实现的带行号自定义编辑器,使用绑定关联Data数组中的文件内容时,切换选中文件(变量i变化)后,修改内容会错误覆盖原有数据,但原生TextEditor无此问题。
  • 绑定代码如下:
let binding = Binding(
    get: {
        String(data: files[i].content, encoding: .utf8) ?? ""
    },
    set: {
        files[i].content = $0.data(using: .utf8) ?? Data()
    }
)
                        
CodeEditor(text: binding)
问题根源
  1. 全局TextView实例复用:CodeEditor结构体中直接声明var textView = LineNumberedTextView()作为属性,SwiftUI的UIViewRepresentable是值类型,每次视图更新(如i变化)时结构体实例可能重建,但该textView会被意外复用,导致新旧绑定的文本数据串混。
  2. Coordinator持有旧实例:Coordinator保存的parent是旧的CodeEditor结构体实例,切换文件后新的CodeEditor创建,但Coordinator仍可能操作旧绑定,导致数据写入错误位置。
  3. 选中行更新逻辑错误:textViewDidChangeSelection中直接操作parent.textView,而非当前活跃的TextView实例,状态更新混乱。
修复方案及修改后代码
import SwiftUI

struct CodeEditor: UIViewRepresentable {
    @Binding var text: String
    var colorScheme: ColorScheme = .dark
    var showLineNumbers: Bool = true

    func makeUIView(context: Context) -> LineNumberedTextView {
        // 每次创建新的TextView实例,避免复用
        let textView = LineNumberedTextView()
        textView.isEditable = true
        textView.delegate = context.coordinator
        textView.autocorrectionType = .no
        textView.autocapitalizationType = .none
        textView.keyboardType = .asciiCapable
        textView.font = UIFont.monospacedSystemFont(ofSize: 15, weight: .regular)
        textView.backgroundColor = UIColor.white
        textView.textContainer.lineBreakMode = .byWordWrapping
        textView.colorScheme = colorScheme
        return textView
    }
    
    func updateUIView(_ uiView: LineNumberedTextView, context: Context) {
        // 同步文本时临时移除代理,避免触发不必要的绑定更新
        if uiView.text != text {
            uiView.delegate = nil
            uiView.text = text
            uiView.delegate = context.coordinator
        }
        // 同步配色设置
        if uiView.colorScheme != colorScheme {
            uiView.colorScheme = colorScheme
            uiView.setNeedsDisplay()
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        // 使用弱引用避免循环引用
        weak var parent: CodeEditor?
        
        init(_ parent: CodeEditor) {
            self.parent = parent
        }
        
        func textViewDidChange(_ textView: UITextView) {
            guard let parent = parent, parent.text != textView.text else { return }
            parent.text = textView.text
        }
        
        func textViewDidChangeSelection(_ textView: UITextView) {
            // 直接操作当前TextView实例,而非parent中的旧实例
            guard let numberedTextView = textView as? LineNumberedTextView else { return }
            numberedTextView.currentLines = textView.text.lineNumbersForRange(textView.selectedRange) ?? []
            numberedTextView.setNeedsLayout()
        }
    }
}

// 以下辅助函数及扩展无需修改
func findRegex(pattern: String, text: String) -> [NSRange] {
    var result: [NSRange] = []
    if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
        let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: text.count))
        for match in matches {
            result.append(match.range)
        }
    }
    return result
}

extension String {
    func lineNumbersForRange(_ range: NSRange) -> [Int]? {
        guard
            let startIndex = self.index(self.startIndex, offsetBy: range.location, limitedBy: self.endIndex),
            let endIndex = self.index(startIndex, offsetBy: range.length, limitedBy: self.endIndex)
        else {
            return nil
        }
        
        let substring = self[startIndex..<endIndex]
        var lineNumbers: [Int] = []
        
        let linesInRange = substring.components(separatedBy: .newlines)
        let lineBreaksBeforeStartIndex = self[..<startIndex].components(separatedBy: .newlines).count - 1
        
        for (index, _) in linesInRange.enumerated() {
            let lineNumber = lineBreaksBeforeStartIndex + index + 1
            lineNumbers.append(lineNumber)
        }
        
        return lineNumbers
    }
}

class LineNumberedTextView: UITextView {
    let lineNumberGutterWidth: CGFloat = 50
    var currentLines: [Int] = []
    var showLineNumbers: Bool = true
    var colorScheme: ColorScheme = .light
    
    override func draw(_ rect: CGRect) {
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .left
        
        let attributes: [NSAttributedString.Key: Any] = [
            .font: UIFont.monospacedSystemFont(ofSize: 15, weight: .ultraLight),
            .paragraphStyle: paragraphStyle,
            .foregroundColor: colorScheme == .light ? UIColor(white: 0.8, alpha: 1) : UIColor(white: 0.2, alpha: 1)
        ]
        let attributes_active: [NSAttributedString.Key: Any] = [
            .font: UIFont.monospacedSystemFont(ofSize: 15, weight: .ultraLight),
            .paragraphStyle: paragraphStyle,
            .foregroundColor: colorScheme == .light ? UIColor.black : UIColor.white
        ]
        
        var lineNum = 1
        var lineRect = CGRect.zero
        let full = NSRange(location: 0, length: self.textStorage.length)
        var isWrapped = false
        
        layoutManager.enumerateLineFragments(forGlyphRange: full) { (rect, usedRect, textContainer, glyphRange, stop) in
            let charRange: NSRange = self.layoutManager.glyphRange(forCharacterRange: glyphRange, actualCharacterRange: nil)
            let paraRange: NSRange? = (self.textStorage.string as NSString?)?.paragraphRange(for: charRange)
            
            let wrapped = charRange.location == paraRange?.location
            
            if wrapped {
                lineRect = CGRect(x: 8, y: rect.origin.y + self.textContainerInset.top, width: self.lineNumberGutterWidth, height: rect.height)
                let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? attributes_active : attributes)
                attributedString.draw(in: lineRect)
                lineNum += 1
            }
            isWrapped = !wrapped
        }
        
        if self.textStorage.string.isEmpty {
            let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? attributes_active : attributes)
            attributedString.draw(at: CGPoint(x: 8, y: self.textContainerInset.top))
        }
        
        if self.textStorage.string.hasSuffix("\n") {
            let rect = lineRect.offsetBy(dx: 0, dy: isWrapped ? (lineRect.height * 2) : lineRect.height)
            let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? attributes_active : attributes)
            attributedString.draw(in: rect)
        }
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        textContainerInset = UIEdgeInsets(top: textContainerInset.top, left: lineNumberGutterWidth, bottom: textContainerInset.bottom, right: textContainerInset.right)
        setNeedsDisplay()
    }
    
    override var text: String! {
        didSet {
            setNeedsDisplay()
        }
    }
}
关键修改说明
  • 移除全局TextView实例:每次makeUIView时创建新的LineNumberedTextView,确保切换文件时使用独立的编辑器实例,避免数据串用。
  • Coordinator使用弱引用:防止循环引用,确保旧的CodeEditor实例能被正确释放。
  • 优化updateUIView逻辑:临时移除代理再设置文本,避免触发不必要的textViewDidChange,防止循环更新;同时同步配色设置。
  • 修正选中行更新逻辑:直接操作当前活跃的LineNumberedTextView实例,确保状态更新准确。

内容的提问来源于stack exchange,提问作者Karl Ehrlich

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.23 07:00:58