Apple recommended Approach for Implementing @Mention System with Dropdown and Smart Backspace in UITextView

I'm working on an iOS app that requires an @mention system in a UITextView, similar to those in apps like Twitter or Slack. Specifically, I need to:

Detect @ Symbol and Show Dropdown: When the user types "@", display a dropdown (UITableView or similar) below the cursor with a list of mentionable users, filtered as the user types.

Handle Selection: Insert the selected username as a styled mention (e.g., blue text).

Smart Backspace Behavior: Ensure backspace deletes an entire mention as a single unit when the cursor is at its end, and cancels the mention process if "@" is deleted.

I've implemented a solution using UITextViewDelegate textViewDidChange(_:) to detect "@", a UITableView for the dropdown, and NSAttributedString for styling mentions. For smart backspace, I track mention ranges and handle deletions accordingly. However, I’d like to know:

What is Apple’s recommended approach for implementing this behavior?

Are there any UIKit APIs that simplify this, for proving this experience like smart backspace or custom text interactions?

I’m using Swift/UIKit. Any insights, sample code, or WWDC sessions you’d recommend would be greatly appreciated!

Edit: I am adding the ViewController file to demonstrate the approach that I m using.

import UIKit

// MARK: - Dummy user model
struct MentionUser {
    let id: String
    let username: String
}

class ViewController: UIViewController, UITextViewDelegate, UITableViewDelegate, UITableViewDataSource {
    
    // MARK: - UI Elements
    private let textView = UITextView()
    private let mentionTableView = UITableView()
    
    // MARK: - Data
    private var allUsers: [MentionUser] = [...]
    
    private var filteredUsers: [MentionUser] = []
    private var currentMentionRange: NSRange?

    // MARK: - View Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        setupTextView()  // to setup the UI
        setupDropdown() // to setup the UI
    }

    // MARK: - UITextViewDelegate
    func textViewDidChange(_ textView: UITextView) {
        let cursorPosition = textView.selectedRange.location
        let text = (textView.text as NSString).substring(to: cursorPosition)

        if let atRange = text.range(of: "@[a-zA-Z0-9_]*$", options: .regularExpression) {
            let nsRange = NSRange(atRange, in: text)
            let query = (text as NSString).substring(with: nsRange).dropFirst()
            currentMentionRange = nsRange

            filteredUsers = allUsers.filter {
                $0.username.lowercased().hasPrefix(query.lowercased())
            }

            mentionTableView.reloadData()
            showMentionDropdown()
        } else {
            hideMentionDropdown()
            currentMentionRange = nil
        }
    }

    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        if text.isEmpty, let attributedText = textView.attributedText {
            if range.location == 0 { return true }

            let attr = attributedText.attributes(at: range.location - 1, effectiveRange: nil)
            if let _ = attr[.mentionUserId] {
                let fullRange = (attributedText.string as NSString).rangeOfMentionAt(location: range.location - 1)
                let mutable = NSMutableAttributedString(attributedString: attributedText)
                mutable.deleteCharacters(in: fullRange)
                textView.attributedText = mutable
                textView.selectedRange = NSRange(location: fullRange.location, length: 0)

                textView.typingAttributes = [
                    .font: textView.font ?? UIFont.systemFont(ofSize: 16),
                    .foregroundColor: UIColor.label
                ]
                return false
            }
        }
        return true
    }

    // MARK: - Dropdown Visibility
    private func showMentionDropdown() {
        guard let selectedTextRange = textView.selectedTextRange else { return }
        mentionTableView.isHidden = false
    }

    private func hideMentionDropdown() {
        mentionTableView.isHidden = true
    }

    // MARK: - UITableViewDataSource
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return filteredUsers.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = "@\(filteredUsers[indexPath.row].username)"
        return cell
    }

    // MARK: - UITableViewDelegate
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        insertMention(filteredUsers[indexPath.row])
    }

    // MARK: - Mention Insertion
    private func insertMention(_ user: MentionUser) {
        guard let range = currentMentionRange else { return }

        let mentionText = "\(user.username)"
        let mentionAttributes: [NSAttributedString.Key: Any] = [
            .foregroundColor: UIColor.systemBlue,
            .mentionUserId: user.id
        ]

        let mentionAttrString = NSAttributedString(string: mentionText, attributes: mentionAttributes)
        let mutable = NSMutableAttributedString(attributedString: textView.attributedText)
        mutable.replaceCharacters(in: range, with: mentionAttrString)

        let spaceAttr = NSAttributedString(string: " ", attributes: textView.typingAttributes)
        mutable.insert(spaceAttr, at: range.location + mentionText.count)

        textView.attributedText = mutable
        textView.selectedRange = NSRange(location: range.location + mentionText.count + 1, length: 0)

        textView.typingAttributes = [
            .font: textView.font ?? UIFont.systemFont(ofSize: 16),
            .foregroundColor: UIColor.label
        ]

        hideMentionDropdown()
    }
}

// MARK: - Custom Attributed Key
extension NSAttributedString.Key {
    static let mentionUserId = NSAttributedString.Key("mentionUserId")
}
Answered by DTS Engineer in 846302022

UIKit doesn't provide any convenient API to achieve the features you described in an easier way, and so I'd say that implementing them with your own code by using UITextViewDelegate is the right way to go.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

UIKit doesn't provide any convenient API to achieve the features you described in an easier way, and so I'd say that implementing them with your own code by using UITextViewDelegate is the right way to go.

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Apple recommended Approach for Implementing @Mention System with Dropdown and Smart Backspace in UITextView
 
 
Q