skip to Main Content

Currently, we are implementing an undo/redo feature in UITextView.

However, we cannot use the built-in UndoManager in UITextView because we have multiple UITextView instances inside a UICollectionView.

Since UICollectionView recycles UITextView instances, the same UITextView might be reused in different rows, making the built-in UndoManager unreliable.

The shouldChangeTextIn method in UITextViewDelegate is key to implementing undo/redo functionality properly. Here is an example of our implementation:

extension ChecklistCell: UITextViewDelegate {
    func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        // Get the current text
        let s = textView.text ?? ""
        
        // Get the starting position of the change
        let start = range.location
        
        // Get the number of characters that will be replaced
        let count = range.length
        
        // Get the number of characters that will be added
        let after = text.count
        
        print(">>>> The current text = "(s)"")
        print(">>>> The starting position of the change = (start)")
        print(">>>> The number of characters that will be replaced = (count)")
        print(">>>> The number of characters that will be added = (after)")
        print(">>>>")
        
        if let delegate = delegate, let checklistId = checklistId, let index = delegate.checklistIdToIndex(checklistId) {
            delegate.attachTextAction(s: s, start: start, count: count, after: after, index: index)
        }
        
        return true
    }
}

This is how it looks like.

https://www.facebook.com/wenotecolor/videos/2174891132886868 – Undo/ redo works pretty well in English

Working scene behind the UITextViewDelegate

enter image description here


However, this implementation does not work well with non-English input using an IME. When using an IME, there is an intermediate input before the final input is produced. For example, typing "wo" (intermediate input) produces "我" (final input). Currently, UITextViewDelegate captures both "wo" and "我".

UITextViewDelegate captures both "wo" and "我"

enter image description here

Is there a way to ignore the intermediate input from IME and only consider the final input?


In Android, we use the beforeTextChanged method in TextWatcher to seamlessly ignore the intermediate input from IME and only consider the final input. You can see this in action in this

Android captures only "我"

enter image description here

Is there an equivalent way in iOS to ignore the intermediate input from IME and only take the final input into consideration?

2

Answers


  1. Since the IME is registering each character separately, I would recommend simply discerning between English and Mandarin characters and only registering the latter. The Mandarin checking aspect of was inspired by a SO article about checking Mandarin characters in Swift

    extension ChecklistCell: UITextViewDelegate {
        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
        // Check if the text contains only Mandarin characters
        let isMandarin = textView.text.range(of: "\p{Han}", options: .regularExpression) != nil
            
        if isMandarin {
        let s = textView.text ?? ""
        
        // Get the starting position of the change
        let start = range.location
        
        // Get the number of characters that will be replaced
        let count = range.length
        
        // Get the number of characters that will be added
        let after = text.count
        
        print(">>>> The current text = "(s)"")
        print(">>>> The starting position of the change = (start)")
        print(">>>> The number of characters that will be replaced = (count)")
        print(">>>> The number of characters that will be added = (after)")
        print(">>>>")
        
        if let delegate = delegate, let checklistId = checklistId, let index = delegate.checklistIdToIndex(checklistId) {
            delegate.attachTextAction(s: s, start: start, count: count, after: after, index: index)
        }
        return true
       } else {
            // Some non-mandarin text was given. 
            return false
           }
        }
    }
    

    For the English version of the app, this could easy be disabled.

    Login or Signup to reply.
  2. Here are steps how I did it for a client few years back,

    First you need to create properties to store the undo and redo managers:

    import UIKit
    
    class UndoRedoManager {
        private var undoStack: [String] = []
        private var redoStack: [String] = []
    
        func undo() -> String? {
            guard let item = undoStack.popLast() else {
                return nil
            }
            redoStack.append(item)
            return undoStack.last
        }
    
        func redo() -> String? {
            guard let item = redoStack.popLast() else {
                return nil
            }
            undoStack.append(item)
            return item
        }
    
        func addToUndoStack(_ text: String) {
            undoStack.append(text)
            redoStack.removeAll()
        }
    
        func canUndo() -> Bool {
            return !undoStack.isEmpty
        }
    
        func canRedo() -> Bool {
            return !redoStack.isEmpty
        }
    }
    
    class CustomTextView: UITextView {
    
        private let undoRedoManager = UndoRedoManager()
    
        override var canBecomeFirstResponder: Bool {
            return true
        }
    
        func undo() {
            if let newText = undoRedoManager.undo() {
                self.text = newText
            }
        }
    
        func redo() {
            if let newText = undoRedoManager.redo() {
                self.text = newText
            }
        }
    
        override func insertText(_ text: String) {
            super.insertText(text)
            undoRedoManager.addToUndoStack(self.text)
        }
    }
    

    Next, create an instance of CustomTextView and set it up:

    let customTextView = CustomTextView()
    customTextView.frame = CGRect(x: 0, y: 0, width: 300, height: 200)
    customTextView.backgroundColor = .lightGray
    customTextView.isEditable = true
    customTextView.text = "Start typing here..."
    

    Finally, add undo and redo buttons to trigger the corresponding actions:

    let undoButton = UIButton(frame: CGRect(x: 50, y: 250, width: 100, height: 50))
    undoButton.setTitle("Undo", for: .normal)
    undoButton.backgroundColor = .blue
    undoButton.addTarget(customTextView, action: #selector(customTextView.undo), for: .touchUpInside)
    
    let redoButton = UIButton(frame: CGRect(x: 200, y: 250, width: 100, height: 50))
    redoButton.setTitle("Redo", for: .normal)
    redoButton.backgroundColor = .green
    redoButton.addTarget(customTextView, action: #selector(customTextView.redo), for: .touchUpInside)
    
    view.addSubview(customTextView)
    view.addSubview(undoButton)
    view.addSubview(redoButton)
    

    That’s how you do it

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search