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

UITextView嵌入UIViewRepresentable后光标上下跳动问题求助

UITextView封装UIViewRepresentable后编辑时光标跳动问题

我把UITextView封装在UIViewRepresentable中,当编辑绑定到SwiftUI字符串状态变量的文本时,光标会向上跳动约100px,30毫秒左右后又回到原位(当前光标位于“pr”之后)。

试过设置textView.contentInsetAdjustmentBehavior = .never、移除内容内边距textView.contentInset = UIEdgeInsets.zero等方法,只有设置textView.isScrollEnabled为false能消除该问题,但这样会失去滚动功能,显然不可行。

请问这是什么原因导致的?


可复现代码

使用示例:

struct TestView: View {
    @State private var text = "Hello, World!"

    var body: some View {
        SourceEditor(text: $text)
            .showsLineNumbers(true)
            .textReplacements([])
    }
}

定义代码:

struct EditorTextReplacement: Equatable {
    var replace: String
    var with: String
    var cursorMovement: EditorCursorMovement
}

enum EditorCursorMovement: Equatable {
    case stay
    case goback(Int)
    case goforth(Int)

    var amount: Int? {
        switch self {
        case .stay:
            return nil
        case .goback(let amount), .goforth(let amount):
            return amount
        }
    }
}

struct EditorTheme {
    var lineNumberSelected: UIColor
    var lineNumber: UIColor
    var textColor: UIColor
    var backgroundColor: UIColor
    var selectionColor: UIColor
    var gutterWidth: CGFloat
}

extension EditorTheme {
    static let defaultLight = EditorTheme(
        lineNumberSelected: UIColor(red: 0.75, green: 0.78, blue: 0.80, alpha: 1.0),
        lineNumber: UIColor(red: 0.55, green: 0.56, blue: 0.57, alpha: 1.0),
        textColor: .black,
        backgroundColor: .white,
        selectionColor: UIColor(red: 0.75, green: 0.82, blue: 0.96, alpha: 1.0),
        gutterWidth: 50
    )

    static let defaultDark = EditorTheme(
        lineNumberSelected: UIColor(red: 0.47, green: 0.50, blue: 0.52, alpha: 1.0),
        lineNumber: UIColor(red: 0.60, green: 0.60, blue: 0.60, alpha: 1.0),
        textColor: .white,
        backgroundColor: UIColor(red: 0.16, green: 0.17, blue: 0.18, alpha: 1.0),
        selectionColor: UIColor(red: 0.32, green: 0.37, blue: 0.43, alpha: 1.0),
        gutterWidth: 50
    )
}

struct SourceEditor: View {
    @Binding var text: String
    @Environment(\.colorScheme) var colorScheme

    private var showLineNumbers: Bool = true
    private var editorFont: UIFont = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular)
    private var textReplacements: [EditorTextReplacement] = []

    private var editorThemeLight: EditorTheme = .defaultLight
    private var editorThemeDark: EditorTheme = .defaultDark

    init(text: Binding<String>) {
        self._text = text
    }

    var body: some View {
        SourceEditorView(
            text: $text,
            font: editorFont,
            showLineNumbers: showLineNumbers,
            textReplacements: textReplacements,
            theme: colorScheme == .light ? editorThemeLight : editorThemeDark
        )
    }

    func editorTheme(_ theme: EditorTheme, forScheme: ColorScheme? = nil) -> SourceEditor {
        var view = self
        if let forScheme {
            if forScheme == .light {
                view.editorThemeLight = theme
            } else {
                view.editorThemeDark = theme
            }
        } else {
            view.editorThemeLight = theme
            view.editorThemeDark = theme
        }
        return view
    }

    func editorFont(_ font: UIFont) -> SourceEditor {
        var view = self
        view.editorFont = font
        return view
    }

    func textReplacements(_ replacements: [EditorTextReplacement]) -> SourceEditor {
        var view = self
        view.textReplacements = replacements
        return view
    }

    func showsLineNumbers(_ show: Bool) -> SourceEditor {
        var view = self
        view.showLineNumbers = show
        return view
    }
}

struct SourceEditorView: UIViewRepresentable {
    @Binding var text: String
    var font: UIFont
    var showLineNumbers: Bool
    var textReplacements: [EditorTextReplacement]
    var theme: EditorTheme

    func makeUIView(context: Context) -> SourceEditorClass {
        let textView = SourceEditorClass()
        textView.delegate = context.coordinator
        textView.font = font
        textView.textColor = theme.textColor
        textView.backgroundColor = theme.backgroundColor
        textView.isEditable = true
        textView.textContainer.lineBreakMode = .byWordWrapping
        textView.autocorrectionType = .no
        textView.contentInset = UIEdgeInsets.zero
        textView.contentInsetAdjustmentBehavior = .never
        textView.keyboardType = .asciiCapable
        textView.textReplacements = textReplacements
        return textView
    }

    func updateUIView(_ uiView: SourceEditorClass, context: Context) {
        if uiView.text != text {
            uiView.text = text
        }
        uiView.font = font
        uiView.updateTheme(theme)
        uiView.textReplacements = textReplacements
        context.coordinator.textDidChange = { newText in
            text = newText
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator()
    }

    class Coordinator: NSObject, UITextViewDelegate {
        var textDidChange: ((String) -> ())?

        func textViewDidChange(_ textView: UITextView) {
            textDidChange?(textView.text)
        }
    }
}

class SourceEditorClass: UITextView {
    var textReplacements: [EditorTextReplacement] = []
    private var currentLines: [Int] = []
    private var theme: EditorTheme = .defaultLight

