Skip to content

Commit c7326e2

Browse files
authored
Improves timing when notifying input delegate of selection changes (Thanks Alexander Blach (@blach)!) (#129)
* Renames willBeginEditingFromNonEditableTextInteraction to isPerformingNonEditableTextInteraction * Removes sendSelectionChangedToTextSelectionView * Notifies of selection changes in layoutSubviews() * Disables "Based on dependency analysis" * Disables SwiftLint warning
1 parent ea607d9 commit c7326e2

2 files changed

Lines changed: 38 additions & 55 deletions

File tree

Sources/Runestone/TextView/TextInput/TextInputView.swift

Lines changed: 20 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ final class TextInputView: UIView, UITextInput {
3131
}
3232
}
3333
set {
34-
// We should not use this setter. It's intended for UIKit to use. It'll invoke the setter in various scenarios, for example when navigating the text using the keyboard.
34+
// We should not use this setter. It's intended for UIKit to use.
35+
// It'll invoke the setter in various scenarios, for example when navigating the text using the keyboard.
3536
let newRange = (newValue as? IndexedRange)?.range
3637
if newRange != _selectedRange {
38+
shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = true
3739
_selectedRange = newRange
38-
inputDelegate?.selectionDidChange(self)
3940
delegate?.textInputViewDidChangeSelection(self)
4041
}
4142
}
@@ -257,13 +258,11 @@ final class TextInputView: UIView, UITextInput {
257258
var indentStrategy: IndentStrategy = .tab(length: 2) {
258259
didSet {
259260
if indentStrategy != oldValue {
260-
inputDelegate?.selectionWillChange(self)
261261
indentController.indentStrategy = indentStrategy
262262
layoutManager.tabWidth = indentController.tabWidth
263263
layoutManager.setNeedsLayout()
264264
setNeedsLayout()
265265
layoutIfNeeded()
266-
inputDelegate?.selectionDidChange(self)
267266
}
268267
}
269268
}
@@ -313,7 +312,6 @@ final class TextInputView: UIView, UITextInput {
313312
layoutManager.invalidateLines()
314313
layoutManager.setNeedsLayout()
315314
layoutManager.layoutIfNeeded()
316-
sendSelectionChangedToTextSelectionView()
317315
}
318316
}
319317
}
@@ -327,7 +325,6 @@ final class TextInputView: UIView, UITextInput {
327325
layoutManager.invalidateLines()
328326
layoutManager.setNeedsLayout()
329327
layoutManager.layoutIfNeeded()
330-
sendSelectionChangedToTextSelectionView()
331328
}
332329
}
333330
}
@@ -454,9 +451,7 @@ final class TextInputView: UIView, UITextInput {
454451
}
455452
set {
456453
if newValue != _selectedRange {
457-
inputDelegate?.selectionWillChange(self)
458454
_selectedRange = newValue
459-
inputDelegate?.selectionDidChange(self)
460455
delegate?.textInputViewDidChangeSelection(self)
461456
}
462457
}
@@ -546,6 +541,8 @@ final class TextInputView: UIView, UITextInput {
546541
}
547542
private var hasPendingFullLayout = false
548543
private let editMenuController = EditMenuController()
544+
// swiftlint:disable:next identifier_name
545+
private var shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = false
549546

