Handling live text reload elegantly

In my current project at my day job, we are using Firebase and ReSwift. I plan to write more about this powerful combination soon. One of the major advantages is that it allows us to easily support live reloading of concurrent editing. However, I ran into a problem in long-form text editing. It was impressive to see the text update while someone else edited the same data, but if you were also trying to type, it would get extremely frustrating. With every reload, your cursor would jump to the end of the text, making it nearly impossible to keep working.

One of my favorite podcasts, Runtime, recently mentioned approaches to diffing text. I reached out to Sam Soffes, who pointed me to a simple library he created for this, diff.

Using that library, I was ready to tackle preserving the cursor position and text selection when the underlying text changed. In the hopes that others can benefit from or improve this work, here is the code that I am using:

func updateText(with newString: String?) {
    guard let textView = textView, newString = newString,
      (diffRange, changedText) = diff(textView.text, newString) else { return }
    guard let selectedRange = textView.selectedTextRange else { textView.text = newString; return }
    textView.text = newString

    let cursorOffset = textView.offsetFromPosition(textView.beginningOfDocument, toPosition: selectedRange.start)
    let selectedEndOffset = textView.offsetFromPosition(textView.beginningOfDocument, toPosition: selectedRange.end)
    let selectedRangeLength = selectedEndOffset - cursorOffset

    if selectedEndOffset < diffRange.startIndex {
        // Change is after current cursor
        moveCursorRelativeToBeginning(with: cursorOffset, rangeLength: selectedRangeLength)
    } else if cursorOffset < diffRange.startIndex && selectedEndOffset > diffRange.endIndex {
        // Change occurs within selection
        moveCursorRelativeToBeginning(with: cursorOffset, rangeLength: selectedRangeLength + changedText.characters.count - diffRange.count)
    } else if cursorOffset >= diffRange.endIndex {
        // Change occurs completely before current cursor
        moveCursorRelativeToBeginning(with: cursorOffset + changedText.characters.count - diffRange.count, rangeLength: selectedRangeLength)
    } else if diffRange.startIndex < selectedEndOffset && diffRange.startIndex > cursorOffset {
        // Change starts in middle of selection
        moveCursorRelativeToBeginning(with: cursorOffset, rangeLength: selectedRangeLength - (selectedEndOffset - diffRange.startIndex))
    } else if diffRange.startIndex <= cursorOffset && cursorOffset < diffRange.endIndex {
        // Change is a removal/change over the current cursor position
        let rangeLength = selectedRangeLength - (diffRange.endIndex - cursorOffset)
        moveCursorRelativeToBeginning(with: cursorOffset - (cursorOffset - diffRange.startIndex) + changedText.characters.count, rangeLength: rangeLength > 0 ? rangeLength : 0)
    }
}

private func moveCursorRelativeToBeginning(with offset: Int, rangeLength: Int = 0) {
    guard let textView = textView, startPosition = textView.positionFromPosition(textView.beginningOfDocument, offset: offset), endPosition = textView.positionFromPosition(startPosition, offset: rangeLength) else { return }
    textView.selectedTextRange = textView.textRangeFromPosition(startPosition, toPosition: endPosition)
}

This is even more useful when combined with the automated tests that ensure that it is working properly. Here are all of the tests:

import XCTest
import Nimble
import Diff
@testable import align

class TextEditingSpec: XCTestCase {

    var textEditing: TextEditing!

    override func setUp() {
        super.setUp()
        textEditing = TextEditing.initializeFromStoryboard()
        let _ = textEditing.view
        textEditing.textView = UITextView()
    }

    /// test that it loads properly
    func testThatItLoadsProperly() {
        expect(self.textEditing.textView).toNot(beNil())
        expect(self.textEditing.textView?.text) == ""
        expect(self.textEditing.title).to(beNil())
    }


    // MARK: - Cursor position tests

    // Original text: "Watch Bugger attack videos together and discuss strategy."

