iOS 17 UITextView shouldChangeTextIn gets called unexpected twice under specific keyboard

Condition:

XCode: 15.0

Version: iOS 17.0

iPhone / Simulator's device settings:

  • Language Preference: Selecting Traditional Chinese (繁体中文) or TraditionalChinese (Hongkong) 繁体中文(香港) ( ) as the first preferernce language
  • Keyboard: Traditional Chiniese Caontonese with CangJie (倉頡) or Accelerated ( 速成) standard or engligsh keyboard

Issue:

When UITextView been very simplified customized by providing its delegation function shouldChangeTextIn and assign this customized text view as its delegation:

extension CustomizedTextView: UITextViewDelegate {
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
// make calculation of the replacementText and insert dot character based on the current cusor positon etc.condition checks
return true
}
}

To observe and control characters appearance for each one of the input stroke, this delegation function would be found been called twice when each number or special characters eg: dot charact . enters under such case.

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

But with the same conditions only to switch keyboard to others out of the listed above eg: English keyboard, then each one of the all alphabets, numbers, special characters on the keyboard gets the delegation function called only once.

Impacts

With this unexpected function triggered twice on iOS 17.0 or above which also been verified still exists on the physical device iOS 17.1, our project to utilize the related result of the function to make inputed number formatting and re-calculate the input cursor position, then the cursor would be inserted into wrong position.

For example:

User input -> 123 / 1234

Lead to wrong alculation shows -> 132 / 1,432

which the new cursor offset calculation based on the current selectedCursor when the function gets called, together with some checks of the number input whether needs insert comma , to format the number. Now the issue makes our checking logic unexpected calculated twice and the result displayed on the text view not aligned with customer inputed which makes it much confused.

Anyone here hits the issue also please provide your view on it, would be much appreciated.

Tip:

  • The is couldn't reproducible under iOS 16
  • The issue also applicable for UITextField on the same condition

I have encountered the same issue on iOS 17 and here is the result of my investigation.

iOS 17 Text/Number Input Bug with Cangjie倉頡, Sucheng速成, and Stroke筆畫 Keyboards

If your app treats user input as if it is missing the last inserted character/digit from actual text on screen (e.g. "12" instead of "123", "-" instead of "-5") for some customers on iOS 17 only (no problem on iOS 16), this is likely the root cause.

Bug Background

iOS calls the following delegate methods to notify our app that user has inserted or deleted a character in text input UI.

textField(_:shouldChangeCharactersIn:replacementString:) for UITextField

textView(_:shouldChangeTextIn:replacementText:) for UITextView

searchBar(_:shouldChangeTextIn:replacementText:) for UISearchBar

These methods have custom code to ensure input is within maximum length and to prevent invalid characters, etc.

Short, Easy to Understand Version of Bug

When user inserts a number or punctuation symbol with Cangjie倉頡, Sucheng速成, or Stroke筆畫 keyboard, iOS 17 calls the delegate methods a second time indicating no insertion or deletion but the current text in UITextField, UITextView, or UISearchBar is missing the last inserted character.

The extra call is NOT triggered by user and the text in UI is correct (not missing any character).

Printing the arguments passed into the delegate methods shows the following for a single insertion:

Normal

current text "1" replaced {1, 0} with "2" is "12"

Bug

current text "1" replaced {1, 0} with "2" is "12"

current text "1" replaced {1, 0} with "" is "1" <---- This did NOT happen but iOS said it happened.

This extra call corrupts our internal data keeping track of user input and leads to incorrect input being validated or used and crash in some cases due to internal text being shorter than actual text in UI and the next character entered being inserted at an index out of bounds.

Findings

  1. Bug exists on iOS 17 real devices and simulator on Apple silicon (simulator on Intel or Rosetta has an even more serious bug reported here.)
  2. Bug still exists on iOS 17.2 simulator in Xcode 15.1 beta 3.
  3. Bug does NOT exist on iOS 16 (and likely older iOS).
  4. Cangjie倉頡, Sucheng速成, and Stroke筆畫 keyboards are listed under both Chinese, Traditional繁體中文 and Cantonese, Traditional繁體廣東話 in iOS Settings, and short version of this bug exists in both languages.
  5. Bug does NOT exist when inserting Chinese characters.

An Approach to Work Around

Since the extra call indicates no insertion or deletion, I think it is reasonable for the delegate methods to do nothing so I decide to use it as identity of the problematic extra call to return early, skip all custom code, and prevent data corruption.

