Last active
April 5, 2024 14:08
-
-
Save benjaminsnorris/710d22ef066ae249156f7f959be7debe to your computer and use it in GitHub Desktop.
Live reload of text in UITextView while preserving cursor position and text selection
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
class TextEditing: UIViewController { | |
@IBOutlet weak var textView: UITextView? | |
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 file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Are you using Diff ?