    /// test that cursor position does not change if state changes but agenda is unchanged
    func testThatCursorPositionDoesNotChangeIfStateChangesButAgendaIsUnchanged() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "Watch Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0
}

    /// test that cursor position does not change when agenda has changes after cursor
    func testThatCursorPositionDoesNotChangeWhenAgendaHasChangesAfterCursor() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "Watch Bugger attack videos together.")
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor position changes when agenda has removed text before current cursor position
    func testThatCursorPositionChangesWhenAgendaHasRemovedTextBeforeCurrentCursorPosition() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 4
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor position changes when agenda has changed text before current cursor position
    func testThatCursorPositionChangesWhenAgendaHasChangedTextBeforeCurrentCursorPosition() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "View Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 9
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor position changes when agenda has removed text that includes current cursor position
    func testThatCursorPositionChangesWhenAgendaHasRemovedTextThatIncludesCurrentCursorPosition() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "Watch attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor position changes when agenda has changed text that includes current cursor position
    func testThatCursorPositionChangesWhenAgendaHasChangedTextThatIncludesCurrentCursorPosition() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "View recorded Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 18
        expect(self.selectedRangeLength()) == 0
    }


    // MARK: - Selected text tests

    /// test that text selection does not changes when text does not change
    func testThatTextSelectionDoesNotChangesWhenTextDoesNotChange() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6
    }

    /// test that text selection does not change when text changes occur after selection
    func testThatTextSelectionDoesNotChangeWhenTextChangesOccurAfterSelection() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bugger attack videos and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6
    }

    /// test that text selection remains same but moves when text is added before selection
    func testThatTextSelectionRemainsSameButMovesWhenTextIsAddedBeforeSelection() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch the Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 6
    }

    /// test that text selection adjusts to include changes that occur within the selection
    func testThatTextSelectionAdjustsToIncludeChangesThatOccurWithinTheSelection() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bear attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 4
    }

    /// test that text selection expands to include additions that occur within the selection
    func testThatTextSelectionExpandsToIncludeAdditionsThatOccurWithinTheSelection() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Big bad bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 14
    }

    /// test that text selection is truncated when the end of the selection is removed
    func testThatTextSelectionIsTruncatedWhenTheEndOfTheSelectionIsRemoved() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bug attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 3
    }

    /// test that text selection is truncated when the end of the selection is changed
    func testThatTextSelectionIsTruncatedWhenTheEndOfTheSelectionIsChanged() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bug vehicle attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 3
    }

    /// test that text selection is truncated when the beginning of the selection is removed
    func testThatTextSelectionIsTruncatedWhenTheBeginningOfTheSelectionIsRemoved() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch er attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 2
    }

    /// test that text selection is truncated and moved when the beginning of the selection is changed
    func testThatTextSelectionIsTruncatedAndMovedWhenTheBeginningOfTheSelectionIsChanged() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watching some tiger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 16
        expect(self.selectedRangeLength()) == 3
    }

    /// test that cursor does not move when the exact selection is removed
    func testThatCursorDoesNotMoveWhenTheExactSelectionIsRemoved() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor is moved when entire selection is removed
    func testThatCursorIsMovedWhenEntireSelectionIsRemoved() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Wattack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 3
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor is moved when entire selection is changed
    func testThatCursorIsMovedWhenEntireSelectionIsChanged() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watching attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 8
        expect(self.selectedRangeLength()) == 0
    }

}


// MARK: - Private functions

private extension TextEditingSpec {

    private func cursorOffset() -> Int {
        guard let textView = textEditing.textView, selectedRange = textView.selectedTextRange else { return 0 }
        return textView.offsetFromPosition(textView.beginningOfDocument, toPosition: selectedRange.start)
    }

    private func selectedRangeLength() -> Int {
        guard let textView = textEditing.textView, selectedRange = textView.selectedTextRange else { return 0 }
        return textView.offsetFromPosition(textView.beginningOfDocument, toPosition: selectedRange.end) - cursorOffset()
    }

    private func moveCursorRelativeToBeginning(with offset: Int, length: Int = 0) {
        guard let textView = textEditing.textView, startPosition = textView.positionFromPosition(textView.beginningOfDocument, offset: offset), endPosition = textView.positionFromPosition(startPosition, offset: length) else { return }
        textView.selectedTextRange = textView.textRangeFromPosition(startPosition, toPosition: endPosition)
    }

}

And in case it is easier to consume, I created a Gist with all of the code.

Edit: Since writing this, I decided to pull all of this code into a simple library: TextMagic.

Related Posts

Automating Scripture Quotes

Using Workflow to extract markdown quotes of scriptures

Getting started with iOS development

A basic set of resources for those considering learning to code iOS apps

Updating layouts for iPhone X

An ongoing summary of lessons learned from updating designs for the iPhone X

Simpler blogging with Working Copy

Combining Workflow and Working Copy makes for powerful, easy blogging from iOS with a GitHub Pages blog

Public drafts with a GitHub Pages blog

Improve the process of publishing from anywhere by previewing drafts on your actual site