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

Swift实现类Facebook@提及用户功能 选中用户后监听不停止问题

iOS实现@用户提及功能异常修复

问题描述

尝试实现类似Facebook的@用户提及效果:当输入字符串包含@符号时,在TableView中拉取展示用户列表。但目前存在异常:只要文本中出现@符号就会持续触发用户列表监听逻辑,即便已经选中目标用户、将用户名插入输入文本后,监听仍不会停止。
初始问题代码如下:

override func viewDidLoad() {
    super.viewDidLoad()
    NotificationCenter.default.addObserver(self, selector: #selector(checkUsername), name: UITextView.textDidChangeNotification, object: nil)  
}

@objc func checkUsername() {
    if  (textView.text.range(of: "@" + "") != nil) {
        let range: Range<String.Index> = self.containerView.commentTextView.text.range(of: "@" + "")!
        self.textView.text.replaceSubrange(range, with: " ")
    }
}

let usernames = ["Ariana", "Brad", "Chelsea"]

问题根因

  • 触发条件过于宽泛:只要全文本存在任意@字符就触发逻辑,完全不区分@是待输入的触发点,还是已经插入完成的用户名的组成部分(比如已经插入的@Ariana里的@也会被识别为触发条件),自然会持续触发监听。
  • 匹配逻辑错误:range(of: "@" + "")本质是匹配全文第一个出现的@,没有定位光标附近的有效触发点,甚至代码里直接把匹配到的@替换为空格,会直接破坏已经插入完成的提及内容。
  • 缺少状态闭环:选中用户插入用户名后,没有任何逻辑退出搜索状态,无法区分「正在输入提及内容」和「提及内容已经插入完成」两种场景。

修复方案

核心逻辑调整:仅当光标紧邻符合规则的@(@位于文本开头或空白字符之后,且@到光标位置之间没有其他空白字符)时,才激活用户列表搜索;选中用户插入完成后,主动重置搜索状态隐藏列表,避免被已插入内容中的@重复触发。
修复后的完整实现代码:

import UIKit

class CommentViewController: UIViewController, UITextViewDelegate, UITableViewDataSource, UITableViewDelegate {
    @IBOutlet weak var commentTextView: UITextView!
    @IBOutlet weak var userListTableView: UITableView!
    
    // 预置用户列表
    let usernames = ["Ariana", "Brad", "Chelsea"]
    // 标记是否处于@提及搜索激活状态
    private var isMentionActive = false
    // 记录当前触发搜索的@所在位置
    private var mentionStartIndex: String.Index?
    // 搜索匹配到的用户
    private var matchedUsers: [String] = [] {
        didSet {
            userListTableView.reloadData()
            userListTableView.isHidden = matchedUsers.isEmpty
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        commentTextView.delegate = self
        userListTableView.isHidden = true
        userListTableView.dataSource = self
        userListTableView.delegate = self
        // 移除原全局通知监听,改用代理回调更可控
    }
    
    // 检测当前文本是否触发@提及
    private func checkMentionStatus() {
        guard let cursorRange = commentTextView.selectedTextRange,
              let cursorPos = commentTextView.position(from: cursorRange.start, offset: 0) else {
            resetMentionStatus()
            return
        }
        
        // 获取光标前的文本内容
        let cursorOffset = commentTextView.offset(from: commentTextView.beginningOfDocument, to: cursorPos)
        let textBeforeCursor = String(commentTextView.text.prefix(cursorOffset))
        
        var searchPos = textBeforeCursor.endIndex
        var validMentionStart: String.Index?
        
        // 从光标位置向前查找最近的有效@
        while searchPos > textBeforeCursor.startIndex {
            searchPos = textBeforeCursor.index(before: searchPos)
            let currentChar = textBeforeCursor[searchPos]
            
            if currentChar == "@" {
                // 校验@前面是否为文本开头或空白字符,避免匹配邮箱、已完成提及中的@
                if searchPos == textBeforeCursor.startIndex {
                    validMentionStart = searchPos
                    break
                }
                let prevChar = textBeforeCursor[textBeforeCursor.index(before: searchPos)]
                if prevChar.isWhitespace || prevChar.isNewline {
                    validMentionStart = searchPos
                    break
                }
            }
            
            // 遇到空白字符则停止查找,说明当前光标前没有待输入的@提及
            if currentChar.isWhitespace || currentChar.isNewline {
                break
            }
        }
        
        guard let mentionStart = validMentionStart else {
            resetMentionStatus()
            return
        }
        
        // 提取@后的搜索关键词
        let keywordStart = textBeforeCursor.index(after: mentionStart)
        let keyword = String(textBeforeCursor[keywordStart...])
        
        // 更新搜索状态
        isMentionActive = true
        mentionStartIndex = mentionStart
        
        // 匹配用户
        if keyword.isEmpty {
            matchedUsers = usernames
        } else {
            matchedUsers = usernames.filter { $0.lowercased().hasPrefix(keyword.lowercased()) }
        }
    }
    
    // 重置提及搜索状态
    private func resetMentionStatus() {
        isMentionActive = false
        mentionStartIndex = nil
        matchedUsers = []
    }
    
    // 选中用户后插入用户名
    private func insertMentionUser(_ username: String) {
        guard let startIndex = mentionStartIndex else { return }
        
        // 计算光标在全文本中的位置,替换@到光标位置的内容
        let cursorLocation = commentTextView.selectedRange.location
        let replaceEnd = commentTextView.text.index(commentTextView.text.startIndex, offsetBy: cursorLocation)
        let replaceRange = startIndex..<replaceEnd
        
        // 插入格式为@用户名 + 空格
        commentTextView.text.replaceSubrange(replaceRange, with: "@\(username) ")
        
        // 移动光标到插入内容末尾
        let newCursorLocation = commentTextView.text.distance(from: commentTextView.text.startIndex, to: replaceRange.lowerBound) + username.count + 2
        commentTextView.selectedRange = NSRange(location: newCursorLocation, length: 0)
        
        // 重置状态隐藏用户列表
        resetMentionStatus()
    }
    
    // MARK: - UITextViewDelegate
    func textViewDidChange(_ textView: UITextView) {
        checkMentionStatus()
    }
    
    func textViewDidChangeSelection(_ textView: UITextView) {
        // 光标移动时校验状态,避免光标移走后列表仍显示
        checkMentionStatus()
    }
    
    // MARK: - UITableViewDataSource/Delegate
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        matchedUsers.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell", for: indexPath)
        cell.textLabel?.text = matchedUsers[indexPath.row]
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        insertMentionUser(matchedUsers[indexPath.row])
    }
}

关键优化点

  • 移除全局通知监听,改用UITextViewDelegate回调处理文本变化,避免全局通知带来的非预期触发
  • 新增有效@校验规则,仅识别光标前、位于文本开头/空白字符后的@作为触发点,不会误判已插入完成的提及内容、邮箱地址中的@
  • 加入完整状态管理,选中用户插入内容后主动重置搜索状态,插入完成后自动隐藏用户列表,不会重复触发监听
  • 支持根据@后输入的关键词实时过滤用户列表,交互逻辑和主流社交产品一致

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.26 22:24:16