A BUG of UITextView Delegate Method

I am developing a richtext editor using UITextView, and I found a BUG of UITextViewDelegate's method:

optional func textView(
    _ textView: UITextView,
    shouldChangeTextIn range: NSRange,
    replacementText text: String
) -> Bool

This BUG occurs when user tries to delete a selected text. For example:

When user deleting "llo" in "hello" by

  1. select "llo"
  2. press delete

The correct range should be NSRange(2, 3),but the actual range is NSRange(1, 4),and replacementText is a empty string. Which means it wants to replace "ello" with "" and it's not right.

The final result of this action is "llo" gets replaced by "", which is correct and corrupted with delegate method's range info! No wonder that we can't find this BUG until we test the delegate method.

However, when trying to replace "llo" with some text(NOT DELETING), the range info is correct.

In conculsion, the caller of the delegate method compute the range wrong when user try to delete a selected text.

I tested with Xcode 14 and iOS 15.

And get the correct result {1, 3}

SAME TextKit BUG on iPhone13 running iOS 17.1.2

When UITextView contains an attachment ahead and select the rest of the text without the attachment, the delegate method's range parameter is obviously wrong!

Code for bug reproduction:

//
//  TestViewController.swift
//  Notes
//
//  Created by laishere on 2023/12/13.
//

import UIKit

class TestViewController : UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        initView()
    }
    
    private func initView() {
        let textView = UITextView(frame: view.bounds)
        textView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(textView)
        NSLayoutConstraint.activate([
            textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            textView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor),
            textView.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor),
        ])
        textView.delegate = self
        let attachment = NSTextAttachment(image: UIImage(systemName: "sun.max.fill")!)
        textView.textStorage.insert(NSAttributedString(attachment: attachment), at: 0)
        let str = NSMutableAttributedString(string: "hello")
        str.addAttribute(.foregroundColor, value: UIColor.systemBlue, range: NSRange(location: 0, length: str.length))
        textView.textStorage.insert(str, at: 1)
    }
}

extension TestViewController : UITextViewDelegate {
    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        let sel = textView.selectedRange
        if sel != range {
            print("total text count: \(textView.text!.count), replacing range: \(range), selected range: \(sel)")
        }
        return true
    }
    
    func textViewDidChange(_ textView: UITextView) {
        print("text: \(textView.text!), count: \(textView.text!.count)")
    }
}

Reproduction procedure:

  1. Run the TestViewController
  2. Select hello
  3. Press delete key
  4. See the delegate params output

As you can see in the above image, the selected range is {1, 5}, but the delegate param range is {0, 6}.

However, despite the delegate method tells us it want to replace the whole 6 characters with empty string, the actual number of deleted characters is 5.

One charater which is the attachment charater is left as you can see in the output of textViewDidChange delegate method.

A BUG of UITextView Delegate Method
 
 
Q