SwiftUI带关键词高亮的UITextView光标跳转Bug排查求助
SwiftUI文本编辑器输入时光标跳转到末尾的修复方案
问题背景
我需要实现一款SwiftUI文本编辑器,核心需求包括:
- 用户输入时实时高亮指定关键词
- 工具栏按钮可将选中的文本加入关键词列表
目前通过UIViewRepresentable结合Coordinator实现,但存在输入时光标跳转到文本末尾的Bug。尝试过移除@EnvironmentObject、去掉富文本样式、改用Binding传递选中文本、手动保存恢复光标位置等方法,均未彻底解决问题(手动恢复导致输入卡顿)。
问题原因分析
- 频繁触发
updateUIView:依赖@EnvironmentObject的整个FormViewModel时,selectedText的更新会触发FormTextEditorBody的视图更新,导致updateUIView被频繁调用。 - 光标被强制重置:每次调用
updateUIView时重新设置attributedText或text,UITextView会自动将光标重置到文本末尾。 - 无效的光标恢复逻辑:之前的手动光标恢复未做条件判断,每次
update都执行该操作,导致输入卡顿。
解决方案
核心优化点
- 分离依赖,减少不必要更新:将
keywords和selectedText通过Binding单独传递,避免依赖整个FormViewModel导致的频繁视图更新。 - 条件更新富文本:仅当文本内容或关键词列表变化时,才重新生成并设置富文本,避免无意义的重复操作。
- 高效恢复光标位置:仅在需要更新富文本时,保存并恢复光标位置,且只在内容真正变化时执行。
修改后的完整代码
1. 重构后的FormTextEditorBody
import SwiftUI // 自定义Keyword类型(根据实际业务调整) struct Keyword: Identifiable, Equatable { let id = UUID() let text: String } struct FormTextEditorBody: UIViewRepresentable { @Binding var text: String @Binding var selectedText: String let highlightKeywords: Bool let keywords: [Keyword] init(text: Binding<String>, selectedText: Binding<String>, highlightKeywords: Bool, keywords: [Keyword]) { self._text = text self._selectedText = selectedText self.highlightKeywords = highlightKeywords self.keywords = keywords } func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator // 初始化基础样式 textView.font = UIFont.systemFont(ofSize: 16) textView.textColor = UIColor(Color.primary) return textView } func updateUIView(_ uiView: UITextView, context: Context) { guard highlightKeywords else { // 普通文本模式:仅内容变化时更新,同步光标位置 if uiView.text != text { let cursorOffset = saveCursorOffset(uiView) uiView.text = text restoreCursorOffset(uiView, offset: cursorOffset) } return } // 富文本模式:仅文本或关键词变化时更新 let needsUpdate = text != context.coordinator.lastText || keywords != context.coordinator.lastKeywords guard needsUpdate else { return } // 保存当前光标位置 let cursorOffset = saveCursorOffset(uiView) // 生成高亮后的富文本 let attributedText = generateHighlightedAttributedText() // 设置富文本 uiView.attributedText = attributedText // 恢复光标位置 restoreCursorOffset(uiView, offset: cursorOffset) // 更新缓存,避免重复更新 context.coordinator.lastText = text context.coordinator.lastKeywords = keywords } func makeCoordinator() -> Coordinator { Coordinator(self) } // MARK: - 私有辅助方法 private func generateHighlightedAttributedText() -> NSAttributedString { let attributed = NSMutableAttributedString(string: text) // 设置基础样式 let fullRange = NSRange(0..<text.count) attributed.addAttribute(.font, value: UIFont.systemFont(ofSize: 16), range: fullRange) attributed.addAttribute(.foregroundColor, value: UIColor(Color.primary), range: fullRange) // 高亮关键词 for word in keywords { for range in text.independentRangesOf(string: word.text) { let nsRange = NSRange(range, in: text) attributed.addAttribute(.foregroundColor, value: UIColor(Color.secondaryAccent), range: nsRange) } } return attributed } private func saveCursorOffset(_ textView: UITextView) -> Int { guard let selectedRange = textView.selectedTextRange else { return 0 } return textView.offset(from: textView.beginningOfDocument, to: selectedRange.start) } private func restoreCursorOffset(_ textView: UITextView, offset: Int) { guard let newPosition = textView.position(from: textView.beginningOfDocument, offset: offset) else { return } textView.selectedTextRange = textView.textRange(from: newPosition, to: newPosition) } // MARK: - Coordinator class Coordinator: NSObject, UITextViewDelegate { var parent: FormTextEditorBody var lastText: String = "" var lastKeywords: [Keyword] = [] init(_ parent: FormTextEditorBody) { self.parent = parent } func textViewDidChange(_ textView: UITextView) { // 避免循环更新:仅内容不同时同步到Binding if textView.text != parent.text { parent.text = textView.text } } func textViewDidChangeSelection(_ textView: UITextView) { guard let selectedRange = textView.selectedTextRange else { return } let newSelectedText = textView.text(in: selectedRange) ?? "" // 仅选中内容变化时更新Binding if newSelectedText != parent.selectedText { parent.selectedText = newSelectedText } } } } // 字符串扩展:获取子字符串的所有独立范围 extension String { func independentRangesOf(string searchString: String) -> [Range<String.Index>] { var ranges: [Range<String.Index>] = [] var startIndex = self.startIndex while let range = self.range(of: searchString, range: startIndex..<self.endIndex) { ranges.append(range) startIndex = range.upperBound } return ranges } }
2. 上级SwiftUI视图调用示例
struct ParentEditorView: View { @State private var editorText = "" @State private var selectedText = "" @EnvironmentObject private var form: FormViewModel var body: some View { VStack(spacing: 16) { FormTextEditorBody( text: $editorText, selectedText: $selectedText, highlightKeywords: true, keywords: form.keywords ) .frame(height: 300) .border(Color.gray.opacity(0.3)) HStack { Button("添加选中内容为关键词") { guard !selectedText.isEmpty, !form.keywords.contains(where: { $0.text == selectedText }) else { return } form.keywords.append(Keyword(text: selectedText)) } .disabled(selectedText.isEmpty) Spacer() Button("清空关键词") { form.keywords.removeAll() } } .padding(.horizontal) } .padding() } } // 示例ViewModel class FormViewModel: ObservableObject { @Published var keywords: [Keyword] = [] }
关键修复说明
- 减少视图更新触发:通过单独传递
keywords和selectedText的Binding,避免了FormViewModel其他属性变化导致的updateUIView调用。 - 条件更新逻辑:在
updateUIView中判断文本或关键词是否变化,仅在必要时生成并设置富文本。 - 高效光标处理:仅在需要更新内容时保存和恢复光标位置,避免无意义的重复操作,解决了卡顿问题。
- 避免循环更新:在
textViewDidChange和textViewDidChangeSelection中添加内容判断,仅当实际变化时同步到Binding,防止循环触发更新。
内容的提问来源于stack exchange,提问作者Alpheratz
相关产品推荐
相关产品推荐

