How can I integrate my own text changes into UITextView's undo manager?

I have an app that uses UITextView for some text editing. I have some custom operations I can do on the text that I want to be able to undo, and I'm representing those operations in a way that plugs into NSUndoManager nicely. For example, if I have a button that appends an emoji to the text, it looks something like this:

func addEmoji() {
  let inserting = NSAttributedString(string: "😀")
  self.textStorage.append(inserting)

  let len = inserting.length
  let range = NSRange(location: self.textStorage.length - len, length: len)
  self.undoManager?.registerUndo(withTarget: self, handler: { view in 
    view.textStorage.deleteCharacters(in: range)
  }
}

My goal is something like this:

  1. Type some text
  2. Press the emoji button to add the emoji
  3. Trigger undo (via gesture or keyboard shortcut) and the emoji is removed
  4. Trigger undo again and the typing from step 1 is reversed

If I just type and then trigger undo, the typing is reversed as you'd expect. And if I just add the emoji and trigger undo, the emoji is removed. But if I do the sequence above, step 3 works but step 4 doesn't. The emoji is removed but the typing isn't reversed.

Notably, if step 3 only changes attributes of the text, like applying a strikethrough to a selection, then the full undo chain works. I can type, apply strikethrough, undo strikethrough, and undo typing.

It's almost as if changing the text invalidates the undo manager's previous operations?

How do I insert my own changes into UITextView's NSUndoManager without invalidating its chain of other operations?

I've posted sample code here: https://github.com/tomhamming/TextViewUndo

By modifying a UITextView's textStorage directly, you're circumventing the middle layer that tracks updates and deletions to the text view.

Instead, I would suggest calling the various methods on UITextInput to update the underlying text storage in order to keep the undo manager in a consistent state. For example, insertText(_:) for inserting text, or replace(_:withText:) for replacing.

If you need to plumb attributes through, you may need to do this by setting typingAttributes before calling insertText(_:) or replace(_:withText:).

Hello! Thanks for the reply. Just using insertText on UIKeyInput doesn't quite work for me, because I need to insert attributed strings.

My full use case is implementing bulleted and numbered lists without using NSTextList (because I have to support OS versions before support for that), and selecting multiple lines and doing things like changing the indent level or removing list styling. The user taps one button and I have to add/remove/change list prefixes and change paragraph style attributes, and I want that to be undoable in one step. I have it set up so I can make those changes and undo and then replay it as I wish, but when I do it erases the undo manager stack.

@Mr. Jefferson , did this ever get solved for you? Did you find your own solution or find one elsewhere?

@Frameworks Engineer, do you have a follow-up to OP's reply to your comment?

I ask because I'm having the exact same issue. I'm coding a word processing app with custom behavior that requires running checks before deleting, and then changing attributes around/near the deletion point after deleting. (I have some other custom behaviors that are affected by this same issue but for simplicity will just refer to the deletion one.)

I want to be able to use default NSTextView behaviors that already come with "free" undo/redo (e.g., typing, deleteBackward(:), insertNewline(:), but then want to modify attributes for a certain range of the textStorage afterward. I register my attribute modifications with the undoManager. Like OP, I can get undo/redo to work flawlessly but only when using only NSTextView default behavior OR when using only my modifications/undos to the NSTextStorage. The second I start mixing both, things get ugly. My code looks like this (note: this is for macOS/AppKit but I have the exact same issue so wherever my error lies, it seems cross-platform in scope):

//...
let oldTypingAttributes = textView.typingAttributes
let oldSelectedRange = textView.selectedRange
let selectedRangePostDelete: NSRange

if selectedRange.length == 0 {
    // If selection is zero length, then deletion will result in the caret moving one position left
    selectedRangePostDelete = NSMakeRange((oldSelectedRange.location - 1), 0)
} else {
    // Otherwise, deletion will result in the caret being positioned at the start of the originally-selected range
    selectedRangePostDelete = NSMakeRange(oldSelectedRange.location, 0)
}

undoManager?.beginUndoGrouping()

textView.deleteBackward(nil)

// Note: I have also tried setting selectedRangePostDelete HERE - after deleteBackward - by calling textView.selectedRange, but I opted to move it earlier to be certain that my issue wasn't caused by run loop timing issues i.e., to be sure I wasn't querying textView.selectedRange before it had been updated post-deletion... all this said, the issue occurs either way

let changeAttributesRange = NSMakeRange(selectedRangePostDelete.location, aLength)
let oldAttributedString = textStorage.attributedSubstring(from: changeAttributesRange)

textStorage.beginEditing()
textStorage.addAttributes(newAttributes, range: changeAttributesRange)
textStorage.endEditing()

textView.selectedRange = selectedRangePostDelete

let undoHandler: (MyCustomViewRepresentable.Coordinator) -> Void = { [oldAttributedString = oldAttributedString, changeAttributesRange = changeAttributesRange, oldTypingAttributes = oldTypingAttributes, changeAttributesRange = changeAttributesRange] target in
    textStorage.beginEditing()
    target.textStorage.replaceCharacters(in: changeAttributesRange, with: oldAttributedString)
    textStorage.endEditing()

    // I have tried setting selection range here in the undo handler to either selectedRangePostDelete or oldSelectedRange. I have also tried setting typingAttributes to oldTypingAttributes. Neither seems to have an effect on the issue I'm seeing.
} // end undoHandler declaration

undoManager?.registerUndo(withTarget: self, handler: undoHandler)
undoManager?.setActionName("Typing")

undoManager?.endUndoGrouping()
//...

With this code, the undo/redo stack seems to get corrupted. Behavior will appear as expected for a few cycles of undo/redo but ultimately starts to produce unwanted behavior after a few cycles.

My use case precludes me from relying only on NSTextView behavior. So I need to modify the underlying textStorage at times. I realize that I could simply make ALL my changes by modifying textStorage only and prevent/override any default NSTextView modifications to the text, but I really don't want to have to reinvent the wheel if I don't have to. Completely coding my own implementation that modifies textStorage directly for all possible keyDown events that would edit text would be a bit absurd and seemingly unnecessary. I'd love to use NSTextView's standard behavior whenever it's applicable, which is 90% of the time in my case.

So I'm desperate to figure out how I can I make NSTextView standard behavior and custom NSTextStorage modification play nice together and fluidly support undo/redo. Any solution has escaped me thus far. I felt relieved to find someone struggling with the same issue!

For further context, before calling the above block of code, I am overriding my NSTextView's doCommand(by selector: Selector) and then running

...
if selector == #selector(deleteBackward(_:)) {
    coordinator.handleDelete(for: self)
    return
}
...

Where handleDelete(for:) calls the big block of code above, and where coordinator is the coordinator for my custom NSViewRepresentable class, which builds a view to provide to SwiftUI. The NSViewRepresentable view hierarchy contains the NSTextView in question.

Note: if you're wondering why in my undo handler closure I call replaceCharacters(in: range, with: oldAttributedSubstring) rather than something like addAttributes(oldAttributes)... it's because at the time of the original addAttributes call, string attributes may differ at various ranges/locations within the range of textStorage in question. To fully restore the textStorage to its original state, I have to completely replace the original attributedSubstring for that range).

How can I integrate my own text changes into UITextView's undo manager?
 
 
Q