Sample code

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if #available(iOS 17, *),
shouldReturnFalseEarlyToWorkAroundiOS17BugWithCangjieSuchengOrStrokeKeyboard(range, string) {
return false
}
... // Original code
}
@available(iOS 17, *)
func shouldReturnFalseEarlyToWorkAroundiOS17BugWithCangjieSuchengOrStrokeKeyboard(_ range: NSRange, _ replacementString: String) -> Bool {
return (range.length == 0) && replacementString.isEmpty // User did not insert or delete any text.
}

Returning either true or false in the delegate methods appears to work just fine in actual use, but since the delegate methods are meant to decide whether to accept the change in text that just happened, I prefer to decline because the change and then extra call itself are different from what really appears in UI.

If your custom code does need to run even when user made no insertion or deletion (Please do let me know such situations), I can only think of storing the last time the delegate method was called and the text in UITextField, UITextView, or UISearchBar in the last call in order to compare and decide that the current call is extra if it indicates no insertion or deletion, has the same text as last call, and is called within a very short time of last call (< 100 ms maybe).

Shortcoming

Clearly this work around needs to be added in all affected delegate methods in the app in order to ensure the extra call is ignored for all text input UI in the app.

Creating subclasses of UITextField, UITextView, and UISearchBar with built-in ignoring of the extra call is another approach, but requires all code using/inheriting UITextField, UITextView, and UISearchBar to use/inherit the new subclasses so probably even more code needs to change.

Ideally I want to work around this bug for all text input UI in the app without adjusting all text input UI code, but I cannot think of any way to achieve this without resorting to questionable practice such as method swizzling.

If anyone has a simpler approach to work around this bug, please do let me know ASAP. I think all iOS developers will appreciate it very much.

Long, Mind-Boggling Version of Bug

The short version of this bug is easy to understand and reproduce when the keyboard type of the text input UI allows user to switch languages and keyboards.

Switching to Cangjie倉頡, Sucheng速成, or Stroke筆畫 keyboard and inserting a number or punctuation symbol on iOS 17 reproduces bug, and switching to other language keyboard and inserting works fine.

What about keyboard types that cannot switch languages and keyboards such as number pad or phone pad that show 0~9 without the globe icone for switching?

My investigation shows that many factors affect whether keyboard types like number pad reproduces bug. The following is probably not complete but what I managed to confirm at the time of writing.

Required Conditions to Reproduce Bug on Number Pads

  1. iOS system language set to any one of the following:
  • Chinese, Traditional繁體中文
  • Chinese, Traditional (Hong Kong)繁體中文(香港)
  • Chinese, Traditional (Macau)繁體中文(澳門)
  • Cantonese, Traditional繁體廣東話
  1. Localizations in app project setting has at least 1 file for Chinese, Traditional

Last Keyboard Used is Another Important Factor

If the first keyboard triggered in app is number pad, the last keyboard used before launching the app is the one that counts.

Cangjie倉頡 or Sucheng速成 keyboard of Chinese, Traditional繁體中文

Number pad reproduces bug.

Other keyboards of Chinese, Traditional繁體中文 (Zhuyin注音, Pinyin拼音, etc.)

Number pad does NOT reproduce bug.

Other keyboards of other languages (including Cangjie倉頡 or Sucheng速成 keyboard of Cantonese, Traditional繁體廣東話)

Unstable, sometimes reproduces bugs, sometimes works fine. One factor that I managed to discover is:

If Cangjie倉頡 or Sucheng速成 is placed above other keyboards of Chinese, Traditional繁體中文 in Keyboards list in iOS Settings, number pad reproduces bug; otherwise, number pad does not reproduce bug.

There may be more factors affecting bug behavior for other keyboards of other languages, but I was suffering a severe headache by this point and didn't bother anymore.

Further details do not help in reducing the amount of work needed to work around this bug anyway, since the short version always exists on iOS 17 without any other required condition that can be intentionally not met to avoid this bug.

Exceptions

  1. Secure text entry for password etc. with text shown as ●●● does NOT have bug.
  2. asciiCapable keyboard type does NOT have bug, likely because it restricts the languages that can be in use.
  3. asciiCapableNumberPad keyboard type does NOT have bug, but this cannot insert decimal numbers (e.g. 2.5) or replace phone pad (*+#).

I‘m so grateful you found this problem!! Gotta stuck for two days.

This order can also reproduce the bug.

  • Zhuyin注音-Handwriting手寫-Cangjie倉頡
iOS 17 UITextView shouldChangeTextIn gets called unexpected twice under specific keyboard
 
 
Q