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

SwiftUI带关键词高亮的UITextView光标跳转Bug排查求助

SwiftUI文本编辑器输入时光标跳转到末尾的修复方案

问题背景

我需要实现一款SwiftUI文本编辑器,核心需求包括:

  • 用户输入时实时高亮指定关键词
  • 工具栏按钮可将选中的文本加入关键词列表

目前通过UIViewRepresentable结合Coordinator实现,但存在输入时光标跳转到文本末尾的Bug。尝试过移除@EnvironmentObject、去掉富文本样式、改用Binding传递选中文本、手动保存恢复光标位置等方法,均未彻底解决问题(手动恢复导致输入卡顿)。

问题原因分析

  1. 频繁触发updateUIView:依赖@EnvironmentObject的整个FormViewModel时,selectedText的更新会触发FormTextEditorBody的视图更新,导致updateUIView被频繁调用。
  2. 光标被强制重置:每次调用updateUIView时重新设置attributedText或text,UITextView会自动将光标重置到文本末尾。
  3. 无效的光标恢复逻辑:之前的手动光标恢复未做条件判断,每次update都执行该操作,导致输入卡顿。

解决方案

核心优化点

  1. 分离依赖,减少不必要更新:将keywords和selectedText通过Binding单独传递,避免依赖整个FormViewModel导致的频繁视图更新。
  2. 条件更新富文本:仅当文本内容或关键词列表变化时,才重新生成并设置富文本,避免无意义的重复操作。
  3. 高效恢复光标位置:仅在需要更新富文本时,保存并恢复光标位置,且只在内容真正变化时执行。

修改后的完整代码

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.25 11:15:37