550547
// MARK: - Lifecycle
551548
init(theme: Theme) {
@@ -606,6 +603,13 @@ final class TextInputView: UIView, UITextInput {
606603
layoutManager.layoutIfNeeded()
607604
layoutManager.layoutLineSelectionIfNeeded()
608605
layoutPageGuideIfNeeded()
606+
// We notify the input delegate about selection changes in layoutSubviews so we have a chance of disabling notifying the input delegate during an editing operation.
607+
// We will sometimes disable notifying the input delegate when the user enters Korean text.
608+
// This workaround is inspired by a dialog with Alexander Blach (@lextar), developer of Textastic.
609+
if shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews {
610+
inputDelegate?.selectionWillChange(self)
611+
inputDelegate?.selectionDidChange(self)
612+
}
609613
}
610614

611615
override func copy(_ sender: Any?) {
@@ -617,18 +621,14 @@ final class TextInputView: UIView, UITextInput {
617621
override func paste(_ sender: Any?) {
618622
if let selectedTextRange = selectedTextRange, let string = UIPasteboard.general.string {
619623
let preparedText = prepareTextForInsertion(string)
620-
inputDelegate?.selectionWillChange(self)
621624
replace(selectedTextRange, withText: preparedText)
622-
inputDelegate?.selectionDidChange(self)
623625
}
624626
}
625627

626628
override func cut(_ sender: Any?) {
627629
if let selectedTextRange = selectedTextRange, let text = text(in: selectedTextRange) {
628630
UIPasteboard.general.string = text
629-
inputDelegate?.selectionWillChange(self)
630631
replace(selectedTextRange, withText: "")
631-
inputDelegate?.selectionDidChange(self)
632632
}
633633
}
634634

@@ -872,11 +872,9 @@ private extension TextInputView {
872872
}
873873

874874
private func performFullLayout() {
875-
inputDelegate?.selectionWillChange(self)
876875
layoutManager.invalidateLines()
877876
layoutManager.setNeedsLayout()
878877
layoutManager.layoutIfNeeded()
879-
inputDelegate?.selectionDidChange(self)
880878
}
881879
}
882880

@@ -981,6 +979,8 @@ extension TextInputView {
981979
guard shouldChangeText(in: deleteRange, replacementText: "") else {
982980
return
983981
}
982+
// Disable notifying delegate in layout subviews to prevent issues when entering Korean text. This workaround is inspired by a dialog with Alexander Black (@lextar), developer of Textastic.
983+
shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = false
984984
// Just before calling deleteBackward(), UIKit will set the selected range to a range of length 1, if the selected range has a length of 0.
985985
// In that case we want to undo to a selected range of length 0, so we construct our range here and pass it all the way to the undo operation.
986986
let selectedRangeAfterUndo: NSRange
@@ -994,8 +994,10 @@ extension TextInputView {
994994
timedUndoManager.endUndoGrouping()
995995
timedUndoManager.beginUndoGrouping()
996996
}
997+
// We've set shouldNotifyInputDelegateAboutSelectionChange to false so we must notify the input delegate ourselves.
998+
inputDelegate?.selectionWillChange(self)
997999
replaceText(in: deleteRange, with: "", selectedRangeAfterUndo: selectedRangeAfterUndo)
998-
sendSelectionChangedToTextSelectionView()
1000+
inputDelegate?.selectionDidChange(self)
9991001
if isDeletingMultipleCharacters {
10001002
timedUndoManager.endUndoGrouping()
10011003
}
@@ -1139,8 +1141,8 @@ extension TextInputView {
11391141
timedUndoManager.registerUndo(withTarget: self) { textInputView in
11401142
textInputView.inputDelegate?.selectionWillChange(textInputView)
11411143
textInputView.replaceText(in: range, with: text)
1142-
textInputView.inputDelegate?.selectionDidChange(textInputView)
11431144
textInputView.selectedRange = oldSelectedRange
1145+
textInputView.inputDelegate?.selectionDidChange(textInputView)
11441146
}
11451147
}
11461148

@@ -1171,26 +1173,6 @@ extension TextInputView {
11711173
return NSRange(location: cappedLocation, length: cappedLength)
11721174
}
11731175

1174-
func sendSelectionChangedToTextSelectionView() {
1175-
// Fores the position of the caret to be updated. Normally we can do this by notifying the input delegate when changing the selected range like:
1176-
//
1177-
// inputDelegate?.selectionWillChange(self)
1178-
// selectedRange = newSelectedRange
1179-
// inputDelegate?.selectionDidChange(self)
1180-
//
1181-
// If we don't notify the input delegate when the setter on selectedTextRange is called, then the location of the caret will not be updated.
1182-
// However, if we do notify the delegate when the setter is called, Korean input will no longer work as described in https://github.com/simonbs/Runestone/issues/11
1183-
// So the workaround is to not notify the delegate but tell the text selection view directly that the selection has changed.
1184-
if let textSelectionView = textSelectionView {
1185-
let sel = NSSelectorFromString("selectionChanged")
1186-
if textSelectionView.responds(to: sel) {
1187-
textSelectionView.perform(sel)
1188-
} else {
1189-
print("\(textSelectionView) does not respond to 'selectionChanged'")
1190-
}
1191-
}
1192-
}
1193-
11941176
private func moveCaret(to linePosition: LinePosition) {
11951177
// By restoring the selected range using the old line position we can better preserve the old selected language.
11961178
let line = lineManager.line(atRow: linePosition.row)
@@ -1294,14 +1276,14 @@ extension TextInputView {
12941276
return
12951277
}
12961278
markedRange = markedText.isEmpty ? nil : NSRange(location: range.location, length: markedText.utf16.count)
1297-
inputDelegate?.selectionWillChange(self)
12981279
replaceText(in: range, with: markedText)
1299-
inputDelegate?.selectionDidChange(self)
13001280
delegate?.textInputViewDidUpdateMarkedRange(self)
13011281
}
13021282

13031283
func unmarkText() {
1284+
inputDelegate?.selectionWillChange(self)
13041285
markedRange = nil
1286+
inputDelegate?.selectionDidChange(self)
13051287
delegate?.textInputViewDidUpdateMarkedRange(self)
13061288
}
13071289
}
@@ -1501,9 +1483,7 @@ extension TextInputView: LayoutManagerDelegate {
15011483
// MARK: - IndentControllerDelegate
15021484
extension TextInputView: IndentControllerDelegate {
15031485
func indentController(_ controller: IndentController, shouldInsert text: String, in range: NSRange) {
1504-
inputDelegate?.selectionWillChange(self)
15051486
replaceText(in: range, with: text)
1506-
inputDelegate?.selectionDidChange(self)
15071487
}
15081488

15091489
func indentController(_ controller: IndentController, shouldSelect range: NSRange) {

Sources/Runestone/TextView/TextView.swift

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ open class TextView: UIScrollView {
584584
private let tapGestureRecognizer = QuickTapGestureRecognizer()
585585
private var _inputAccessoryView: UIView?
586586
private let _inputAssistantItem = UITextInputAssistantItem()
587-
private var willBeginEditingFromNonEditableTextInteraction = false
587+
private var isPerformingNonEditableTextInteraction = false
588588
private var delegateAllowsEditingToBegin: Bool {
589589
guard isEditable else {
590590
return false
@@ -671,8 +671,6 @@ open class TextView: UIScrollView {
671671
@discardableResult
672672
override open func becomeFirstResponder() -> Bool {
673673
if !isEditing && delegateAllowsEditingToBegin {
674-
// Reset willBeginEditingFromNonEditableTextInteraction to support calling becomeFirstResponder() programmatically.
675-
willBeginEditingFromNonEditableTextInteraction = false
676674
_ = textInputView.resignFirstResponder()
677675
_ = textInputView.becomeFirstResponder()
678676
return true
@@ -735,30 +733,30 @@ open class TextView: UIScrollView {
735733
/// Inserts text at the location of the caret or, if no selection or caret is present, at the end of the text.
736734
/// - Parameter text: A string to insert.
737735
open func insertText(_ text: String) {
736+
textInputView.inputDelegate?.selectionWillChange(textInputView)
738737
textInputView.insertText(text)
739-
// Called in TextView since we only want to force the text selection view to update when editing text programmatically.
740-
textInputView.sendSelectionChangedToTextSelectionView()
738+
textInputView.inputDelegate?.selectionDidChange(textInputView)
741739
}
742740

743741
/// Replaces the text that is in the specified range.
744742
/// - Parameters:
745743
/// - range: A range of text in the document.
746744
/// - text: A string to replace the text in range.
747745
open func replace(_ range: UITextRange, withText text: String) {
746+
textInputView.inputDelegate?.selectionWillChange(textInputView)
748747
textInputView.replace(range, withText: text)
749-
// Called in TextView since we only want to force the text selection view to update when editing text programmatically.
750-
textInputView.sendSelectionChangedToTextSelectionView()
748+
textInputView.inputDelegate?.selectionDidChange(textInputView)
751749
}
752750

753751
/// Replaces the text that is in the specified range.
754752
/// - Parameters:
755753
/// - range: A range of text in the document.
756754
/// - text: A string to replace the text in range.
757755
public func replace(_ range: NSRange, withText text: String) {
756+
textInputView.inputDelegate?.selectionWillChange(textInputView)
758757
let indexedRange = IndexedRange(range)
759758
textInputView.replace(indexedRange, withText: text)
760-
// Called in TextView since we only want to force the text selection view to update when editing text programmatically.
761-
textInputView.sendSelectionChangedToTextSelectionView()
759+
textInputView.inputDelegate?.selectionDidChange(textInputView)
762760
}
763761

764762
/// Replaces the text in the specified matches.
@@ -1079,7 +1077,6 @@ private extension TextView {
10791077
return
10801078
}
10811079
if gestureRecognizer.state == .ended {
1082-
willBeginEditingFromNonEditableTextInteraction = false
10831080
let point = gestureRecognizer.location(in: textInputView)
10841081
let oldSelectedRange = textInputView.selectedRange
10851082
textInputView.moveCaret(to: point)
@@ -1240,24 +1237,24 @@ extension TextView: TextInputViewDelegate {
12401237
guard isEditable else {
12411238
return
12421239
}
1243-
isEditing = !willBeginEditingFromNonEditableTextInteraction
1240+
isEditing = !isPerformingNonEditableTextInteraction
12441241
// If a developer is programmatically calling becomeFirstresponder() then we might not have a selected range.
12451242
// We set the selectedRange instead of the selectedTextRange to avoid invoking any delegates.
1246-
if textInputView.selectedRange == nil && !willBeginEditingFromNonEditableTextInteraction {
1243+
if textInputView.selectedRange == nil && !isPerformingNonEditableTextInteraction {
12471244
textInputView.selectedRange = NSRange(location: 0, length: 0)
12481245
}
12491246
// Ensure selection is laid out without animation.
12501247
UIView.performWithoutAnimation {
12511248
textInputView.layoutIfNeeded()
12521249
}
12531250
// The editable interaction must be installed early in the -becomeFirstResponder() call
1254-
if !willBeginEditingFromNonEditableTextInteraction {
1251+
if !isPerformingNonEditableTextInteraction {
12551252
installEditableInteraction()
12561253
}
12571254
}
12581255

12591256
func textInputViewDidBeginEditing(_ view: TextInputView) {
1260-
if !willBeginEditingFromNonEditableTextInteraction {
1257+
if !isPerformingNonEditableTextInteraction {
12611258
editorDelegate?.textViewDidBeginEditing(self)
12621259
}
12631260
}
@@ -1425,7 +1422,13 @@ extension TextView: UITextInteractionDelegate {
14251422
// When long-pressing our instance of UITextInput, the UITextInteraction will make the text input first responder.
14261423
// In this case the user wants to select text in the text view but not start editing, so we set a flag that tells us
14271424
// that we should not install editable text interaction in this case.
1428-
willBeginEditingFromNonEditableTextInteraction = true
1425+
isPerformingNonEditableTextInteraction = true
1426+
}
1427+
}
1428+
1429+
public func interactionDidEnd(_ interaction: UITextInteraction) {
1430+
if interaction.textInteractionMode == .nonEditable {
1431+
isPerformingNonEditableTextInteraction = false
14291432
}
14301433
}
14311434
}

0 commit comments

Comments
 (0)