    func updateTheme(_ newTheme: EditorTheme) {
        self.theme = newTheme
        self.backgroundColor = theme.backgroundColor
        self.textColor = theme.textColor
        setNeedsDisplay()
    }

    override func draw(_ rect: CGRect) {
        super.draw(rect)
        drawLineNumbers()
    }

    private func drawLineNumbers() {
        guard let context = UIGraphicsGetCurrentContext() else { return }

        context.saveGState()
        context.setFillColor(UIColor.clear.cgColor)
        context.fill(bounds)

        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .left

        let attributes: [NSAttributedString.Key: Any] = [
            .font: font ?? UIFont.monospacedSystemFont(ofSize: 15, weight: .regular),
            .paragraphStyle: paragraphStyle,
            .foregroundColor: theme.lineNumber
        ]

        let activeAttributes: [NSAttributedString.Key: Any] = [
            .font: font ?? UIFont.monospacedSystemFont(ofSize: 15, weight: .regular),
            .paragraphStyle: paragraphStyle,
            .foregroundColor: theme.lineNumberSelected
        ]

        var lineNum = 1
        var lineRect = CGRect.zero
        let fullRange = NSRange(location: 0, length: self.textStorage.length)
        var isWrapped = false

        layoutManager.enumerateLineFragments(forGlyphRange: fullRange) { (rect, _, _, glyphRange, _) 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.theme.gutterWidth, height: rect.height)
                let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? activeAttributes : attributes)
                attributedString.draw(in: lineRect)
                lineNum += 1
            }
            isWrapped = !wrapped
        }

        if self.textStorage.string.isEmpty {
            let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? activeAttributes : 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) ? activeAttributes : attributes)
            attributedString.draw(in: rect)
        }

        context.restoreGState()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        textContainerInset.left = theme.gutterWidth
        setNeedsDisplay()
    }

    override var text: String! {
        didSet {
            setNeedsDisplay()
        }
    }

    override func insertText(_ text: String) {
        var superDidInsert = false

        for replacement in textReplacements {
            if text == replacement.replace {
                super.insertText(replacement.with)

                switch replacement.cursorMovement {
                case .stay:
                    break
                case .goback(let int):
                    if let newPosition = self.position(from: self.beginningOfDocument, offset: self.offset(from: self.beginningOfDocument, to: self.selectedTextRange!.start) - int) {
                        self.selectedTextRange = self.textRange(from: newPosition, to: newPosition)
                    }
                case .goforth(let int):
                    if let newPosition = self.position(from: self.beginningOfDocument, offset: self.offset(from: self.beginningOfDocument, to: self.selectedTextRange!.start) + int) {
                        self.selectedTextRange = self.textRange(from: newPosition, to: newPosition)
                    }
                }

                superDidInsert = true
            }
        }

        if !superDidInsert {
            super.insertText(text)
        }
    }
}

问题原因

这个光标跳动的核心是SwiftUI与UIKit状态同步引发的UITextView重排冲突:

  1. 输入文本时,textViewDidChange触发SwiftUI的@Binding状态更新,进而调用updateUIView。
  2. updateUIView中直接设置uiView.text = text会强制UITextView重新渲染,重置滚动位置和光标位置。
  3. SourceEditorClass的layoutSubviews每次都会修改textContainerInset.left,状态同步时会再次触发布局调整,导致光标短暂偏移后被系统修正。
  4. draw(_ rect:)中手动绘制行号的逻辑,会在文本更新时触发额外重绘,加重布局波动。

修复方案

1. 避免无意义的文本重置

在updateUIView中,仅当文本真正不同时更新,且保留光标和滚动状态:

func updateUIView(_ uiView: SourceEditorClass, context: Context) {
    if uiView.text != text {
        // 保存当前状态
        let selectedRange = uiView.selectedTextRange
        let contentOffset = uiView.contentOffset
        
        uiView.text = text
        
        // 恢复状态
        uiView.selectedTextRange = selectedRange
        uiView.contentOffset = contentOffset
    }
    
    uiView.font = font
    uiView.updateTheme(theme)
    uiView.textReplacements = textReplacements
    
    // 仅初始化一次回调,避免重复赋值
    if context.coordinator.textDidChange == nil {
        context.coordinator.textDidChange = { newText in
            text = newText
        }
    }
}

2. 优化布局逻辑

在SourceEditorClass中,仅当gutter宽度变化时才更新内边距:

class SourceEditorClass: UITextView {
    private var currentGutterWidth: CGFloat = 0
    
    override func layoutSubviews() {
        super.layoutSubviews()
        if textContainerInset.left != theme.gutterWidth {
            textContainerInset.left = theme.gutterWidth
            currentGutterWidth = theme.gutterWidth
            setNeedsDisplay()
        }
    }
}

3. 拆分行号绘制逻辑

将行号绘制移到独立的LineNumberView作为侧边栏,通过监听UITextView的滚动同步位置,减少主视图重绘次数。


内容的提问来源于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.19 16:55:56