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重排冲突:
- 输入文本时,
textViewDidChange触发SwiftUI的@Binding状态更新,进而调用updateUIView。 updateUIView中直接设置uiView.text = text会强制UITextView重新渲染,重置滚动位置和光标位置。SourceEditorClass的layoutSubviews每次都会修改textContainerInset.left,状态同步时会再次触发布局调整,导致光标短暂偏移后被系统修正。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
相关产品推荐
相关产品推荐

